diff --git a/.task/archive/feature-norm-remediation-u4.md b/.task/archive/feature-norm-remediation-u4.md new file mode 100644 index 00000000..ffb61683 --- /dev/null +++ b/.task/archive/feature-norm-remediation-u4.md @@ -0,0 +1,261 @@ +# feature/norm-remediation-u4 任务卡 + +任务主记录:PR #621 https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/621;状态:已完成(合入前闭合);owner:weichen + +- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= eef7e5e778cabcb6216f74b74213a3d7511f383b +- 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4(worktree);分支:feature/norm-remediation-u4 +- 分支类型:child;initiative_id:feature-norm-remediation;root_target:main;immediate_target:feature/norm-remediation;accepting_requirements:false;parent_branch:feature/norm-remediation;parent_head_sha:6cee4c5728f1c5e723e599986f1f0e4372ec3032;parent_pr:无;parent_source:origin +- 需求分流:免(父 initiative 子任务登记已列出 feature/norm-remediation-u4;parent_head_sha=6cee4c5728f1c5e723e599986f1f0e4372ec3032) + +- 授权与交付终点:按用户批准的 U1-U7 规范整改计划实施 U4,并完成 Review、测试、PR/CI 后合入 initiative。范围限 `policy/facts.md`、`scripts/facts-verify.sh`、`tests/test_facts_verify.py`;不接触真实凭据、不跑现场事实刷新、不修改平台或其他 U-ID。 +- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进) +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md) +- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) +- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) + +## 目标与非目标 + +目标:F2 的验证路径不让展开后的 token 进入子进程 argv、输出或证据;F4 验证只刷新它能证明的事实,DNS/超时/泛化网络错误不能刷新 TLS 特定原因。复用已有 token-safe 请求模式,回归使用合成 HOME/IMC 凭据、桩客户端和临时表,验证正常/异常及 --update 日期语义。 + +计划:U1-U7 规范整改实施计划 U4(唯一正文位于 `Agent-System-Dev-wt-feature-norm-remediation-plan/docs/superpowers/plans/2026-10-18-0849-fix-development-norm-remediation-plan.md`)。实际 child 代码基线为 parent_head_sha `6cee4c5`;启动收据的 R0 为当时最新 root main 快照,二者不混同。 + +## Agent 实施收据 + +路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。 + +| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---|---| +| norm-u4-safe-credential-truthful-facts | facts policy/probes/offline tests | caefcb72 | implementation | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 7e0b0e4ff8c5 | 0 | /tmp/norm-u4-facts-receipt.json | +| norm-u4-credential-destination-repair | exact destination and redirect negative cases | caefcb72 | repair | cursor, agy, pi | 1 | cursor-agent-stable / auto | success | 39834ead7d5c | 0 | /tmp/norm-u4-credential-boundary-repair-receipt.json | +| norm-u4-security-correctness-review | independent fixed-diff review and offline replay | caefcb72 | review | pi, agy, cursor, codex | 1 | pi / minimax-cn/MiniMax-M3 | success | 25cdc6adc848 | 0 | /tmp/norm-u4-independent-review-receipt.json | + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | +|---|---|---|---| +| 启动 | eef7e5e778cabcb6216f74b74213a3d7511f383b | 6cee4c5728f1c5e723e599986f1f0e4372ec3032 | 2026-10-18T17:17:00Z | + +## 证据(Evidence) + + +| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) | +|---|---|---|---|---|---| + +## 结果与验证 + +- 结果:F2 token 仅在进程内读取/发送,不进入 child argv/输出/收据;端点在读取凭据前逐字核对,不跟随 301/302/303/307/308 重定向。F4 将退役禁令与现场 TLS 现象分开,DNS/超时/连接错误/握手成功均不刷新 TLS 特定事实;历史日期明确不证明新判据。 +- 验证:host 在代码候选 `39834ead7d5cae48477621b5e68f737ae0082e65` 重放 8 项离线事实测试、shell syntax/diff 检查通过;`make test-governance` 执行 503 项(18 项 skip),无失败(SELF,完整收据经 evidence 导入)。独立 PI Review 同 head 重放 8 项并判无阻塞项。测试先红后绿证据在各 worker receipt;所有用例只使用合成 HOME/IMC 和客户端桩,不读真实凭据、不访问 Gitea。 +- 影响与剩余项:没有刷新任何真实 last_verified 日期;无部署、凭据轮换、平台或其他范围修改。F4 若需重新证明当前 TLS 现象应另行现场重验,不能沿用旧宽泛失败。 + +## 合入与清理 + +- Review 记录(独立 fresh CLI session,2026-10-18):PI / minimax-cn / MiniMax-M3;host 按 run `03b4578a94b710425f11bd74df523a20`、attempt `d0bfc2e77832` 核验新进程及独立 Review,执行器本地记录为 SELF,不冒充 CI/GATE 身份; + base/head=6cee4c5728f1c5e723e599986f1f0e4372ec3032..39834ead7d5cae48477621b5e68f737ae0082e65, + tested_head=39834ead7d5cae48477621b5e68f737ae0082e65(`git rev-parse HEAD` 已对账); + 离线 focused test `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_facts_verify -v` + → 8 tests in 4.608s OK(test_f2_pass_sends_home_token_in_process_and_leaks_nowhere / test_f2_missing_or_malformed_credentials_fail_without_echo / test_f2_rejected_or_failing_request_does_not_refresh_or_leak / test_f2_rejects_unregistered_destination_before_reading_credentials / test_f2_refuses_redirect_without_forwarding_authorization / test_f4_only_tls_handshake_failure_passes_and_refreshes / test_f4_helper_reports_each_condition_distinctly / test_registry_separates_retired_endpoint_policy_from_tls_observation); + bash -n scripts/facts-verify.sh 无语法错误。 + 验收逐项: + · HOME token 仅进程内经 Authorization 头发送,child argv/stderr/stdout/收据/登记表中不存在 HOME/IMC 真实凭据值:test_f2_pass 断言 http 拦截仅 1 次且 url=https://git-home.u1680832.nyat.app:30319/api/v1/user,authorization=token ;assert_no_secrets 覆盖子进程 argv、PATH 桩 curl/wget argv、stdout/stderr、收据 JSON、登记表; + · 凭据缺失/格式错/文件缺失三条 subTest 均 FAIL,http 拦截=[] 即未发请求,stderr 含 "ACCESS_TOKEN" 但不含 token 字面值,且 `--update` 不刷新 F2 日期(保持 OLD_DATE):test_f2_missing_or_malformed_credentials_fail_without_echo; + · URL 校验 11 个变体(含 http://、不同端口、userinfo、userinfo@、fragment、查询串、路径尾缀 /../、路径变体、尾随空格)全部 exit 6 且无 http 拦截,且 ACCESS_TOKEN 不在 stderr(端点先于凭据校验):test_f2_rejects_unregistered_destination_before_reading_credentials;脚本侧 `if sys.argv[2] != ENDPOINT: ... sys.exit(6)` 是逐字字符串等价,避开任何 URL 归一化; + · 301/302/303/307/308 五种全不转发 Authorization:`NoRedirect(HTTPRedirectHandler).redirect_request` 固定返回 None,stub 拦截 `[r["url"] for r in requests]==["https://git-home.u1680832.nyat.app:30319/api/v1/user"]` 无后续请求、收不到 redirect-target 主机名,且 `--update` 模式下 F2 仍是 FAIL 且未刷新日期; + · F4 仅 TLS 握手错误判 PASS,dns/timeout/refused/reset/tls-timeout/ok 全 FAIL 且不刷新日期:test_f4_only_tls_handshake_failure_passes_and_refreshes;probe `tls-handshake-fails` 出口 0/3/4/5/6 区分明确,F4 现有 2026-10-14 last_verified 在 `policy/facts.md` 被显式标注为「历史日期…不证明当前 TLS 层握手失败判据;经该判据独立重验前不得当作已核实」,与 F4 行禁用字符串「不得使用」(政策禁令,相对独立)分离(test_registry_separates_retired_endpoint_policy_from_tls_observation); + · 测试全离线:`socket.socket.connect/_ex = _deny` 拒绝未桩接的真实连接,`socket.create_connection`、`ssl.SSLContext.wrap_socket`、`urllib.request.HTTPSHandler/https_open / http_open` 全部经 sitecustomize 桩化;HOME 指向 tmpdir 不读真实 ~/.secrets;PATH 桩化 curl/wget 仅写 argv 日志;全程未触达 Gitea 或旧端点;本评审运行无任何 `--update` 写回真实 `policy/facts.md`。 + · reviewer profile/model 以独立运行事件为依据;Shell=bash,Bash 语法 `bash -n` 通过;Python=3.x,`task_triage.TOKEN_RE`、`task_triage.secrets_file_token()`、`task_triage.http_get_json()`、`task_triage.Stop` 已直接阅读验证其 token 仅入 Authorization 头、异常消息仅含 `type(exc).__name__` 不含凭据。 + 无阻塞项;不修正实现;不在本评审做合入/CI/PR 操作(合规范围内仅写一条 review 记录)。 +- 冲突记录:最新 initiative `c6fe5ad` 已经 task-sync 合入;仅父任务元数据变化,无冲突;实现/测试/事实文件相对已审已测代码候选 `39834ead` 的 diff 为空,既有直接验证仍适用。 +- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果) +- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验) +- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置) + + +```json +{ + "summary_schema": 1, + "source": "SELF", + "timestamp": "2026-10-18T17:16:04.326553+00:00", + "command": "make precheck", + "exit_code": 0, + "cwd": "/home/weichen/projects/Agent-System-Dev", + "before_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "after_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "branch": "main", + "target_branch": "main", + "target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "root_target": "main", + "immediate_target": "main", + "before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "output_tail": [ + "make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev'", + "[repo] branch=main head=eef7e5e778", + "[remote] origin/main=eef7e5e778 ahead=0 behind=0", + "[tree] clean submodules_drift=0 uninit=12", + "[warn] 12 个子模块未初始化——构建/测试前需物化(不阻断写入)", + "[worktrees] linked=8", + "[recycle] merged un-recycled branches: feature/layered-python-env", + "[initiatives] local cards (not authoritative): none", + "[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)", + "[facts] policy/facts.md 6 条事实均在有效期内", + "[required-reading] core/baseline.md, workflows/delivery.md §1, policy/delivery.md", + "[R0] eef7e5e778(完整:eef7e5e778cabcb6216f74b74213a3d7511f383b)", + "[worktree] before-sync: recursive component checks passed", + "[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch", + "make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev'" + ], + "output_sha256": "b1b224c850fc8ccfd988d17caaf1f716e2c2e07ab50d9c5387de9ed292aa9ad0", + "receipt_sha256": "818aca5afe7aca05cc3436a020562d87b532a92ebdee7a828b83a39b417e003c", + "receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/818aca5afe7aca05cc3436a020562d87b532a92ebdee7a828b83a39b417e003c.json" +} +``` + + +```json +{ + "summary_schema": 1, + "source": "SELF", + "timestamp": "2026-10-18T17:22:01.884792+00:00", + "command": "python3 -m unittest tests.test_facts_verify && bash -n scripts/facts-verify.sh && git diff --check", + "exit_code": 0, + "cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4", + "before_head": "7e0b0e4ff8c54e9095d86be133965ab1828a8e3d", + "after_head": "7e0b0e4ff8c54e9095d86be133965ab1828a8e3d", + "branch": "feature/norm-remediation-u4", + "target_branch": "main", + "target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "root_target": "main", + "immediate_target": "feature/norm-remediation", + "before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "output_tail": [ + "......", + "----------------------------------------------------------------------", + "Ran 6 tests in 3.532s", + "", + "OK" + ], + "output_sha256": "0fff5c17193d077cf601d6c73735a79a0074e48107773120f33b8f14d8e71409", + "receipt_sha256": "7101fa8f0bf09c8e8ca06be96351cc1f81f4a13e69363b427a61b49b37ad9941", + "receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/7101fa8f0bf09c8e8ca06be96351cc1f81f4a13e69363b427a61b49b37ad9941.json" +} +``` + + +```json +{ + "summary_schema": 1, + "source": "SELF", + "timestamp": "2026-10-18T17:29:35.268634+00:00", + "command": "python3 -m unittest tests.test_facts_verify && bash -n scripts/facts-verify.sh && git diff --check", + "exit_code": 0, + "cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4", + "before_head": "39834ead7d5cae48477621b5e68f737ae0082e65", + "after_head": "39834ead7d5cae48477621b5e68f737ae0082e65", + "branch": "feature/norm-remediation-u4", + "target_branch": "main", + "target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "root_target": "main", + "immediate_target": "feature/norm-remediation", + "before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "output_tail": [ + "........", + "----------------------------------------------------------------------", + "Ran 8 tests in 4.333s", + "", + "OK" + ], + "output_sha256": "c5b777791a060b3d9c4ef8c01df28662e588ac3c05ccd2de96068357ba5438d3", + "receipt_sha256": "0f6b6ec11bdbe651b8f6f73c82501755916e9f0d495af8cd6c8ddc0dc6e408e1", + "receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/0f6b6ec11bdbe651b8f6f73c82501755916e9f0d495af8cd6c8ddc0dc6e408e1.json" +} +``` + + +```json +{ + "summary_schema": 1, + "source": "SELF", + "timestamp": "2026-10-18T17:30:04.489780+00:00", + "command": "make test-governance", + "exit_code": 0, + "cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4", + "before_head": "39834ead7d5cae48477621b5e68f737ae0082e65", + "after_head": "39834ead7d5cae48477621b5e68f737ae0082e65", + "branch": "feature/norm-remediation-u4", + "target_branch": "main", + "target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "root_target": "main", + "immediate_target": "feature/norm-remediation", + "before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "output_tail": [ + "test_feature_missing_parent_is_allowed (test_write_guard.WriteGuardTests.test_feature_missing_parent_is_allowed) ... ok", + "test_invalid_json_is_denied (test_write_guard.WriteGuardTests.test_invalid_json_is_denied) ... ok", + "test_missing_parent_on_main_is_denied (test_write_guard.WriteGuardTests.test_missing_parent_on_main_is_denied) ... ok", + "test_missing_unknown_and_malformed_write_input_are_denied (test_write_guard.WriteGuardTests.test_missing_unknown_and_malformed_write_input_are_denied) ... ok", + "test_non_git_destination_is_explicitly_outside_scope (test_write_guard.WriteGuardTests.test_non_git_destination_is_explicitly_outside_scope) ... ok", + "test_nonwrite_tools_are_outside_scope (test_write_guard.WriteGuardTests.test_nonwrite_tools_are_outside_scope) ... ok", + "test_relative_path_uses_hook_cwd_not_process_cwd (test_write_guard.WriteGuardTests.test_relative_path_uses_hook_cwd_not_process_cwd) ... ok", + "test_symlink_into_main_with_missing_parents_is_denied (test_write_guard.WriteGuardTests.test_symlink_into_main_with_missing_parents_is_denied) ... ok", + "test_valid_multi_file_patch_on_feature_is_allowed (test_write_guard.WriteGuardTests.test_valid_multi_file_patch_on_feature_is_allowed) ... ok", + "", + "----------------------------------------------------------------------", + "Ran 503 tests in 368.596s", + "", + "OK (skipped=18)", + "make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4'" + ], + "output_sha256": "54c9dc06bb99075654b296b8d494da68390cc635159c9a364bcc4cf9925c84a0", + "receipt_sha256": "3621e9d21fb28c174997b1c2fdbc100eda2ad653ea81d96f6206a36b15bf8ba3", + "receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/3621e9d21fb28c174997b1c2fdbc100eda2ad653ea81d96f6206a36b15bf8ba3.json" +} +``` +| 同步(记录前对象) | c6fe5adba34d7266e7bcfbb9a2c64a0ae61e9b8e | ddad7838535ae06070bfef973106e582c75c6380 | 2026-10-18T17:37:17Z | + + +```json +{ + "summary_schema": 1, + "source": "SELF", + "timestamp": "2026-10-18T17:36:52.906926+00:00", + "command": "make task-sync", + "exit_code": 0, + "cwd": "/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4", + "before_head": "53897dc218b492c1498637cca9a736143793e3df", + "after_head": "e2da2fef9a205584ba5d82a9e70cf43ad294c9db", + "branch": "feature/norm-remediation-u4", + "target_branch": "main", + "target_head": "eef7e5e778cabcb6216f74b74213a3d7511f383b", + "root_target": "main", + "immediate_target": "feature/norm-remediation", + "before_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "after_tracked_diff_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "output_tail": [ + "make[1]: Entering directory '/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4'", + "[worktree] before-sync: recursive component checks passed", + "Merge made by the 'ort' strategy.", + " .task/feature-norm-remediation.md | 16 ++++++++++++++--", + " 1 file changed, 14 insertions(+), 2 deletions(-)", + "[worktree] before-update: recursive component checks passed", + "[worktree] materialize: recursive component checks passed", + "[worktree] after-sync: recursive component checks passed", + "admit: ok", + "[evidence] WARN JSON receipt line 73: unverified: recorded component state was not at its pinned SHA ——关键检查须重跑并以 make evidence 重新写入", + "[evidence] WARN JSON receipt line 114: recorded HEAD predates non-card changes ——关键检查须重跑并以 make evidence 重新写入", + "[synced] sync_base=c6fe5adba34d7266e7bcfbb9a2c64a0ae61e9b8e tested_head=e2da2fef9a205584ba5d82a9e70cf43ad294c9db checked_at=2026-10-18T17:37:17Z changed=1 sync_target=feature/norm-remediation", + "[next] 跑正式验收(make test 等),证据写入任务卡", + "make[1]: Leaving directory '/home/weichen/projects/Agent-System-Dev-wt-feature-norm-remediation-u4'" + ], + "output_sha256": "f0e3a1083a6d4caf71bdb0213510488f7496f8bf8e73ae3bcb8eee8bd940d977", + "receipt_sha256": "3f384a0ba3dba53089f1d7b26c15265f8bcc7f9007d001e7666e74245388d94d", + "receipt_path": "/home/weichen/.cache/agent-system-dev/evidence/3f384a0ba3dba53089f1d7b26c15265f8bcc7f9007d001e7666e74245388d94d.json" +} +``` + +## 闭合记录(合入前折叠) + +- 本卡在 PR #621(https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/621)合入前闭合并归档,随该 PR 一同合入。 +- 该 PR 的合并提交即本任务的落地记录。 +- 合入后无需回填,也无需单独的闭合 PR。 diff --git a/policy/facts.md b/policy/facts.md index c1469f79..3fcf83fa 100644 --- a/policy/facts.md +++ b/policy/facts.md @@ -7,12 +7,17 @@ 把 PASS 项的 last_verified 刷新为当天并写回本表(随 PR 合入)。 - 表内验证命令不得含竖线 `|`(保持表格与解析可核);新增事实先实测再登记,不得凭记忆填日期。 - precheck 的 `[facts]` 行报告过期项;AGENTS.md「Gitea 与 Git 访问事实」节为本表的叙述版。 +- 凭据类验证命令不得把凭据展开进命令行、输出或证据:经 `scripts/facts-verify.sh` 探针子命令在进程内读取并发送。 +- 旧端点 git-panel.u1680832.nyat.app:30319 已退役,一律不得使用——这是政策禁令,不随 F4 核验结果变化; + F4 只登记可观测的 TLS 握手失败现象,DNS 失败、超时、连接失败或握手成功都判 FAIL,不刷新 last_verified。 +- F4 现有 last_verified(2026-10-14)是历史日期,来自旧的宽泛检查,不证明当前 TLS 层握手失败判据; + 经该判据独立重验前不得当作已核实。 | ID | 事实 | 验证命令 | last_verified | TTL(天) | |---|---|---|---|---| | F1 | Gitea Web/API 可达:https://git-home.u1680832.nyat.app:30319(1.27.3) | `curl -fsS -m 10 https://git-home.u1680832.nyat.app:30319/api/v1/version` | 2026-10-14 | 30 | -| F2 | API token 存在且可用(值在 ~/.secrets/gitea,HOME/IMC 两段各一组 `- ACCESS_TOKEN=…`,本条取 HOME 段;不入仓不回显) | `t=$(awk '/^HOME:/{h=1;next} /^IMC:/{h=0} h&&sub(/^- ACCESS_TOKEN=/,""){print;exit}' ~/.secrets/gitea); test -n "$t" && curl -fsS -m 10 -o /dev/null -H "Authorization: token $t" https://git-home.u1680832.nyat.app:30319/api/v1/user` | 2026-10-14 | 30 | +| F2 | API token 存在且可用(值在 ~/.secrets/gitea,HOME/IMC 两段各一组 `- ACCESS_TOKEN=…`,本条取 HOME 段;不入仓不回显,不进子进程 argv) | `bash scripts/facts-verify.sh api-token https://git-home.u1680832.nyat.app:30319/api/v1/user` | 2026-10-14 | 30 | | F3 | Git over SSH 可达:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git(key ~/.ssh/home_gitea) | `GIT_SSH_COMMAND="ssh -i ~/.ssh/home_gitea -o BatchMode=yes" git ls-remote ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git HEAD` | 2026-10-14 | 30 | -| F4 | 旧端点 git-panel.u1680832.nyat.app:30319 已失效(TLS 握手失败),不得使用 | `! curl -fsS -m 8 https://git-panel.u1680832.nyat.app:30319/api/v1/version` | 2026-10-14 | 180 | +| F4 | 旧端点 git-panel.u1680832.nyat.app:30319 TCP 可连通但 TLS 握手失败(仅 TLS 层握手错误判 PASS) | `bash scripts/facts-verify.sh tls-handshake-fails git-panel.u1680832.nyat.app 30319` | 2026-10-14 | 180 | | F5 | 基座 users 表存有姓名(name 列,CreateAccount/ListUsersAdmin 读写;EXT-19 目录自 P3 起向授权管理读面下发) | `cd modules/backend && go test ./internal/app/ -run TestInprocHostSearchAccounts -count=1` | 2026-10-14 | 180 | | F6 | 基座账号无停用位(users 表无 disabled;EXT-19 Valid 恒为存在性)——停用机制未实现,不得当已具备 | `cd modules/backend && ! grep -rn -e disabled -e Disabled internal/store/auth.go pkg/extension/extension.go` | 2026-10-14 | 180 | diff --git a/scripts/facts-verify.sh b/scripts/facts-verify.sh index 490ed92d..61d4200c 100755 --- a/scripts/facts-verify.sh +++ b/scripts/facts-verify.sh @@ -3,7 +3,86 @@ # 规则依据:AGENTS.md「证据与验证门禁」(外部事实零信任);verification.md「声明与证据来源」。 # 用法:make facts-verify [ARGS=--update] (--update:把 PASS 项 last_verified 刷为当天并写回) # 表内验证命令约定不含竖线;含 "\|" 转义时按字面还原后执行。 +# 登记表引用的探针子命令(单独运行时在 stderr 说明判定,不输出凭据): +# api-token URL URL 须逐字等于登记端点 https://git-home.u1680832.nyat.app:30319/api/v1/user, +# 校验通过后才进程内读 ~/.secrets/gitea HOME 段 ACCESS_TOKEN 并 GET(不跟随重定向); +# 0=接受 3=缺失 4=格式不符 5=请求失败/被拒/重定向 6=非登记端点 +# tls-handshake-fails HOST PORT 仅当 TCP 已连通且 TLS 层握手报错时 0; +# 3=DNS 失败 4=超时 5=连接失败 6=握手成功(端点仍可用) set -uo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +case "${1:-}" in + api-token) + [ $# -eq 2 ] || { echo "usage: facts-verify.sh api-token URL" >&2; exit 2; } + exec python3 - "$SCRIPT_DIR" "$2" <<'PY' +import sys, urllib.request +sys.path.insert(0, sys.argv[1]) +import task_triage + +ENDPOINT = "https://git-home.u1680832.nyat.app:30319/api/v1/user" +if sys.argv[2] != ENDPOINT: + print("api-token: URL 不是 policy/facts.md F2 登记端点,拒绝发送凭据", file=sys.stderr) + sys.exit(6) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +urllib.request.install_opener(urllib.request.build_opener(NoRedirect)) +url = ENDPOINT +token = task_triage.secrets_file_token() +if not token: + print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 缺失", file=sys.stderr) + sys.exit(3) +if not task_triage.TOKEN_RE.fullmatch(token): + print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 格式不符(应为单个无空白值)", file=sys.stderr) + sys.exit(4) +try: + task_triage.http_get_json(url, token) +except task_triage.Stop as stop: + print("api-token: " + stop.message, file=sys.stderr) + sys.exit(5) +print("api-token: accepted by " + url, file=sys.stderr) +PY + ;; + tls-handshake-fails) + [ $# -eq 3 ] || { echo "usage: facts-verify.sh tls-handshake-fails HOST PORT" >&2; exit 2; } + exec python3 - "$2" "$3" <<'PY' +import socket, ssl, sys + +host, port = sys.argv[1], int(sys.argv[2]) + + +def verdict(code, text): + print(f"tls-handshake-fails {host}:{port}: {text}", file=sys.stderr) + sys.exit(code) + + +try: + sock = socket.create_connection((host, port), timeout=8) +except socket.gaierror: + verdict(3, "DNS resolution failed (not a TLS observation)") +except TimeoutError: + verdict(4, "TCP connect timed out (not a TLS observation)") +except OSError as exc: + verdict(5, f"TCP connect failed ({type(exc).__name__}; not a TLS observation)") +try: + with sock, ssl.create_default_context().wrap_socket(sock, server_hostname=host) as tls: + version = tls.version() +except ssl.SSLError as exc: + verdict(0, f"TLS handshake failed ({type(exc).__name__}: {getattr(exc, 'reason', None) or 'unknown'})") +except TimeoutError: + verdict(4, "TLS handshake timed out (not a TLS-layer error)") +except OSError as exc: + verdict(5, f"connection failed during handshake ({type(exc).__name__}; not a TLS-layer error)") +verdict(6, f"TLS handshake succeeded ({version}); endpoint still answers") +PY + ;; +esac + F="${FACTS:-policy/facts.md}" [ -f "$F" ] || { echo "ERROR: 事实登记表 $F 不存在" >&2; exit 1; } python3 - "$F" "${1:-}" <<'PY' diff --git a/tests/test_facts_verify.py b/tests/test_facts_verify.py new file mode 100644 index 00000000..5b3d9d2a --- /dev/null +++ b/tests/test_facts_verify.py @@ -0,0 +1,399 @@ +"""外部事实重验契约(scripts/facts-verify.sh + policy/facts.md 的 F2/F4 验证命令)。 + +全部离线:合成 HOME/IMC 凭据、sitecustomize 桩拦截 urllib 处理器网络边界(保留真实重定向判定) +与 socket/ssl(真实连接一律拒绝)、 +PATH 桩记录 curl/wget argv、临时 Git 仓库与临时登记表;不读取真实 ~/.secrets,不连接 Gitea 或旧端点。 +""" +import datetime +import json +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[1] +REGISTRY = SOURCE / "policy" / "facts.md" +HOME_TOKEN = "h0me" + "1" * 36 +IMC_TOKEN = "1mc0" + "2" * 36 +ENV_DECOY = "dec0" + "3" * 36 +OLD_DATE = "2026-01-01" +SECRETS = [HOME_TOKEN, IMC_TOKEN] + +STUB = r''' +import http.client, io, json, os, socket, ssl, subprocess, urllib.request, urllib.response + + +def _log(kind, **data): + with open(os.environ["FACTS_STUB_LOG"], "a", encoding="utf-8") as fh: + fh.write(json.dumps({"kind": kind, **data}) + "\n") + + +def _deny(*args, **kwargs): + raise RuntimeError("live network disabled in facts-verify tests") + + +socket.socket.connect = _deny +socket.socket.connect_ex = _deny + +_popen_init = subprocess.Popen.__init__ + + +def _popen(self, args, *rest, **kwargs): + argv = [args] if isinstance(args, (str, bytes, os.PathLike)) else list(args) + _log("exec", argv=[os.fsdecode(a) if isinstance(a, (bytes, os.PathLike)) else str(a) for a in argv]) + _popen_init(self, args, *rest, **kwargs) + + +subprocess.Popen.__init__ = _popen + + +def _response(url, code, body=b"", location=None): + headers = http.client.HTTPMessage() + if location: + headers["Location"] = location + resp = urllib.response.addinfourl(io.BytesIO(body), headers, url, code) + resp.msg = "stub" + return resp + + +REDIRECT_TARGET = "redirect-target.invalid" + + +# Intercept only the handlers' network boundary so the opener's real redirect and +# error processing still decides whether a follow-up request is made. +def _open(self, request): + url = request.full_url + auth = request.get_header("Authorization") + _log("http", url=url, authorization=auth) + if REDIRECT_TARGET in url: + return _response(url, 200, b'{"login": "redirect-target"}') + mode = os.environ.get("FAKE_HTTP", "200") + if mode == "200": + return _response(url, 200, b'{"login": "fixture"}') + if mode == "leak": + raise ValueError("Invalid header value " + repr(auth)) + code = int(mode) + location = "https://" + REDIRECT_TARGET + "/collect" if 300 <= code < 400 else None + return _response(url, code, location=location) + + +urllib.request.HTTPSHandler.https_open = _open +urllib.request.HTTPHandler.http_open = _open + + +class _Conn: + def close(self): + pass + + def version(self): + return "TLSv1.3" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _create_connection(address, *args, **kwargs): + _log("connect", host=address[0], port=int(address[1])) + mode = os.environ.get("FAKE_NET", "") + if mode == "dns": + raise socket.gaierror(socket.EAI_NONAME, "Name or service not known") + if mode == "timeout": + raise TimeoutError("timed out") + if mode == "refused": + raise ConnectionRefusedError(111, "Connection refused") + return _Conn() + + +socket.create_connection = _create_connection + + +def _wrap_socket(self, sock, *args, **kwargs): + mode = os.environ.get("FAKE_NET", "") + if mode == "tls": + raise ssl.SSLError(1, "[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure") + if mode == "reset": + raise ConnectionResetError(104, "Connection reset by peer") + if mode == "tls-timeout": + raise TimeoutError("handshake timed out") + return _Conn() + + +ssl.SSLContext.wrap_socket = _wrap_socket +''' + +SHIM = """#!/bin/sh +for a in "$0" "$@"; do printf '%s\\n' "$a"; done >> "$FACTS_ARGV_LOG" +exit "${FAKE_CURL_EXIT:-0}" +""" + +# Exit codes the retired-endpoint curl check would see for each simulated condition. +CURL_EXIT = {"dns": "6", "timeout": "28", "refused": "7", "reset": "35", "tls-timeout": "28", + "tls": "35", "ok": "0"} + + +def registry_row(fid): + for line in REGISTRY.read_text(encoding="utf-8").splitlines(): + if re.match(r"^\|\s*" + fid + r"\s*\|", line): + return line + raise AssertionError(fid + " missing from policy/facts.md") + + +def registry_command(fid): + cells = [c.strip() for c in registry_row(fid).strip().strip("|").replace("\\|", "\x00").split("|")] + return cells[2].strip("`").replace("\x00", "|") + + +def with_date(line, date): + return re.sub(r"\|\s*\d{4}-\d{2}-\d{2}\s*\|(\s*\d+\s*\|)\s*$", "| " + date + r" |\1", line) + + +class FactsVerifyTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory(prefix="facts-verify-") + self.addCleanup(tmp.cleanup) + self.base = Path(tmp.name) + self.repo = self.base / "repo" + (self.repo / "scripts").mkdir(parents=True) + for name in ("facts-verify.sh", "task_triage.py"): + shutil.copy2(SOURCE / "scripts" / name, self.repo / "scripts" / name) + self.git("init", "-q", "-b", "main") + self.git("add", "scripts") + self.git("-c", "user.name=Facts Test", "-c", "user.email=facts@example.invalid", + "commit", "-qm", "Initial") + self.home = self.base / "home" + (self.home / ".secrets").mkdir(parents=True) + self.write_secrets(f"HOME:\n- URL_PREFIX=https://home.invalid\n- ACCESS_TOKEN={HOME_TOKEN}\n" + f"IMC:\n- URL_PREFIX=https://imc.invalid\n- ACCESS_TOKEN={IMC_TOKEN}\n") + self.stub_dir = self.base / "stub" + self.stub_dir.mkdir() + (self.stub_dir / "sitecustomize.py").write_text(STUB, encoding="utf-8") + self.shim_dir = self.base / "bin" + self.shim_dir.mkdir() + for name in ("curl", "wget"): + shim = self.shim_dir / name + shim.write_text(SHIM, encoding="utf-8") + shim.chmod(0o755) + self.stub_log = self.base / "stub.log" + self.argv_log = self.base / "argv.log" + self.facts = self.base / "facts.md" + self.today = datetime.date.today().isoformat() + + def git(self, *args): + subprocess.run(["git", *args], cwd=self.repo, check=True, capture_output=True) + + def write_secrets(self, text): + (self.home / ".secrets" / "gitea").write_text(text, encoding="utf-8") + + def write_registry(self, *fids): + rows = [with_date(registry_row(fid), OLD_DATE) for fid in fids] + rows.append(f"| F8 | 对照:总能通过 | `true` | {OLD_DATE} | 30 |") + rows.append(f"| F9 | 对照:总是失败 | `false` | {OLD_DATE} | 30 |") + self.facts.write_text("| ID | 事实 | 验证命令 | last_verified | TTL(天) |\n|---|---|---|---|---|\n" + + "\n".join(rows) + "\n", encoding="utf-8") + + def env(self, net="", http="200"): + env = {k: v for k, v in os.environ.items() + if k not in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL")} + curl_exit = CURL_EXIT.get(net, "0") if net else ("0" if http == "200" else "22") + env.update({ + "HOME": str(self.home), "GITEA_TOKEN": ENV_DECOY, "FACTS": str(self.facts), + "PATH": str(self.shim_dir) + os.pathsep + env.get("PATH", ""), + "PYTHONPATH": str(self.stub_dir), "FACTS_STUB_LOG": str(self.stub_log), + "FACTS_ARGV_LOG": str(self.argv_log), "FAKE_NET": net, "FAKE_HTTP": http, + "FAKE_CURL_EXIT": curl_exit, "AGENT_EVIDENCE_DIR": str(self.base / "evidence"), + "GIT_CONFIG_NOSYSTEM": "1", + }) + return env + + def run_script(self, *args, net="", http="200"): + return subprocess.run(["bash", "scripts/facts-verify.sh", *args], cwd=self.repo, + env=self.env(net, http), capture_output=True, text=True, timeout=120) + + def run_evidence(self, command, receipt, net="", http="200"): + env = self.env(net, http) + env.update({"CMD": command, "RECEIPT": str(receipt)}) + return subprocess.run(["python3", str(SOURCE / "scripts" / "evidence.py")], cwd=self.repo, + env=env, capture_output=True, text=True, timeout=120) + + def stub_events(self, kind): + if not self.stub_log.exists(): + return [] + events = [json.loads(line) for line in self.stub_log.read_text(encoding="utf-8").splitlines()] + return [e for e in events if e["kind"] == kind] + + def dates(self): + found = {} + for line in self.facts.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^\|\s*(F\d+)\s*\|.*\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*\d+\s*\|\s*$", line) + if match: + found[match.group(1)] = match.group(2) + return found + + def assert_no_secrets(self, *texts): + exec_argv = "\n".join("\0".join(e["argv"]) for e in self.stub_events("exec")) + shim_argv = self.argv_log.read_text(encoding="utf-8") if self.argv_log.exists() else "" + for label, text in [("child argv", exec_argv), ("curl/wget argv", shim_argv), + *((f"output#{i}", t) for i, t in enumerate(texts))]: + for secret in SECRETS: + self.assertNotIn(secret, text, f"credential leaked into {label}") + + def assert_status(self, output, fid, status): + self.assertRegex(output, r"(?m)^\[" + fid + r"\] " + status + r"\b") + + # --- F2:凭据只在进程内使用 ------------------------------------------------- + + def test_f2_pass_sends_home_token_in_process_and_leaks_nowhere(self): + self.write_registry("F2") + receipt = self.base / "receipt.json" + result = self.run_evidence("bash scripts/facts-verify.sh --update", receipt) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) # F9 control fails + self.assert_status(result.stdout, "F2", "PASS") + requests = self.stub_events("http") + self.assertEqual(len(requests), 1, requests) + self.assertEqual(requests[0]["url"], "https://git-home.u1680832.nyat.app:30319/api/v1/user") + self.assertEqual(requests[0]["authorization"], "token " + HOME_TOKEN) + self.assertEqual(self.dates(), {"F2": self.today, "F8": self.today, "F9": OLD_DATE}) + self.assertTrue(receipt.exists(), result.stderr) + self.assert_no_secrets(result.stdout, result.stderr, receipt.read_text(encoding="utf-8"), + self.facts.read_text(encoding="utf-8")) + + def test_f2_missing_or_malformed_credentials_fail_without_echo(self): + self.write_registry("F2") + cases = { + "no HOME token": f"HOME:\n- URL_PREFIX=https://home.invalid\nIMC:\n- ACCESS_TOKEN={IMC_TOKEN}\n", + "malformed HOME token": f"HOME:\n- ACCESS_TOKEN={HOME_TOKEN} {IMC_TOKEN}\n" + f"IMC:\n- ACCESS_TOKEN={IMC_TOKEN}\n", + "missing file": None, + } + for label, text in cases.items(): + with self.subTest(label): + self.stub_log.unlink(missing_ok=True) + self.argv_log.unlink(missing_ok=True) + secrets = self.home / ".secrets" / "gitea" + secrets.unlink(missing_ok=True) + if text is not None: + self.write_secrets(text) + self.write_registry("F2") + receipt = self.base / f"receipt-{len(label)}.json" + result = self.run_evidence("bash scripts/facts-verify.sh --update", receipt) + self.assert_status(result.stdout, "F2", "FAIL") + self.assertEqual(self.stub_events("http"), [], "no request without a valid HOME token") + self.assertEqual(self.dates()["F2"], OLD_DATE) + direct = self.run_script("api-token", "https://git-home.u1680832.nyat.app:30319/api/v1/user") + self.assertNotEqual(direct.returncode, 0) + self.assertIn("ACCESS_TOKEN", direct.stderr) + self.assert_no_secrets(result.stdout, result.stderr, direct.stdout, direct.stderr, + receipt.read_text(encoding="utf-8")) + + def test_f2_rejected_or_failing_request_does_not_refresh_or_leak(self): + for http in ("401", "leak"): + with self.subTest(http): + self.stub_log.unlink(missing_ok=True) + self.write_registry("F2") + result = self.run_script("--update", http=http) + self.assert_status(result.stdout, "F2", "FAIL") + self.assertEqual(self.dates(), {"F2": OLD_DATE, "F8": self.today, "F9": OLD_DATE}) + direct = self.run_script("api-token", "https://git-home.u1680832.nyat.app:30319/api/v1/user", + http=http) + self.assertNotEqual(direct.returncode, 0) + self.assertNotIn("Authorization", direct.stderr) + self.assert_no_secrets(result.stdout, result.stderr, direct.stdout, direct.stderr) + + def test_f2_rejects_unregistered_destination_before_reading_credentials(self): + bad_urls = [ + "http://git-home.u1680832.nyat.app:30319/api/v1/user", + "https://evil.invalid/api/v1/user", + "https://git-home.u1680832.nyat.app:30320/api/v1/user", + "https://git-home.u1680832.nyat.app/api/v1/user", + "https://user:pw@git-home.u1680832.nyat.app:30319/api/v1/user", + "https://git-home.u1680832.nyat.app:30319@evil.invalid/api/v1/user", + "https://git-home.u1680832.nyat.app:30319/api/v1/user#frag", + "https://git-home.u1680832.nyat.app:30319/api/v1/user?next=https://evil.invalid", + "https://git-home.u1680832.nyat.app:30319/api/v1/users", + "https://git-home.u1680832.nyat.app:30319/api/v1/user/../admin", + "https://git-home.u1680832.nyat.app:30319/api/v1/user ", + ] + for secrets_present in (True, False): + if not secrets_present: + (self.home / ".secrets" / "gitea").unlink() + for url in bad_urls: + with self.subTest(url=url, secrets_present=secrets_present): + self.stub_log.unlink(missing_ok=True) + direct = self.run_script("api-token", url) + self.assertEqual(direct.returncode, 6, direct.stderr) + self.assertEqual(self.stub_events("http"), [], "no request to an unregistered destination") + self.assertNotIn(url.strip(), direct.stdout + direct.stderr) + self.assertNotIn("evil.invalid", direct.stdout + direct.stderr) + self.assertNotIn("ACCESS_TOKEN", direct.stderr, "destination is checked before credentials") + self.assert_no_secrets(direct.stdout, direct.stderr) + + def test_f2_refuses_redirect_without_forwarding_authorization(self): + for code in ("301", "302", "303", "307", "308"): + with self.subTest(code): + self.stub_log.unlink(missing_ok=True) + direct = self.run_script("api-token", "https://git-home.u1680832.nyat.app:30319/api/v1/user", + http=code) + self.assertEqual(direct.returncode, 5, direct.stderr) + requests = self.stub_events("http") + self.assertEqual([r["url"] for r in requests], + ["https://git-home.u1680832.nyat.app:30319/api/v1/user"], "no follow-up request") + self.assertNotIn("redirect-target", direct.stdout + direct.stderr) + self.assert_no_secrets(direct.stdout, direct.stderr) + + self.stub_log.unlink(missing_ok=True) + self.write_registry("F2") + result = self.run_script("--update", http=code) + self.assert_status(result.stdout, "F2", "FAIL") + self.assertEqual(self.dates()["F2"], OLD_DATE) + self.assertEqual(len(self.stub_events("http")), 1) + self.assert_no_secrets(result.stdout, result.stderr) + + # --- F4:只有 TLS 层握手失败才支持该事实 ----------------------------------- + + def test_f4_only_tls_handshake_failure_passes_and_refreshes(self): + expected = {"tls": "PASS", "dns": "FAIL", "timeout": "FAIL", "refused": "FAIL", + "reset": "FAIL", "tls-timeout": "FAIL", "ok": "FAIL"} + for net, status in expected.items(): + with self.subTest(net): + self.stub_log.unlink(missing_ok=True) + self.write_registry("F4") + result = self.run_script("--update", net=net) + self.assert_status(result.stdout, "F4", status) + self.assertEqual(self.dates(), {"F4": self.today if status == "PASS" else OLD_DATE, + "F8": self.today, "F9": OLD_DATE}) + connects = self.stub_events("connect") + self.assertEqual([(c["host"], c["port"]) for c in connects], + [("git-panel.u1680832.nyat.app", 30319)]) + + def test_f4_helper_reports_each_condition_distinctly(self): + codes = {} + for net in ("tls", "dns", "timeout", "refused", "reset", "tls-timeout", "ok"): + result = self.run_script("tls-handshake-fails", "git-panel.u1680832.nyat.app", "30319", net=net) + codes[net] = result.returncode + self.assertTrue(result.stderr.strip(), net + " must explain its verdict") + self.assertEqual(codes["tls"], 0) + self.assertEqual(len({codes["dns"], codes["timeout"], codes["refused"], codes["ok"]}), 4, codes) + self.assertNotIn(0, [code for net, code in codes.items() if net != "tls"]) + + def test_registry_separates_retired_endpoint_policy_from_tls_observation(self): + text = REGISTRY.read_text(encoding="utf-8") + policy_lines = [line for line in text.splitlines() + if "git-panel" in line and not line.startswith("|")] + self.assertTrue(any("不得使用" in line for line in policy_lines), + "retired-endpoint prohibition must stand outside the F4 verification row") + self.assertNotIn("不得使用", registry_row("F4")) + for fid in ("F2", "F4"): + command = registry_command(fid) + self.assertNotIn("curl", command) + self.assertNotIn("Authorization", command) + self.assertNotIn(".secrets", command) + + +if __name__ == "__main__": + unittest.main()