From a9de6449a6707f0ab9f9dbddf50b5e199ef3ebbe Mon Sep 17 00:00:00 2001 From: mine_take Date: Mon, 20 Jul 2026 08:42:45 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(TASK-0872):=20#872=20PR-2=20=E9=9D=9EH?= =?UTF-8?q?O=20=E2=80=94=20c3-prime=20=E5=8F=97=E7=90=86=E5=99=A8=20+=20E2?= =?UTF-8?q?E=20+=20HO=20patch=20=E7=BE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 受理側実装を非 HO と HO に分離。本コミットは非 HO(AI コミット可能): - scripts/ai-loop/c3prime_verify.py 新設(非 HO・bin/plangate 共有ヘルパー): approvals/c3.json の approval_kind を strict JSON 判別。c3-prime なら契約 §4 の 全束縛(plan_hash / artifact_hashes / plan_package_hash / source_sha / reviewer snapshot 三つ組 / decision-verdict 整合 / decision allowlist)を再検証(trust boundary・#887 F-4)。exit 0=受理 / 10=legacy 委譲 / 1=fail-closed - tests/extras/ta-55-c3prime-accept.sh: c3prime_verify.py を直接叩く E2E(valid/ 改竄/legacy/未知 approval_kind の 4 ケース・CI 常時 PASS)+ HO 適用後の bin/plangate 全鎖テスト(未適用時 SKIP)。set -eu 安全な rc 捕捉 - docs/working/TASK-0872/patches/: HO patch 群(Human 適用)— bin-plangate.patch/.new(validate+exec に _plangate_c3_dispatch 配線・legacy 非変更・ git apply --check clean 実証・sandbox で全 6 exit code 実測)/ ai-loop-workflow command patch ×2(run 入口 TASK 必須化)/ c3-prime.schema.json(#888 で同梱済)/ ho-apply-approval.md(適用手順・検証結果) HO 適用(bin/plangate・commands ×2・schemas/)は Human-owned のため本 PR には含めず、 ho-apply-approval.md に集約。未適用の間 c3-prime は legacy grep で FAIL / schema-validate は F-8 で ERROR(fail-closed・Shadow Config なし)。 Refs: #872 Co-Authored-By: Claude Fable 5 --- docs/working/TASK-0872/current-state.md | 13 +- docs/working/TASK-0872/decision-log.jsonl | 1 + .../patches/ai-loop-workflow-claude.patch | 19 + .../patches/ai-loop-workflow.claude.new | 64 + .../patches/ai-loop-workflow.plugin.new | 64 + .../TASK-0872/patches/bin-plangate.new | 2497 +++++++++++++++++ .../TASK-0872/patches/bin-plangate.patch | 172 ++ .../TASK-0872/patches/ho-apply-approval.md | 66 + scripts/ai-loop/c3prime_verify.py | 119 + tests/extras/ta-55-c3prime-accept.sh | 101 + 10 files changed, 3110 insertions(+), 6 deletions(-) create mode 100644 docs/working/TASK-0872/patches/ai-loop-workflow-claude.patch create mode 100644 docs/working/TASK-0872/patches/ai-loop-workflow.claude.new create mode 100644 docs/working/TASK-0872/patches/ai-loop-workflow.plugin.new create mode 100755 docs/working/TASK-0872/patches/bin-plangate.new create mode 100644 docs/working/TASK-0872/patches/bin-plangate.patch create mode 100644 docs/working/TASK-0872/patches/ho-apply-approval.md create mode 100755 scripts/ai-loop/c3prime_verify.py create mode 100644 tests/extras/ta-55-c3prime-accept.sh diff --git a/docs/working/TASK-0872/current-state.md b/docs/working/TASK-0872/current-state.md index a21297bd..7c1d1ce0 100644 --- a/docs/working/TASK-0872/current-state.md +++ b/docs/working/TASK-0872/current-state.md @@ -1,8 +1,9 @@ # Current State — TASK-0872 -- 更新: 2026-07-20 08:05 -- フェーズ: exec(PR-1 merged #886 → #887 是正完了 = PR #888 C-4 待ち) -- Mode: critical / c3.json APPROVED(plan_hash 1af8d43e…・不変) -- 直近の完了: #887(PR #886 敵対的レビュー major 4)の全件是正 — evidence 判定マーカー正規化(F-1/F-5)・hash ベース stale(F-2)・decision↔verdicts 整合(F-3)・HO 先勝ちテスト(F-6)・契約 §5 是正(F-7)・schema 不在窓 FAIL 化(F-8)+ T-14 schema patch 同梱 -- 次のアクション(Human): C-4 = PR #888 レビュー・マージ -- マージ後の残: T-15 bin/plangate 両受理 patch → T-16 command 入口 patch → H-3 Human 適用(schema 含む)→ T-18 E2E extras fixture → T-19/20 記録 +- 更新: 2026-07-20 09:00 +- フェーズ: exec(PR-2 = 非 HO 実装完了・PR 作成へ / HO patch は Human 適用待ち) +- Mode: critical +- 直近の完了: PR-2 の受理側実装 — `scripts/ai-loop/c3prime_verify.py`(非 HO・契約 §4 全数再検証)+ bin/plangate 配線 patch(sandbox 実適用テストで全 6 exit code 実測)+ command run 入口 patch + schema 完成形 + TA-55 E2E(非 HO 部分 CI 常時 PASS・HO 全鎖は適用後 SKIP→PASS) +- 次のアクション: 非 HO を commit → PR 作成 → C-4 + H-3(HO patch Human 適用) +- 既知(スコープ外): ta-42 TC-04(status 異常系 exit)はローカル macOS sh の set -e 挙動差による偽陽性。main CI(Test)は e6ccc4f で success = CI では通る。PR-2 非改変領域 +- HO 適用手順: `patches/ho-apply-approval.md` diff --git a/docs/working/TASK-0872/decision-log.jsonl b/docs/working/TASK-0872/decision-log.jsonl index 7e2990b5..b52784f6 100644 --- a/docs/working/TASK-0872/decision-log.jsonl +++ b/docs/working/TASK-0872/decision-log.jsonl @@ -13,3 +13,4 @@ {"ts":"2026-07-20T08:30:00Z","phase":"exec","decision":"PR #886(PR-1) Human C-4マージ確認(721edcb・diff 0照合・branch掃除)。bot quota不在のため敵対的レビュー1本実施→approve(重大0・全指摘実測再現)。minor 3: ①schema不在窓のvalidate SKIP→PR-2でschema先行着地+FAIL化 ②prod+no-pp+reject-rejectのBLOCKED→ESCALATED変化(安全側・記録のみ) ③_PATH_RE sanitize(V2候補)。R-015〜R-017としてreview-external集約","by":"agent"} {"ts":"2026-07-20T09:00:00Z","phase":"exec","decision":"#887(別セッション敵対的レビューmajor 4)を受理しPR-2前に是正実装へ。自レーンCレビュー(approve)はF-1/F-3/F-5を見逃し=検出力差を認め是々非々で採用。是正設計: evidence判定は行頭アンカー機械マーカー(C1-VERDICT/C2-VERDICT + plan=sha256:)へ正規化しちょうど1回を要求(F-1)・stale はマーカー内plan hash照合で決定論化しmtime廃止(F-2)・build_c3_primeにdecision↔verdicts整合(AUTO_APPROVEDは両approve必須)を追加し契約§4にも受理側規則明記(F-3)・実artifact形式fixture追加(F-5)・契約§5のgrep記述是正=legacy grep流用禁止を明記(F-7)・schema_mappingはc3-prime時にis_fileスキップでschema不在をFAILへ(F-8)","by":"agent"} {"ts":"2026-07-20T09:30:00Z","phase":"exec","decision":"複数エージェントレビュー(Codex×2+Sonnet)実施。Codex conditional/reject vs Sonnet approve の分裂を全件一次ソースで再現裁定。スコープ内 major 4+minor 1 採用(R-019〜023): decision/verdict allowlist・schema cross-field制約・evidence再読TOCTOU fail-closed・F-8回帰テスト・prefix文法外行fail-closed。R-024/025(bin/plangate受理側・schema配置)はPR-2スコープのためreject理由として却下。検証: test_plan_package 30・run-tests 406 passed","by":"agent"} +{"ts":"2026-07-20T09:00:00Z","phase":"exec","decision":"PR-2受理側実装。設計: c3-prime検証をscripts/ai-loop/c3prime_verify.py(非HO・AIコミット可)に分離しbin/plangateは_plangate_c3_dispatch配線数行(HO)のみに縮小。契約§4全数再検証(trust boundary #887 F-4)。実適用テストでset -e漏れバグ(dispatch戻り10がスクリプト中断)を捕捉し|| _c3_rc=$?で修正。sandbox 6シナリオ全exit code実測(legacy非退行0/c3-prime受理0/改竄1)。HO patch4件はho-apply-approval.mdでHuman適用。ta-42 TC-04失敗はmacOS sh set-e差の既存偽陽性(main CI success)","by":"agent"} diff --git a/docs/working/TASK-0872/patches/ai-loop-workflow-claude.patch b/docs/working/TASK-0872/patches/ai-loop-workflow-claude.patch new file mode 100644 index 00000000..441a8f95 --- /dev/null +++ b/docs/working/TASK-0872/patches/ai-loop-workflow-claude.patch @@ -0,0 +1,19 @@ +diff --git a/.claude/commands/ai-loop-workflow.md b/.claude/commands/ai-loop-workflow.md +index 625eb6e..c3e8cad 100644 +--- a/.claude/commands/ai-loop-workflow.md ++++ b/.claude/commands/ai-loop-workflow.md +@@ -18,7 +18,13 @@ arbiter 裁定 → exec → rubric grader)。本コマンドは前提確認と + + $ARGUMENTS に以下の形式で渡される: + +-- `run <対象の説明>` — 新しい run を開始(LoopSpec 作成から) ++- `run TASK-XXXX` — **Plan-first の正式入口**(TASK ID 必須)。既存の Plan Package ++ (pbi-input / plan / todo / test-cases + C-1/C-2 evidence)を起点に production run を ++ 開始する。LoopSpec は `scripts/ai-loop/plan_package.py` の `derive_loopspec()` で ++ Plan Package から決定論派生し、`production: true` + `plan_package` ブロックを ++ arbiter へ渡す(契約正本: `docs/workflows/ai-loop/c3-prime-contract.md`)。 ++ TASK ID を伴わない自由文だけの `run <説明>` は **production run を開始できない** ++ (TASK-0872 / #872。Plan Package 束縛の無い run を Wチェックへ進めない) + - `status` — 直近 run の状態・decision record・摩擦台帳の要約を表示 + - (引数なし) — 前提チェックのみ実施して結果を報告 + diff --git a/docs/working/TASK-0872/patches/ai-loop-workflow.claude.new b/docs/working/TASK-0872/patches/ai-loop-workflow.claude.new new file mode 100644 index 00000000..c3e8cad3 --- /dev/null +++ b/docs/working/TASK-0872/patches/ai-loop-workflow.claude.new @@ -0,0 +1,64 @@ +# /ai-loop-workflow + +ai-loop-workflow(human-on-the-loop 裁定ループ)を明示起動する。 +`/ai-dev-workflow`(PlanGate 本番フロー WF-00〜07)と**対をなす入口**であり、 +本番フローの置き換えではない。恒久定義(責務・terminal state・C-3'/Human C-3 経路)の +正本は `00_concept.md`、適用制限(Phase 1 rollout eligibility)の正本は +`rollout-policy.md`(eligible run 限定。PlanGate 本番フローの C-3 は常に Human・不変)。 + +実行手順の正本: skill `ai-loop-cycle`(1 サイクル = LoopSpec → W チェック → +arbiter 裁定 → exec → rubric grader)。本コマンドは前提確認と起動のみを担う。 + +> **docs の参照先**: plugin 導入先では ai-loop ドキュメントは skill 内の +> `references/` 配下(`skills/ai-loop-cycle/references/`)に同梱される。 +> 本リポジトリ(正本側)では `docs/workflows/ai-loop/` 配下。以下の参照は +> 環境に応じてどちらかで解決すること。 + +## 引数 + +$ARGUMENTS に以下の形式で渡される: + +- `run TASK-XXXX` — **Plan-first の正式入口**(TASK ID 必須)。既存の Plan Package + (pbi-input / plan / todo / test-cases + C-1/C-2 evidence)を起点に production run を + 開始する。LoopSpec は `scripts/ai-loop/plan_package.py` の `derive_loopspec()` で + Plan Package から決定論派生し、`production: true` + `plan_package` ブロックを + arbiter へ渡す(契約正本: `docs/workflows/ai-loop/c3-prime-contract.md`)。 + TASK ID を伴わない自由文だけの `run <説明>` は **production run を開始できない** + (TASK-0872 / #872。Plan Package 束縛の無い run を Wチェックへ進めない) +- `status` — 直近 run の状態・decision record・摩擦台帳の要約を表示 +- (引数なし) — 前提チェックのみ実施して結果を報告 + +## 実行前チェック(必須・満たさない場合は開始せず報告) + +1. **HO 境界の解決**: ho-paths(導入先確定済み)が arbiter から解決できるか + — `python3 --input /dev/null` 相当の疎通でなく、 + ho-paths 解決元の stderr 表示を確認する。未確定なら「全件 human escalate になる」旨を伝え、確定を促す +2. **保存先の定義**: run 記録(LoopSpec・decision record)と摩擦台帳の置き場が + プロジェクトで定義済みか(未定義なら既定案を提示して合意を取る) +3. **適用制限(Phase 1 rollout eligibility)**: 対象変更が `rollout-policy.md` の + eligible 条件を満たしうるか(boundary=clean、かつ `lite-criteria.md` §2 の + 4 軸〔変更規模・新規設計の有無・既存パターン踏襲・可逆性〕。docs に限らず + 実機能も含む — Human 決定 #807)。承認境界・本番承認フローに触れる場合は + 本コマンドを使わず通常フローへ + +## 実行 + +前提 3 点を満たしたら、skill `ai-loop-cycle` の手順に従って 1 サイクルを実行する。 +停止規則(round 上限・escalate 条件・touches-HO 無条件 escalate)は skill と +同梱 docs(decision-table / execution-runbook / loop-safety-gates)が正本。 + +## Iron Law(ai-loop 版・違反したら即停止) + +| ルール | 意味 | +|-------|------| +| `NO LOOP WITHOUT STOPPING RULE` | 停止できないループを回すな | +| `NO AUTO-APPROVE ON HO CONTACT` | HO 接触は無条件で人間へ | +| `NO OPTIMIZE WITHOUT RECORD` | 記録なき最適化をするな | +| `NO MERGE BY AI` | マージは常に Human | + +## 関連 + +- skill: `ai-loop-cycle`(実行単位の正本) +- docs: 同梱 ai-loop ドキュメント(design-philosophy / decision-table / execution-runbook — + plugin 導入先は skill 内 `references/`、本リポジトリは `docs/workflows/ai-loop/` 配下) +- 対: `/ai-dev-workflow`(PlanGate 本番フロー入口) diff --git a/docs/working/TASK-0872/patches/ai-loop-workflow.plugin.new b/docs/working/TASK-0872/patches/ai-loop-workflow.plugin.new new file mode 100644 index 00000000..c3e8cad3 --- /dev/null +++ b/docs/working/TASK-0872/patches/ai-loop-workflow.plugin.new @@ -0,0 +1,64 @@ +# /ai-loop-workflow + +ai-loop-workflow(human-on-the-loop 裁定ループ)を明示起動する。 +`/ai-dev-workflow`(PlanGate 本番フロー WF-00〜07)と**対をなす入口**であり、 +本番フローの置き換えではない。恒久定義(責務・terminal state・C-3'/Human C-3 経路)の +正本は `00_concept.md`、適用制限(Phase 1 rollout eligibility)の正本は +`rollout-policy.md`(eligible run 限定。PlanGate 本番フローの C-3 は常に Human・不変)。 + +実行手順の正本: skill `ai-loop-cycle`(1 サイクル = LoopSpec → W チェック → +arbiter 裁定 → exec → rubric grader)。本コマンドは前提確認と起動のみを担う。 + +> **docs の参照先**: plugin 導入先では ai-loop ドキュメントは skill 内の +> `references/` 配下(`skills/ai-loop-cycle/references/`)に同梱される。 +> 本リポジトリ(正本側)では `docs/workflows/ai-loop/` 配下。以下の参照は +> 環境に応じてどちらかで解決すること。 + +## 引数 + +$ARGUMENTS に以下の形式で渡される: + +- `run TASK-XXXX` — **Plan-first の正式入口**(TASK ID 必須)。既存の Plan Package + (pbi-input / plan / todo / test-cases + C-1/C-2 evidence)を起点に production run を + 開始する。LoopSpec は `scripts/ai-loop/plan_package.py` の `derive_loopspec()` で + Plan Package から決定論派生し、`production: true` + `plan_package` ブロックを + arbiter へ渡す(契約正本: `docs/workflows/ai-loop/c3-prime-contract.md`)。 + TASK ID を伴わない自由文だけの `run <説明>` は **production run を開始できない** + (TASK-0872 / #872。Plan Package 束縛の無い run を Wチェックへ進めない) +- `status` — 直近 run の状態・decision record・摩擦台帳の要約を表示 +- (引数なし) — 前提チェックのみ実施して結果を報告 + +## 実行前チェック(必須・満たさない場合は開始せず報告) + +1. **HO 境界の解決**: ho-paths(導入先確定済み)が arbiter から解決できるか + — `python3 --input /dev/null` 相当の疎通でなく、 + ho-paths 解決元の stderr 表示を確認する。未確定なら「全件 human escalate になる」旨を伝え、確定を促す +2. **保存先の定義**: run 記録(LoopSpec・decision record)と摩擦台帳の置き場が + プロジェクトで定義済みか(未定義なら既定案を提示して合意を取る) +3. **適用制限(Phase 1 rollout eligibility)**: 対象変更が `rollout-policy.md` の + eligible 条件を満たしうるか(boundary=clean、かつ `lite-criteria.md` §2 の + 4 軸〔変更規模・新規設計の有無・既存パターン踏襲・可逆性〕。docs に限らず + 実機能も含む — Human 決定 #807)。承認境界・本番承認フローに触れる場合は + 本コマンドを使わず通常フローへ + +## 実行 + +前提 3 点を満たしたら、skill `ai-loop-cycle` の手順に従って 1 サイクルを実行する。 +停止規則(round 上限・escalate 条件・touches-HO 無条件 escalate)は skill と +同梱 docs(decision-table / execution-runbook / loop-safety-gates)が正本。 + +## Iron Law(ai-loop 版・違反したら即停止) + +| ルール | 意味 | +|-------|------| +| `NO LOOP WITHOUT STOPPING RULE` | 停止できないループを回すな | +| `NO AUTO-APPROVE ON HO CONTACT` | HO 接触は無条件で人間へ | +| `NO OPTIMIZE WITHOUT RECORD` | 記録なき最適化をするな | +| `NO MERGE BY AI` | マージは常に Human | + +## 関連 + +- skill: `ai-loop-cycle`(実行単位の正本) +- docs: 同梱 ai-loop ドキュメント(design-philosophy / decision-table / execution-runbook — + plugin 導入先は skill 内 `references/`、本リポジトリは `docs/workflows/ai-loop/` 配下) +- 対: `/ai-dev-workflow`(PlanGate 本番フロー入口) diff --git a/docs/working/TASK-0872/patches/bin-plangate.new b/docs/working/TASK-0872/patches/bin-plangate.new new file mode 100755 index 00000000..b0f0f816 --- /dev/null +++ b/docs/working/TASK-0872/patches/bin-plangate.new @@ -0,0 +1,2497 @@ +#!/bin/sh +# PlanGate CLI +# Usage: plangate [args] + +set -eu + +PLANGATE_VERSION="0.2.0" + +plangate_bin_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +plangate_root=$(CDPATH= cd -- "$plangate_bin_dir/.." && pwd) +plangate_working_dir="$plangate_root/docs/working" +plangate_templates_dir="$plangate_root/docs/working/templates" + +# ── utilities ──────────────────────────────────────────────────────────────── + +plangate_now() { + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +plangate_today() { + date '+%Y-%m-%d' +} + +plangate_sha256() { + file=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + else + shasum -a 256 "$file" | awk '{print $1}' + fi +} + +plangate_validate_task_id() { + task_id=$1 + case "$task_id" in + TASK-[A-Za-z0-9]*) + case "$task_id" in + *[!A-Za-z0-9-]*) + printf 'error: Task ID must contain only letters, numbers, and hyphens.\n' >&2 + return 1 + ;; + esac + ;; + *) + printf 'error: Task ID must match TASK-XXXX (e.g. TASK-0001).\n' >&2 + return 1 + ;; + esac +} + +plangate_require_task_dir() { + task_id=$1 + work_dir="$plangate_working_dir/$task_id" + if [ ! -d "$work_dir" ]; then + printf 'error: Task directory not found: %s\n' "$work_dir" >&2 + printf 'Run: plangate init %s\n' "$task_id" >&2 + return 1 + fi +} + +plangate_check() { + label=$1 + shift + if "$@" >/dev/null 2>&1; then + printf ' [PASS] %s\n' "$label" + return 0 + else + printf ' [FAIL] %s\n' "$label" + return 1 + fi +} + +plangate_check_file() { + label=$1 + path=$2 + if [ -f "$path" ]; then + printf ' [PASS] %s\n' "$label" + return 0 + else + printf ' [FAIL] %s — not found: %s\n' "$label" "$path" + return 1 + fi +} + +plangate_append_ndjson() { + file=$1 + json=$2 + printf '%s\n' "$json" >> "$file" +} + +# Extract c3 required_artifacts from a workflow YAML and print one filename per line. +# Uses python3 for YAML parsing (python3 is required by the timeline command already). +plangate_yaml_c3_artifacts() { + yaml_file=$1 + python3 - "$yaml_file" <<'PYEOF' +import sys, re +with open(sys.argv[1]) as f: + content = f.read() +# Find required_artifacts: [...] under gate_enforcement.c3 +m = re.search(r'gate_enforcement.*?c3:.*?required_artifacts:\s*\[([^\]]+)\]', content, re.DOTALL) +if m: + mapping = { + 'pbi_input': 'pbi-input.md', 'plan': 'plan.md', 'todo': 'todo.md', + 'test_cases': 'test-cases.md', 'review_self': 'review-self.md', + 'review_external': 'review-external.md', 'handoff': 'handoff.md', + } + for item in m.group(1).split(','): + key = item.strip() + print(mapping.get(key, key + '.md')) +PYEOF +} + +# [TASK-0144] .plangate.yml から設定値を読む(c3_approval.mode 等) +# 使い方: _read_plangate_config (例: c3_approval.mode) +# .plangate.yml 未存在 -> 'cli' (デフォルト) +# PyYAML 未インストール・不正 YAML・不正 mode 値 -> stderr WARN + 'cli' +_read_plangate_config() { + _rpc_key="${1:-c3_approval.mode}" + _rpc_cfg="$plangate_root/.plangate.yml" + if [ ! -f "$_rpc_cfg" ]; then + printf 'cli' + return 0 + fi + _rpc_val=$(python3 - "$_rpc_cfg" "$_rpc_key" <<'PYC' +import sys +cfg_path, key = sys.argv[1], sys.argv[2] +try: + import yaml + with open(cfg_path, "r", encoding="utf-8") as f: + d = yaml.safe_load(f) + if not isinstance(d, dict): + sys.stderr.write("warn: .plangate.yml is not a YAML mapping\n") + print("cli"); sys.exit(0) + val = d + for part in key.split("."): + if not isinstance(val, dict): + sys.stderr.write("warn: .plangate.yml key path broken: {}\n".format(key)) + print("cli"); sys.exit(0) + val = val.get(part) + if val is None: + print("cli"); sys.exit(0) + allowed = {"cli", "conversation"} + if str(val) not in allowed: + sys.stderr.write("warn: .plangate.yml {}: invalid value: {}\n".format(key, val)) + print("cli"); sys.exit(0) + print(str(val)) +except ImportError: + sys.stderr.write("warn: PyYAML not installed; falling back to cli mode\n") + print("cli") +except Exception as e: + sys.stderr.write("warn: .plangate.yml read error: {}\n".format(e)) + print("cli") +PYC +) || _rpc_val="cli" + [ -n "$_rpc_val" ] || _rpc_val="cli" + printf '%s' "$_rpc_val" +} + +# ── commands ────────────────────────────────────────────────────────────────── + +cmd_help() { + printf '%s\n' \ + "PlanGate CLI v$PLANGATE_VERSION" \ + "" \ + "Usage: plangate [args]" \ + "" \ + "Commands:" \ + " init Create task folder and template files" \ + " brainstorm Brainstorm PBI INPUT PACKAGE (wraps ai-dev-workflow)" \ + " plan Generate plan.md/todo.md/test-cases.md (B-1->B-2->B-3)" \ + " gate Check C-1/C-2/C-3 gate readiness" \ + " verify [--mode] Run V-1 (validate) + settings lock + V-3 + eval + metrics" \ + " handoff Copy handoff.md template into TASK working dir" \ + " doctor [--json] [--scope ] [--fix [--dry-run] [--yes]] [--check-settings]" \ + " Check environment; --fix wires hooks (TASK-0069)" \ + " status Show current phase and next action" \ + " validate [--mode] Verify artifacts and plan_hash integrity" \ + " validate-schemas Validate JSON artifacts against schemas/ (Issue #158)" \ + " eval [--baseline] Run 8-aspect eval (Issue #156)" \ + " metrics [--collect|--report] Collect/report workflow metrics (Issue #195)" \ + " plan-check [--init|--validate] Lightweight plan quality check (#213)" \ + " report --from --to Period report for retrospective (#200)" \ + " context --phase

[--mode ] [--profile ] Dynamic Context Engine v1 (opt-in, #199)" \ + " keep-rate [--no-write] Keep Rate v1 (advisory, #198)" \ + " review [--phase] Run external AI review (c2/v3)" \ + " exec [--mode] Dispatch implementation agent (after C-3)" \ + " abort [--reason] Record abort event in run.ndjson" \ + " timeline Display run.ndjson event log" \ + " resume Show current-state.md for session resume" \ + " approve [--reject|--conditional] Human one-action C-3 approval (L1-L4, schema-valid c3.json) (TASK-0128)" \ + " render [--html] Aggregate C-3 review MDs into self-contained HTML (TASK-0127)" \ + " maintenance ... In-session edit window (Human-only, L1-L4 defense) (TASK-0106)" \ + " version Print version" +} + +cmd_version() { + printf 'plangate %s\n' "$PLANGATE_VERSION" +} + +cmd_init() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate init \n' >&2 + return 1 + fi + task_id=$1 + plangate_validate_task_id "$task_id" + work_dir="$plangate_working_dir/$task_id" + + if [ -d "$work_dir" ]; then + printf 'Task directory already exists: %s\n' "$work_dir" + printf 'Use plangate status %s to check current state.\n' "$task_id" + return 0 + fi + + mkdir -p "$work_dir/approvals" "$work_dir/evidence" + + cat > "$work_dir/pbi-input.md" < + +## Design/UI Addendum(UI タスク時のみ・非UIは削除可) + +\`is_ui_task: false\` + +### 1. 踏襲元の明示(★最優先・単独でも効果大) + +- 真似る既存実装(X): TODO(path / component) +- 真似てはいけない(not Y): TODO + 理由 +- 参照なしの場合: \`[参照なし]\` と明示(曖昧進行・ゲート回避を防ぐ) + +### 2. 真実源(source of truth)— 環境分岐 + +- Figma あり: ノード URL / フレーム参照 = TODO(pixel/token は Figma 由来) +- Figma なし: 踏襲元の既存パターンを正 +- どちらも無: \`[参照なし]\`(視覚受入基準・回帰ガードは必須のまま) + +### 3. 配置仕様 + +- 挿入位置(どの要素の前/後): TODO +- 余白/間隔(できればデザイントークン名): TODO + +### 4. レスポンシブ・マトリクス + +| ビューポート | 期待表示 | +|------------|---------| +| PC | TODO | +| SP | TODO | + +### 5. 視覚的受入基準 + +- TODO(要素の有無・リンク先・崩れないこと等、検証可能な形で) + +### 6. 視覚的回帰ガード + +- 変えてはいけない既存表示: TODO + +### 7. 視覚証跡 + +- 受入時スクショ: Figma vs 実装 / または before/after(PC・SP) +EOF + + cat > "$work_dir/current-state.md" < 更新日: $(plangate_today) +> フェーズ: A — PBI INPUT 作成中 + +## 中断地点 + +pbi-input.md を記入してください。 + +## 次のアクション + +1. \`docs/working/$task_id/pbi-input.md\` の Why / What / AC を埋める +2. \`/ai-dev-workflow $task_id plan\` で plan / todo / test-cases を生成する +EOF + + cat > "$work_dir/INDEX.md" < 生成日: $(plangate_today) +> フェーズ: A + +## ファイル一覧 + +| ファイル | 状態 | +| --- | --- | +| pbi-input.md | draft | +| plan.md | - | +| todo.md | - | +| test-cases.md | - | +| review-self.md | - | +| handoff.md | - | +EOF + + printf 'Initialized: %s\n' "$work_dir" + printf 'Next: edit %s/pbi-input.md\n' "$work_dir" +} + +cmd_doctor() { + # T-7 (TASK-0069): 引数を解析。 + # --json : 機械可読出力(doctor_check.py へ委譲) + # --scope : doctor_check.py へ透過転送(default v8.6.0) + # --fix/--dry-run/--yes : 修復系(実体は T-8 で追加。ここでは flag 受領と + # 非 tty + --yes 無し時の安全 abort 土台のみ) + doctor_json=0 + doctor_scope=v8.6.0 + doctor_fix=0 + doctor_dry_run=0 + doctor_yes=0 + doctor_check_settings=0 + while [ $# -gt 0 ]; do + case "$1" in + --json) doctor_json=1 ;; + --scope) + if [ $# -lt 2 ]; then + printf 'plangate doctor: --scope requires a value\n' >&2 + return 2 + fi + doctor_scope=$2 + shift + ;; + --scope=*) doctor_scope=${1#--scope=} ;; + --fix) doctor_fix=1 ;; + --dry-run) doctor_dry_run=1 ;; + --yes) doctor_yes=1 ;; + --check-settings) doctor_check_settings=1 ;; + *) + printf 'plangate doctor: unknown argument: %s\n' "$1" >&2 + return 2 + ;; + esac + shift + done + + # TASK-0080 S1b: --check-settings は settings wiring 契約検証へ委譲 + # (既存 doctor ロジック非破壊。単独フラグとして早期 return)。 + if [ "$doctor_check_settings" -eq 1 ]; then + csw="$plangate_root/scripts/check-settings-wiring.sh" + if [ ! -f "$csw" ]; then + printf 'plangate doctor --check-settings: %s not found\n' "$csw" >&2 + return 2 + fi + sh "$csw" --target user + return $? + fi + + # K-3 (v8.6.0 PR7) + T-7: --json は doctor_check.py へ委譲。--scope を透過転送 + # (指定なし時は従来通り v8.6.0 → 出力バイト互換)。 + if [ "$doctor_json" -eq 1 ]; then + py="$plangate_root/scripts/doctor_check.py" + if [ ! -f "$py" ]; then + printf '{"error": "scripts/doctor_check.py not found"}\n' >&2 + return 2 + fi + python3 "$py" --scope "$doctor_scope" + return $? + fi + + # T-7: 非 tty かつ --yes 無しで --fix を要求された場合は、確認プロンプトを + # 出せず(set -eu 下で read が EOF を返すと中断/誤動作するため)安全に + # abort する。実際の修復ロジック自体は T-8 で追加する。 + if [ "$doctor_fix" -eq 1 ] && [ "$doctor_yes" -eq 0 ] && [ "$doctor_dry_run" -eq 0 ]; then + if [ ! -t 0 ]; then + printf 'plangate doctor --fix: refusing to prompt on a non-interactive stdin.\n' >&2 + printf ' No changes were made. Re-run with --yes to proceed.\n' >&2 + return 3 + fi + fi + + printf 'PlanGate Doctor — environment check\n' + printf '\n' + + failures=0 + + printf '=== CLI Tools ===\n' + plangate_check "gh (GitHub CLI)" command -v gh || failures=$((failures + 1)) + plangate_check "git" command -v git || failures=$((failures + 1)) + plangate_check "codex (Codex CLI)" command -v codex || failures=$((failures + 1)) + printf '\n' + + printf '=== Claude Code Plugin / .claude/ ===\n' + has_plugin=0 + has_claude_dir=0 + + if [ -d "$plangate_root/plugin/plangate" ]; then + printf ' [INFO] plugin/plangate/ exists (Option A)\n' + has_plugin=1 + fi + if [ -d "$HOME/.claude/commands" ] || [ -d "$plangate_root/.claude/commands" ]; then + printf ' [INFO] .claude/commands/ exists (Option B)\n' + has_claude_dir=1 + fi + + if [ "$has_plugin" -eq 1 ] && [ "$has_claude_dir" -eq 1 ]; then + printf ' [WARN] Both plugin and .claude/ detected — may cause duplicate command loading\n' + printf ' See: TROUBLESHOOTING.md — Double installation\n' + elif [ "$has_plugin" -eq 0 ] && [ "$has_claude_dir" -eq 0 ]; then + printf ' [FAIL] Neither plugin/plangate/ nor .claude/commands/ found\n' + printf ' Install instructions: README.md — Install\n' + failures=$((failures + 1)) + else + printf ' [PASS] Installation method detected\n' + fi + printf '\n' + + printf '=== Codex Plugin (non-fatal) ===\n' + if [ -f "$plangate_root/scripts/check-codex-plugin-status.sh" ]; then + sh "$plangate_root/scripts/check-codex-plugin-status.sh" || true + else + printf ' [INFO] scripts/check-codex-plugin-status.sh not found\n' + fi + printf '\n' + + printf '=== Required Workflow Files ===\n' + plangate_check "docs/working/ directory exists" test -d "$plangate_working_dir" || failures=$((failures + 1)) + plangate_check_file "docs/working/templates/handoff.md" "$plangate_templates_dir/handoff.md" || failures=$((failures + 1)) + plangate_check_file "CLAUDE.md" "$plangate_root/CLAUDE.md" || failures=$((failures + 1)) + plangate_check "docs/working/ writable" test -w "$plangate_working_dir" || failures=$((failures + 1)) + printf '\n' + + printf '=== Rules ===\n' + for rule in working-context review-principles mode-classification; do + plangate_check_file "$rule.md" "$plangate_root/.claude/rules/$rule.md" || failures=$((failures + 1)) + done + printf '\n' + + printf '=== v8.6.0 Metrics & Privacy ===\n' + # F-3 (v8.6.0 改善 PR5): metrics v1 / privacy 強制の health check + plangate_check_file "schemas/plangate-event.schema.json" "$plangate_root/schemas/plangate-event.schema.json" || failures=$((failures + 1)) + plangate_check_file "schemas/eval-baseline.schema.json" "$plangate_root/schemas/eval-baseline.schema.json" || failures=$((failures + 1)) + plangate_check_file "scripts/metrics_collector.py" "$plangate_root/scripts/metrics_collector.py" || failures=$((failures + 1)) + plangate_check_file "scripts/metrics_reporter.py" "$plangate_root/scripts/metrics_reporter.py" || failures=$((failures + 1)) + plangate_check_file "scripts/baseline-snapshot.py" "$plangate_root/scripts/baseline-snapshot.py" || failures=$((failures + 1)) + plangate_check_file "scripts/hooks/check-metrics-privacy.sh (EH-8)" "$plangate_root/scripts/hooks/check-metrics-privacy.sh" || failures=$((failures + 1)) + if [ -x "$plangate_root/scripts/hooks/check-metrics-privacy.sh" ]; then + printf ' [PASS] EH-8 hook is executable\n' + else + printf ' [WARN] scripts/hooks/check-metrics-privacy.sh is not executable\n' + fi + if grep -q '_metrics/events.ndjson' "$plangate_root/.gitignore" 2>/dev/null; then + printf ' [PASS] docs/working/_metrics/events.ndjson is .gitignore-d (privacy §8)\n' + else + printf ' [FAIL] events.ndjson missing from .gitignore — privacy §8 not enforced\n' + failures=$((failures + 1)) + fi + if [ -f "$plangate_root/docs/working/_metrics/events.ndjson" ]; then + if git -C "$plangate_root" check-ignore docs/working/_metrics/events.ndjson >/dev/null 2>&1; then + printf ' [PASS] events.ndjson exists locally and is git-ignored\n' + else + printf ' [FAIL] events.ndjson exists but is NOT git-ignored\n' + failures=$((failures + 1)) + fi + else + printf ' [INFO] events.ndjson absent (no metrics collected yet — opt-in)\n' + fi + plangate_check_file "docs/ai/metrics.md" "$plangate_root/docs/ai/metrics.md" || failures=$((failures + 1)) + plangate_check_file "docs/ai/metrics-privacy.md" "$plangate_root/docs/ai/metrics-privacy.md" || failures=$((failures + 1)) + printf '\n' + + printf '=== Hook Enforcement Wiring ===\n' + # T-7 (TASK-0069 / AC-1): .claude/settings.json が .claude/settings.example.json + # の各 hook ブロック (event, matcher, type, command) を含むかを判定する。 + # 正本は settings.example.json(scripts/hooks/ のファイル数とは独立)。 + # command 文字列のみの集合一致は不採用(別 event/matcher に存在しても誤 PASS + # するため)。ブロック単位で同一 event 配下に同一 matcher/type/command を要求。 + # privacy: presence の真偽のみ。settings 内容・パス値は出力しない。 + doctor_example="$plangate_root/.claude/settings.example.json" + doctor_settings="$plangate_root/.claude/settings.json" + if [ ! -f "$doctor_example" ]; then + printf ' [WARN] .claude/settings.example.json not found — cannot evaluate hook wiring\n' + else + doctor_wiring_rc=0 + python3 - "$doctor_example" "$doctor_settings" <<'PYEOF' || doctor_wiring_rc=$? +import json +import sys + +example_path, settings_path = sys.argv[1], sys.argv[2] + + +def blocks(doc): + """Yield (event, matcher, type, command) tuples from a settings doc.""" + out = set() + hooks = (doc or {}).get("hooks", {}) + if not isinstance(hooks, dict): + return out + for event, entries in hooks.items(): + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + matcher = entry.get("matcher") + for h in entry.get("hooks", []) or []: + if not isinstance(h, dict): + continue + out.add((event, matcher, h.get("type"), h.get("command"))) + return out + + +try: + with open(example_path, "r", encoding="utf-8") as f: + expected = blocks(json.load(f)) +except (OSError, ValueError): + print(" [WARN] .claude/settings.example.json is unreadable or invalid JSON") + sys.exit(0) + +if not expected: + print(" [WARN] .claude/settings.example.json defines no hook blocks") + sys.exit(0) + +try: + with open(settings_path, "r", encoding="utf-8") as f: + present = blocks(json.load(f)) +except FileNotFoundError: + present = set() +except (OSError, ValueError): + print(" [FAIL] PlanGate hooks not wired" + " (.claude/settings.json is missing or not valid JSON)") + sys.exit(1) + +missing = expected - present +if missing: + print(" [FAIL] PlanGate hooks not wired" + " (%d/%d expected hook block(s) missing from .claude/settings.json)" + % (len(missing), len(expected))) + print(" Run: plangate doctor --fix (see TROUBLESHOOTING.md)") + sys.exit(1) + +print(" [PASS] PlanGate hooks wired (%d/%d hook blocks present)" + % (len(expected), len(expected))) +sys.exit(0) +PYEOF + if [ "$doctor_wiring_rc" -ne 0 ]; then + failures=$((failures + 1)) + fi + fi + printf '\n' + + printf '=== CLI Hook Wiring (EH-4/5/7) ===\n' + _cli_hook_failures=0 + for _cli_hook_name in check-test-cases check-verification-evidence check-merge-approvals; do + _cli_hook_path="$plangate_root/scripts/hooks/${_cli_hook_name}.sh" + if [ -f "$_cli_hook_path" ] && [ -x "$_cli_hook_path" ]; then + printf ' [PASS] %s.sh exists and is executable\n' "$_cli_hook_name" + elif [ -f "$_cli_hook_path" ]; then + printf ' [WARN] %s.sh exists but is not executable\n' "$_cli_hook_name" + else + printf ' [FAIL] %s.sh not found\n' "$_cli_hook_name" + _cli_hook_failures=$((_cli_hook_failures + 1)) + fi + done + if [ "$_cli_hook_failures" -gt 0 ]; then + failures=$((failures + _cli_hook_failures)) + fi + if grep -q 'check-test-cases\.sh' "$plangate_root/bin/plangate" 2>/dev/null; then + printf ' [PASS] EH-4 wired in bin/plangate verify\n' + else + printf ' [WARN] EH-4 not wired -- run: sh scripts/apply-task-0143-eh457-wiring.sh --apply\n' + fi + if grep -q 'check-verification-evidence\.sh' "$plangate_root/bin/plangate" 2>/dev/null; then + printf ' [PASS] EH-5 wired in bin/plangate verify\n' + else + printf ' [WARN] EH-5 not wired -- run: sh scripts/apply-task-0143-eh457-wiring.sh --apply\n' + fi + printf ' [INFO] EH-7: run before merge: sh scripts/hooks/check-merge-approvals.sh \n' + printf '\n' + + printf '=== C-3 Approval Mode ===\n' + _c3mode_doctor=$(_read_plangate_config c3_approval.mode || echo 'cli') + if [ -f "$plangate_root/.plangate.yml" ]; then + printf ' [INFO] .plangate.yml found: c3_approval.mode=%s\n' "$_c3mode_doctor" + else + printf ' [INFO] .plangate.yml not found (default: c3_approval.mode=cli)\n' + fi + printf '\n' + + printf '=== Optional Provider CLIs ===\n' + if command -v gemini >/dev/null 2>&1; then + printf ' [INFO] gemini CLI found (PLANGATE_EXTERNAL_REVIEWER=gemini supported)\n' + else + printf ' [INFO] gemini CLI not found (optional — install to enable plangate review with Gemini)\n' + fi + if command -v opencode >/dev/null 2>&1; then + printf ' [INFO] opencode found (PLANGATE_IMPL_AGENT=opencode supported)\n' + else + printf ' [INFO] opencode not found (optional — install to enable plangate exec with OpenCode)\n' + fi + printf '\n' + + printf '=== Cursor Provider (optional) ===\n' + if [ -f "$plangate_root/.cursor/hooks.json" ]; then + printf ' [PASS] .cursor/hooks.json present\n' + else + printf ' [WARN] .cursor/hooks.json missing — see docs/cursor/quickstart.md\n' + fi + if [ -x "$plangate_root/scripts/hooks/cursor-adapter.sh" ]; then + printf ' [PASS] scripts/hooks/cursor-adapter.sh executable\n' + else + printf ' [WARN] scripts/hooks/cursor-adapter.sh missing or not executable\n' + fi + if [ -f "$plangate_root/docs/rfc/provider-cursor.md" ]; then + printf ' [PASS] docs/rfc/provider-cursor.md\n' + else + printf ' [WARN] docs/rfc/provider-cursor.md missing\n' + fi + printf ' [INFO] PLANGATE_IMPL_AGENT=cursor — manual Agent handoff (docs/cursor/quickstart.md)\n' + printf '\n' + + # T-8 (TASK-0069): --fix / --dry-run / --yes 修復分岐。 + # settings.json は scripts/doctor_fix.py 経由(直接書換禁止)。 + # 併せて EH-8 chmod / .gitignore 追記 / docs/working mkdir を修復。 + # gh/codex 未導入は自動インストールせず案内のみ(exit code は他修復に従う)。 + if [ "$doctor_fix" -eq 1 ]; then + printf '=== Fix (TASK-0069) ===\n' + + fix_py="$plangate_root/scripts/doctor_fix.py" + if [ ! -f "$fix_py" ]; then + printf ' [FAIL] scripts/doctor_fix.py not found — cannot apply fixes\n' >&2 + return 2 + fi + + if [ "$doctor_dry_run" -eq 1 ]; then + # Dry-run: 全修復対象を非書込で計画表示のみ。 + printf ' [PLAN] dry-run — no files will be modified\n' + printf ' [PLAN] settings.json hooks (via doctor_fix.py --dry-run):\n' + dry_run_output=$(python3 "$fix_py" --project-dir "$plangate_root" --dry-run) || { + printf ' [FAIL] doctor_fix.py --dry-run failed\n' >&2 + return 2 + } + printf '%s\n' "$dry_run_output" | sed 's/^/ /' + eh8="$plangate_root/scripts/hooks/check-metrics-privacy.sh" + if [ -f "$eh8" ] && [ ! -x "$eh8" ]; then + printf ' [PLAN] would: chmod +x scripts/hooks/check-metrics-privacy.sh (EH-8)\n' + fi + if ! grep -q '_metrics/events.ndjson' "$plangate_root/.gitignore" 2>/dev/null; then + printf ' [PLAN] would: append docs/working/_metrics/events.ndjson to .gitignore\n' + fi + if [ ! -d "$plangate_working_dir" ]; then + printf ' [PLAN] would: mkdir -p docs/working/\n' + fi + printf '\n' + printf 'Result: DRY-RUN (no changes made; %d check(s) currently failing)\n' "$failures" + return 0 + fi + + # 確認ゲート: --yes でスキップ。非 tty かつ --yes 無しは(T-7 の土台に + # 加えここでも)安全に abort(プロンプトで hang させない)。 + if [ "$doctor_yes" -eq 0 ]; then + if [ ! -t 0 ]; then + printf 'plangate doctor --fix: refusing to prompt on a non-interactive stdin.\n' >&2 + printf ' No changes were made. Re-run with --yes to proceed.\n' >&2 + return 3 + fi + printf ' This will modify .claude/settings.json (merge-only, backup kept),\n' + printf ' scripts/hooks/check-metrics-privacy.sh permissions, .gitignore and docs/working/.\n' + printf ' Proceed? [y/N] ' + fix_reply=n + read -r fix_reply || fix_reply=n + case "$fix_reply" in + y | Y | yes | YES) ;; + *) + printf ' Aborted by user. No changes were made.\n' + return 4 + ;; + esac + fi + + fix_rc=0 + + # 1. settings.json: doctor_fix.py --apply(merge-only / backup / 冪等) + if python3 "$fix_py" --project-dir "$plangate_root" --apply; then + printf ' [DONE] settings.json hooks merged (doctor_fix.py --apply)\n' + else + printf ' [FAIL] doctor_fix.py --apply failed\n' >&2 + fix_rc=1 + fi + + # 2. EH-8 実行ビット + eh8="$plangate_root/scripts/hooks/check-metrics-privacy.sh" + if [ -f "$eh8" ]; then + if [ -x "$eh8" ]; then + printf ' [SKIP] EH-8 hook already executable\n' + elif chmod +x "$eh8"; then + printf ' [DONE] chmod +x scripts/hooks/check-metrics-privacy.sh (EH-8)\n' + else + printf ' [FAIL] could not chmod +x EH-8 hook\n' >&2 + fix_rc=1 + fi + fi + + # 3. .gitignore に events.ndjson 追記(privacy §8) + gitignore="$plangate_root/.gitignore" + if grep -q '_metrics/events.ndjson' "$gitignore" 2>/dev/null; then + printf ' [SKIP] .gitignore already excludes events.ndjson\n' + else + if [ -f "$gitignore" ] && [ -s "$gitignore" ] && [ "$(tail -c 1 "$gitignore")" != "$(printf '\n')" ]; then + printf '\n' >> "$gitignore" || { + printf ' [FAIL] could not append newline to .gitignore\n' >&2 + fix_rc=1 + } + fi + fi + if [ "$fix_rc" -eq 0 ] && ! grep -q '_metrics/events.ndjson' "$gitignore" 2>/dev/null; then + if printf '%s\n' 'docs/working/_metrics/events.ndjson' >> "$gitignore"; then + printf ' [DONE] appended events.ndjson to .gitignore\n' + else + printf ' [FAIL] could not append to .gitignore\n' >&2 + fix_rc=1 + fi + fi + + # 4. docs/working/ ディレクトリ + if [ -d "$plangate_working_dir" ]; then + printf ' [SKIP] docs/working/ already exists\n' + elif mkdir -p "$plangate_working_dir"; then + printf ' [DONE] created docs/working/\n' + else + printf ' [FAIL] could not create docs/working/\n' >&2 + fix_rc=1 + fi + + # 5. gh / codex 未導入は案内のみ(自動インストールしない) + if ! command -v gh >/dev/null 2>&1; then + printf ' [INFO] gh (GitHub CLI) not installed — install manually: https://cli.github.com/\n' + fi + if ! command -v codex >/dev/null 2>&1; then + printf ' [INFO] codex (Codex CLI) not installed — install manually per your provider docs\n' + fi + + printf '\n' + if [ "$fix_rc" -ne 0 ]; then + printf 'Result: FIX FAILED (one or more repair steps failed)\n' + return 1 + fi + printf 'Result: FIX APPLIED (re-run `plangate doctor` to verify)\n' + return 0 + fi + + # TASK-0106 T-07a: maintenance 窓の有無を 1 行表示(doctor 全 scope 共通の補足) + _maint_file="$plangate_root/docs/working/_maintenance/maintenance.json" + if [ -f "$_maint_file" ]; then + _m=$(python3 - "$_maint_file" <<'PYM' 2>/dev/null || true +import json, sys, time +try: + d = json.load(open(sys.argv[1])) + now = int(time.time()) + rem = max(0, int(d.get("until", 0)) - now) + one_shot = bool(d.get("one_shot", False)) + consumed = d.get("consumed_at") is not None + active = (int(d.get("granted_at", 0)) <= now and rem > 0 and not consumed) + if active: + ap = d.get('allowed_paths') + paths_str = ap if ap is not None else '(any non-Override)' + print(f"active remaining={rem//60:02d}:{rem%60:02d} scope={d.get('scope','')} one_shot={str(one_shot).lower()} paths={paths_str}") + elif consumed: + print(f"consumed (one_shot) — re-issue with 'plangate maintenance start' if needed") + elif rem == 0: + print(f"expired — use 'plangate maintenance stop' to clean up") + else: + print("inactive") +except Exception as e: + print(f"unparsable: {e}") +PYM +) + printf '\n=== Maintenance Window ===\n' + printf ' [INFO] %s\n' "$_m" + fi + + if [ "$failures" -eq 0 ]; then + printf 'Result: PASS (all checks passed)\n' + else + printf 'Result: FAIL (%d check(s) failed)\n' "$failures" + return 1 + fi +} + +cmd_status() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate status \n' >&2 + return 1 + fi + task_id=$1 + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + + work_dir="$plangate_working_dir/$task_id" + + printf 'Task: %s\n' "$task_id" + printf 'Work dir: %s\n' "$work_dir" + printf '\n' + + # Artifact presence + printf '=== Artifacts ===\n' + for f in pbi-input.md plan.md todo.md test-cases.md review-self.md handoff.md; do + if [ -f "$work_dir/$f" ]; then + printf ' [x] %s\n' "$f" + else + printf ' [ ] %s\n' "$f" + fi + done + printf '\n' + + # Gate status + printf '=== Gates ===\n' + if [ -f "$work_dir/approvals/c3.json" ]; then + c3_status=$(grep '"c3_status"' "$work_dir/approvals/c3.json" 2>/dev/null | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "unknown") + printf ' C-3: %s\n' "$c3_status" + else + printf ' C-3: pending\n' + fi + if [ -f "$work_dir/approvals/c4.json" ]; then + c4_status=$(grep '"c4_status"' "$work_dir/approvals/c4.json" 2>/dev/null | sed 's/.*"c4_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "unknown") + printf ' C-4: %s\n' "$c4_status" + else + printf ' C-4: pending\n' + fi + printf '\n' + + # Phase inference + printf '=== Phase ===\n' + if [ ! -f "$work_dir/pbi-input.md" ]; then + phase="A — PBI input required" + next="Edit docs/working/$task_id/pbi-input.md" + elif [ ! -f "$work_dir/plan.md" ]; then + phase="B — plan generation required" + next="/ai-dev-workflow $task_id plan" + elif [ ! -f "$work_dir/approvals/c3.json" ]; then + phase="C-3 — human review pending" + next="Review plan.md → create approvals/c3.json" + elif [ ! -f "$work_dir/handoff.md" ]; then + phase="D / V — exec or verification" + next="/ai-dev-workflow $task_id exec" + else + phase="Done" + next="Close issue and merge PR" + fi + printf ' Current: %s\n' "$phase" + printf ' Next: %s\n' "$next" + + # current-state.md excerpt + if [ -f "$work_dir/current-state.md" ]; then + printf '\n=== current-state.md (last 10 lines) ===\n' + tail -10 "$work_dir/current-state.md" + fi +} + +# c3-prime / legacy c3.json の受理を判別・検証する共有ヘルパー(TASK-0872 PR-2)。 +# approvals/c3.json の approval_kind を strict JSON で判別し、c3-prime なら +# scripts/ai-loop/c3prime_verify.py で Plan Package 束縛を全数再検証する +# (契約 §4/§5: legacy grep 経路は c3-prime に流用しない)。 +# 戻り値: 0=c3-prime 受理 / 10=legacy(呼び出し側が grep 経路で処理)/ 1=c3-prime NG +_plangate_c3_dispatch() { + _c3d_dir="$1" + _c3d_verify="$plangate_root/scripts/ai-loop/c3prime_verify.py" + if [ ! -f "$_c3d_verify" ]; then + # 検証器が無い環境では c3-prime を受理できない(fail-closed)。 + # legacy 判別のみ python3 で行い、legacy なら委譲・c3-prime なら NG。 + _c3d_kind=$(python3 -c "import json,sys +try: d=json.load(open(sys.argv[1])) +except Exception: print(''); sys.exit(0) +print(d.get('approval_kind','') if isinstance(d,dict) else '')" "$_c3d_dir/approvals/c3.json" 2>/dev/null || echo "") + [ "$_c3d_kind" = "c3-prime" ] && return 1 + return 10 + fi + python3 "$_c3d_verify" "$_c3d_dir" >&2 + return $? +} + +cmd_validate() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate validate [--mode ] | --dir [--mode ]\n' >&2 + return 1 + fi + + # TA-01: parse all flags first, then dispatch by positional arg + work_dir="" + label="" + task_id="" + mode="" + + while [ $# -gt 0 ]; do + case "$1" in + --dir) + shift + if [ $# -eq 0 ]; then + printf 'Usage: plangate validate --dir \n' >&2 + return 1 + fi + work_dir=$1 + label=$(basename "$work_dir") + ;; + --mode) + shift + if [ $# -eq 0 ]; then + printf 'error: --mode requires a value\n' >&2 + return 1 + fi + mode=$1 + ;; + *) + task_id=$1 + ;; + esac + shift + done + + if [ -n "$task_id" ]; then + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + work_dir="$plangate_working_dir/$task_id" + label="$task_id" + fi + + if [ -z "$work_dir" ]; then + printf 'Usage: plangate validate [--mode ] | --dir [--mode ]\n' >&2 + return 1 + fi + + if [ ! -d "$work_dir" ]; then + printf 'error: Directory not found: %s\n' "$work_dir" >&2 + return 1 + fi + + failures=0 + + printf 'Validating: %s\n\n' "$label" + + # TA-01: use YAML-derived artifact list when --mode is specified + printf '=== Required Artifacts ===\n' + if [ -n "$mode" ]; then + yaml_file="$plangate_root/workflows/$mode.yaml" + if [ ! -f "$yaml_file" ]; then + printf 'error: Unknown mode: %s (no %s)\n' "$mode" "$yaml_file" >&2 + return 1 + fi + artifact_list=$(plangate_yaml_c3_artifacts "$yaml_file") + for f in $artifact_list; do + plangate_check_file "$f" "$work_dir/$f" || failures=$((failures + 1)) + done + else + for f in pbi-input.md plan.md todo.md test-cases.md review-self.md; do + plangate_check_file "$f" "$work_dir/$f" || failures=$((failures + 1)) + done + fi + printf '\n' + + printf '=== C-3 Gate ===\n' + c3_file="$work_dir/approvals/c3.json" + if [ ! -f "$c3_file" ]; then + printf ' [FAIL] approvals/c3.json not found\n' + failures=$((failures + 1)) + else + printf ' [PASS] approvals/c3.json exists\n' + # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 + # legacy(approval_kind 無し)は従来の grep 経路で処理する。 + # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 + _c3_rc=0 + _plangate_c3_dispatch "$work_dir" || _c3_rc=$? + if [ "$_c3_rc" = "0" ]; then + printf ' [PASS] c3-prime record verified (decision=AUTO_APPROVED, Plan Package 束縛整合)\n' + elif [ "$_c3_rc" = "1" ]; then + printf ' [FAIL] c3-prime verification failed (see message above)\n' + failures=$((failures + 1)) + else + # _c3_rc == 10: legacy 経路(従来どおり grep で c3_status / plan_hash 検証) + c3_status=$(grep '"c3_status"' "$c3_file" 2>/dev/null | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") + if [ "$c3_status" = "APPROVED" ]; then + printf ' [PASS] c3_status = APPROVED\n' + else + printf ' [FAIL] c3_status = %s (expected APPROVED)\n' "${c3_status:-missing}" + failures=$((failures + 1)) + fi + + # plan_hash check + plan_file="$work_dir/plan.md" + if [ -f "$plan_file" ]; then + recorded_hash=$(grep '"plan_hash"' "$c3_file" 2>/dev/null | sed 's/.*"plan_hash"[[:space:]]*:[[:space:]]*"sha256:\([^"]*\)".*/\1/' || echo "") + if [ -z "$recorded_hash" ]; then + printf ' [WARN] plan_hash not found in c3.json\n' + else + current_hash=$(plangate_sha256 "$plan_file") + if [ "$current_hash" = "$recorded_hash" ]; then + printf ' [PASS] plan.md hash matches c3.json (no post-approval modification)\n' + else + printf ' [FAIL] plan.md hash MISMATCH — plan was modified after C-3 approval\n' + printf ' recorded: sha256:%s\n' "$recorded_hash" + printf ' current: sha256:%s\n' "$current_hash" + failures=$((failures + 1)) + fi + fi + fi + fi + fi + printf '\n' + + if [ "$failures" -eq 0 ]; then + printf 'Result: PASS\n' + else + printf 'Result: FAIL (%d issue(s))\n' "$failures" + return 1 + fi +} + +cmd_validate_schemas() { + # Validate JSON artifacts under a TASK dir / arbitrary --dir / explicit files + # against schemas/. Wraps scripts/validate-schemas.py. + # Usage: + # plangate validate-schemas + # plangate validate-schemas --dir + # plangate validate-schemas --files-from + # plangate validate-schemas ... + if [ $# -eq 0 ]; then + printf 'Usage: plangate validate-schemas | --dir | --files-from | ...\n' >&2 + return 1 + fi + + py_script="$plangate_root/scripts/validate-schemas.py" + if [ ! -f "$py_script" ]; then + printf 'error: scripts/validate-schemas.py not found\n' >&2 + return 2 + fi + + # If first arg matches TASK-XXXX, expand to --dir + case "$1" in + TASK-*) + task_id=$1 + shift + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + python3 "$py_script" --dir "$plangate_working_dir/$task_id" "$@" + return $? + ;; + esac + + python3 "$py_script" "$@" + return $? +} + +cmd_eval() { + # Run 8-aspect eval against a task dir. Wraps scripts/eval-runner.py. + # Issue #156 / TASK-0049 + if [ $# -eq 0 ]; then + printf 'Usage: plangate eval [--baseline ] [--profile ] [--no-write] [--dogfood]\n' >&2 + printf ' --dogfood [#231] Dogfooding Eval v1: 5-item structural eval -> eval-dogfood.md\n' >&2 + return 1 + fi + + py_script="$plangate_root/scripts/eval-runner.py" + if [ ! -f "$py_script" ]; then + printf 'error: scripts/eval-runner.py not found\n' >&2 + return 2 + fi + + python3 "$py_script" "$@" + return $? +} + +cmd_metrics() { + # Collect / report / validate PlanGate workflow metrics for a TASK. + # Issue #195 / PBI-HI-001 (Metrics v1) + v8.6.0 PR5 (--validate) + if [ $# -eq 0 ]; then + printf 'Usage: plangate metrics [--collect|--report|--validate] [--aggregate] [--json|--markdown-section] [--since ]\n' >&2 + printf ' --collect Append metrics events to docs/working/_metrics/events.ndjson (default if no flag)\n' >&2 + printf ' --report Print summary instead of appending\n' >&2 + printf ' --validate Validate every line of events.ndjson against plangate-event.schema.json\n' >&2 + printf ' --timeline [experimental #229] Emit phase/gate/ts-ordered timeline JSON (schema 1.1)\n' >&2 + printf ' --aggregate Aggregate across all TASKs (--report only)\n' >&2 + printf ' --json JSON output (--report / --timeline)\n' >&2 + printf ' --markdown-section Emit handoff §7 markdown table (--report only, v8.6.0 PR7 / K-1)\n' >&2 + printf ' --since Filter events with ts >= ISO date (v8.6.0 PR7 / K-2)\n' >&2 + return 1 + fi + + collector="$plangate_root/scripts/metrics_collector.py" + reporter="$plangate_root/scripts/metrics_reporter.py" + if [ ! -f "$collector" ] || [ ! -f "$reporter" ]; then + printf 'error: metrics scripts not found under scripts/\n' >&2 + return 2 + fi + + mode="collect" + passthrough="" + task_id="" + events_log_arg="" + while [ $# -gt 0 ]; do + case "$1" in + --collect) mode="collect" ;; + --report) mode="report" ;; + --validate) mode="validate" ;; + --timeline) mode="timeline" ;; + --aggregate) passthrough="$passthrough --aggregate" ;; + --json) passthrough="$passthrough --json" ;; + --markdown-section) passthrough="$passthrough --markdown-section" ;; + --since) + shift + passthrough="$passthrough --since $1" + ;; + --dry-run) passthrough="$passthrough --dry-run" ;; + --events-log) + shift + events_log_arg="$1" + passthrough="$passthrough --events-log $1" + ;; + TASK-*) task_id="$1" ;; + *) + printf 'Unknown metrics arg: %s\n' "$1" >&2 + return 2 + ;; + esac + shift + done + + if [ "$mode" = "timeline" ]; then + # #229 PBI-HI-013 (experimental): phase/gate/ts 順 timeline JSON + case "$passthrough" in + *--aggregate*|*--markdown-section*) + printf 'plangate metrics --timeline: --aggregate/--markdown-section は併用不可\n' >&2 + return 2 ;; + esac + tl="$plangate_root/scripts/metrics_timeline.py" + if [ ! -f "$tl" ]; then + printf 'error: scripts/metrics_timeline.py not found\n' >&2 + return 2 + fi + if [ -n "$events_log_arg" ]; then + python3 "$tl" "$task_id" --events-log "$events_log_arg" + else + python3 "$tl" "$task_id" + fi + return $? + fi + + if [ "$mode" = "validate" ]; then + # H-1 (v8.6.0 PR5): validate events.ndjson against plangate-event.schema.json + schema="$plangate_root/schemas/plangate-event.schema.json" + log_path=${events_log_arg:-"$plangate_root/docs/working/_metrics/events.ndjson"} + if [ ! -f "$schema" ]; then + printf '[FAIL] schema not found: %s\n' "$schema" >&2 + return 2 + fi + if [ ! -f "$log_path" ]; then + printf '[INFO] events.ndjson not found at %s — nothing to validate\n' "$log_path" + return 0 + fi + python3 - "$schema" "$log_path" <<'PY' "$@" +import json, sys +try: + import jsonschema +except ImportError: + print("[SKIP] jsonschema not available — install with: pip install jsonschema", file=sys.stderr) + sys.exit(0) +schema_path, log_path = sys.argv[1], sys.argv[2] +schema = json.load(open(schema_path)) +ok = 0 +errors = [] +with open(log_path) as fp: + for ln, raw in enumerate(fp, 1): + line = raw.strip() + if not line: + continue + try: + ev = json.loads(line) + except json.JSONDecodeError as e: + errors.append(f"line {ln}: invalid JSON ({e})") + continue + try: + jsonschema.validate(ev, schema) + ok += 1 + except jsonschema.ValidationError as e: + errors.append(f"line {ln}: schema violation ({e.message})") +print(f"[INFO] validated {ok} valid event(s) against plangate-event.schema.json") +if errors: + for e in errors[:20]: + print(f"[FAIL] {e}", file=sys.stderr) + if len(errors) > 20: + print(f"[FAIL] ... and {len(errors)-20} more", file=sys.stderr) + sys.exit(1) +print("[PASS] all events valid") +PY + return $? + fi + + if [ "$mode" = "collect" ]; then + if [ -z "$task_id" ]; then + printf 'metrics --collect requires TASK-XXXX\n' >&2 + return 2 + fi + # shellcheck disable=SC2086 + python3 "$collector" "$task_id" $passthrough + return $? + fi + + # report + if [ -n "$task_id" ]; then + # shellcheck disable=SC2086 + python3 "$reporter" "$task_id" $passthrough + else + # shellcheck disable=SC2086 + python3 "$reporter" $passthrough + fi + return $? +} + +cmd_abort() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate abort [--reason "..."]\n' >&2 + return 1 + fi + task_id=$1 + shift + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + + reason="no reason specified" + while [ $# -gt 0 ]; do + case "$1" in + --reason) + shift + reason="${1:-}" + ;; + *) + reason="$1" + ;; + esac + shift + done + + work_dir="$plangate_working_dir/$task_id" + run_log="$work_dir/run.ndjson" + ts=$(plangate_now) + + # JSON-escape the reason: backslash and double-quote must be escaped + escaped_reason=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g') + plangate_append_ndjson "$run_log" \ + "{\"ts\":\"$ts\",\"task_id\":\"$task_id\",\"phase\":\"D\",\"event\":\"session_ended\",\"detail\":\"aborted: $escaped_reason\"}" + + printf 'Abort recorded in: %s\n' "$run_log" + printf 'Reason: %s\n' "$reason" +} + +cmd_timeline() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate timeline \n' >&2 + return 1 + fi + task_id=$1 + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + + work_dir="$plangate_working_dir/$task_id" + run_log="$work_dir/run.ndjson" + + if [ ! -f "$run_log" ]; then + printf 'No run.ndjson found for %s\n' "$task_id" + printf 'Events are recorded automatically during exec.\n' + return 0 + fi + + printf 'Timeline: %s\n\n' "$task_id" + printf '%-25s %-6s %-30s %s\n' "Timestamp" "Phase" "Event" "Detail" + printf '%s\n' "$(printf '%.0s-' $(seq 1 80))" + + while IFS= read -r line; do + if command -v python3 >/dev/null 2>&1; then + # Use python3 for correct JSON decoding (handles escaped characters in detail) + result=$(printf '%s' "$line" | python3 -c " +import json, sys +try: + d = json.loads(sys.stdin.read().strip()) + sep = '\x01' + fields = [ + d.get('ts', '-'), + d.get('phase', '-'), + d.get('event', '-'), + d.get('detail', '').replace('\n', ' ').replace('\x01', ' '), + ] + print(sep.join(fields)) +except Exception: + print('-\x01-\x01-\x01') +" 2>/dev/null || printf '%s\001%s\001%s\001' "-" "-" "-") + ts=${result%%"$(printf '\001')"*} + _rest=${result#*"$(printf '\001')"} + phase=${_rest%%"$(printf '\001')"*} + _rest=${_rest#*"$(printf '\001')"} + event=${_rest%%"$(printf '\001')"*} + detail=${_rest#*"$(printf '\001')"} + else + # Fallback: sed-based extraction (does not handle escaped quotes in detail) + ts=$(printf '%s' "$line" | sed 's/.*"ts"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "-") + phase=$(printf '%s' "$line" | sed 's/.*"phase"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "-") + event=$(printf '%s' "$line" | sed 's/.*"event"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "-") + detail=$(printf '%s' "$line" | sed 's/.*"detail"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' 2>/dev/null || echo "") + fi + printf '%-25s %-6s %-30s %s\n' "$ts" "$phase" "$event" "$detail" + done < "$run_log" +} + +cmd_resume() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate resume \n' >&2 + return 1 + fi + task_id=$1 + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + + work_dir="$plangate_working_dir/$task_id" + + printf '=== Resume: %s ===\n\n' "$task_id" + + if [ -f "$work_dir/current-state.md" ]; then + cat "$work_dir/current-state.md" + else + printf 'No current-state.md found. Run plangate status %s for phase info.\n' "$task_id" + fi + + printf '\n=== Quick Status ===\n' + cmd_status "$task_id" 2>/dev/null | grep -E 'Current:|Next:' || true +} + +cmd_maintenance() { + # TASK-0106: bin/plangate maintenance start/stop + # Human-owned in-session skip CLI. 多層 best-effort 防御 + 全試行監査ログ。 + # AI 自己付与は L1 isatty / L2 env barrier / L3 parent process heuristic / + # L4 対話 nonce で抑止し、全 start 試行を hook-events.log に記録。 + # 完全な構造保証は別 PBI 分割(R-012)。 + REPO_ROOT="$plangate_root" + _hook_events="$REPO_ROOT/docs/working/_audit/hook-events.log" + _maint_dir="$REPO_ROOT/docs/working/_maintenance" + _maint="$_maint_dir/maintenance.json" + mkdir -p "$_maint_dir" "$REPO_ROOT/docs/working/_audit" + + _ts_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } + _audit() { + # $1=event $2=verdict $3=detail (JSON-safe) + _ev="$1"; _vd="$2"; _dt="$3" + printf '{"ts":"%s","event":"%s","verdict":"%s","ppid":%s,"isatty_stdin":%s,"detail":%s}\n' \ + "$(_ts_iso)" "$_ev" "$_vd" "${PPID:-0}" \ + "$([ -t 0 ] && printf true || printf false)" \ + "$(printf '%s' "$_dt" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \ + >> "$_hook_events" 2>/dev/null || true + } + + _sub="${1:-help}" + case "$_sub" in + start) + shift + _reason=""; _paths=""; _minutes="5"; _force="false" + while [ $# -gt 0 ]; do + case "$1" in + --reason) _reason="$2"; shift 2 ;; + --paths) _paths="$2"; shift 2 ;; + --minutes) _minutes="$2"; shift 2 ;; + --force) _force="true"; shift ;; + *) printf 'error: unknown arg: %s\n' "$1" >&2; return 2 ;; + esac + done + + # --- L1: isatty (stdin が対話 TTY か) --- + if ! [ -t 0 ]; then + _audit "maintenance_start_attempt" "reject_L1" "non-interactive stdin" + printf 'error: L1 interactive TTY required (stdin not a tty)\n' >&2 + return 1 + fi + + # --- L2: env barrier --- + for _evname in CI CLAUDE_AGENT CURSOR_AGENT PLANGATE_BYPASS_HOOK; do + _evval=$(eval "printf '%s' \"\${$_evname:-}\"") + if [ -n "$_evval" ]; then + _audit "maintenance_start_attempt" "reject_L2" "env $_evname=$_evval detected" + printf 'error: L2 agent env detected (%s set)\n' "$_evname" >&2 + return 1 + fi + done + + # --- L3: parent process heuristic --- + _pcomm=$(ps -p $PPID -o comm= 2>/dev/null | tr -d ' ') + # PLANGATE_FAKE_PPID_COMM はテスト専用注入。本番経路での L3 上書きを防ぐため + # PLANGATE_TEST_MODE=1 のときのみ honor する(TASK-0139 AC-03)。 + if [ "${PLANGATE_TEST_MODE:-0}" = "1" ] && [ -n "${PLANGATE_FAKE_PPID_COMM:-}" ]; then _pcomm="$PLANGATE_FAKE_PPID_COMM"; fi + if printf '%s' "$_pcomm" | grep -iqE 'claude|codex|cursor'; then + _audit "maintenance_start_attempt" "reject_L3" "parent process comm=$_pcomm" + printf 'error: L3 AI agent lineage detected (ppid comm: %s)\n' "$_pcomm" >&2 + return 1 + fi + + # --- L4: 対話 nonce --- + _nonce=$(python3 -c 'import secrets; print(secrets.token_hex(4))') + printf 'L4 nonce challenge: please type the following 8-hex string and press Enter to confirm human operation:\n' + printf ' %s\n' "$_nonce" + printf 'PLANGATE_MAINT_ACK> ' + read -r _ack || _ack="" + if [ "$_ack" != "$_nonce" ]; then + _audit "maintenance_start_attempt" "reject_L4" "nonce mismatch (got: $_ack)" + printf 'error: L4 nonce mismatch (got: %s, expected: %s)\n' "$_ack" "$_nonce" >&2 + return 1 + fi + + # --- 引数バリデーション --- + if [ -z "$_reason" ] || [ -z "$(printf '%s' "$_reason" | tr -d '[:space:]')" ]; then + _audit "maintenance_start_attempt" "reject_arg" "empty --reason" + printf 'error: --reason is required (non-blank)\n' >&2 + return 2 + fi + case "$_minutes" in + ''|*[!0-9]*) printf 'error: --minutes must be a positive integer\n' >&2; return 2 ;; + esac + if [ "$_minutes" -le 0 ] || [ "$_minutes" -gt 30 ]; then + _audit "maintenance_start_attempt" "reject_arg" "minutes out of range (1..30): $_minutes" + printf 'error: --minutes must be 1..30 (hard cap 30)\n' >&2 + return 2 + fi + + # --- 既存有効ファイルチェック (AC-9 / R-005/R-010) --- + if [ -f "$_maint" ] && [ "$_force" = "false" ]; then + _audit "maintenance_start_attempt" "reject_existing" "already active maintenance.json present (use --force to overwrite)" + printf 'error: already active maintenance window (use --force to overwrite)\n' >&2 + return 1 + fi + + # --- 生成 --- + _now=$(date -u +%s) + _until=$((_now + _minutes * 60)) + _allowed_json="null" + if [ -n "$_paths" ]; then + _allowed_json=$(printf '%s' "$_paths" | python3 -c 'import json,sys; print(json.dumps([p.strip() for p in sys.stdin.read().split(",") if p.strip()]))') + fi + _user="${USER:-unknown}" + python3 - "$_maint" "$_user" "$_reason" "$_now" "$_until" "$_allowed_json" <<'PYW' +import json, sys, os +target, user, reason, granted_at, until_, allowed_json = sys.argv[1:7] +d = { + "scope": "in-session edit", + "until": int(until_), + "granted_at": int(granted_at), + "reason": reason, + "approved_by": user, + "one_shot": True, +} +allowed = json.loads(allowed_json) +if allowed is not None: + d["allowed_paths"] = allowed +tmp = target + ".tmp" +with open(tmp, "w") as f: + json.dump(d, f, ensure_ascii=False, indent=2) +os.replace(tmp, target) +PYW + _audit "maintenance_start_attempt" "ok" "reason=$_reason paths=$_paths minutes=$_minutes force=$_force" + printf 'maintenance window opened: until=%s scope=in-session edit allowed_paths=%s one_shot=true\n' "$_until" "${_paths:-(any non-Override)}" + return 0 + ;; + stop) + if [ -f "$_maint" ]; then + rm -f "$_maint" + _audit "maintenance_stop" "ok" "removed maintenance.json" + printf 'maintenance window closed (maintenance.json removed)\n' + else + _audit "maintenance_stop" "noop" "no active maintenance.json" + printf 'no active maintenance window\n' + fi + return 0 + ;; + help|--help|-h|"") + printf 'Usage: plangate maintenance [args]\n\n' + printf 'Subcommands:\n' + printf ' start --reason "" [--paths ] [--minutes 1..30] [--force]\n' + printf ' Human-only (TTY+env+ppid+nonce 4-layer best-effort defense)\n' + printf ' stop Remove active maintenance.json\n' + printf ' help Show this help\n' + return 0 + ;; + *) + printf 'error: unknown maintenance subcommand: %s\n' "$_sub" >&2 + return 2 + ;; + esac +} + +cmd_plan_check() { + # Lightweight Plan Quality Check(手動実行・heavy Gate 非依存 / #213) + # 正本: docs/ai/plan-quality-checks.md + # Usage: + # plangate plan-check --init 雛形 plan-quality-check.json 生成 + # plangate plan-check --validate schema 検証 + if [ $# -lt 1 ]; then + printf 'Usage: plangate plan-check [--init|--validate]\n' >&2 + return 1 + fi + task_id=$1 + shift + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + pqc_file="$plangate_working_dir/$task_id/plan-quality-check.json" + action=${1:---init} + case "$action" in + --init) + if [ -f "$pqc_file" ]; then + printf '[plan-check] exists (not overwritten): %s\n' "$pqc_file" + return 0 + fi + cat > "$pqc_file" <<'PQC' +{ + "check_type": "combined", + "score": 0, + "decision": "insufficient", + "summary": "TODO: 計画品質サマリを記述(plan_check/risk_check/done_check)", + "score_breakdown": { + "goal_clarity": 0, + "scope_defined": 0, + "success_metric": 0, + "risks_identified": 0, + "done_criteria_defined": 0 + }, + "missing_items": [], + "risks": [], + "assumptions": [], + "done_criteria": [], + "next_actions": [] +} +PQC + printf '[plan-check] template created: %s\n' "$pqc_file" + printf ' Fill in via plan-quality-check skill, then: plangate plan-check %s --validate\n' "$task_id" + return 0 + ;; + --validate) + if [ ! -f "$pqc_file" ]; then + printf '[plan-check] not found: %s (run --init first)\n' "$pqc_file" >&2 + return 2 + fi + schema="$plangate_root/schemas/plan-quality-check.schema.json" + vs="$plangate_root/scripts/validate-schemas.py" + if [ -f "$vs" ]; then + python3 "$vs" "$pqc_file" + return $? + fi + python3 - "$pqc_file" "$schema" <<'PYV' +import json,sys +doc=json.load(open(sys.argv[1])); sch=json.load(open(sys.argv[2])) +try: + import jsonschema + jsonschema.validate(doc,sch) + print("[plan-check] VALID:", sys.argv[1]) +except ImportError: + req=sch.get("required",[]) + miss=[k for k in req if k not in doc] + print("[plan-check] %s (jsonschema absent; required check)" % + ("VALID" if not miss else "INVALID missing=%s"%miss)) + sys.exit(0 if not miss else 1) +except Exception as e: + print("[plan-check] INVALID:", e); sys.exit(1) +PYV + return $? + ;; + *) + printf 'Usage: plangate plan-check [--init|--validate]\n' >&2 + return 1 + ;; + esac +} + +cmd_report() { + # Reporting & Retrospective v1 — 期間集計 Markdown(advisory / #200) + # 正本: docs/ai/reporting.md + # 最小チェックのみ(--from/--to 必須検証は scripts/reporting.py argparse に委譲) + if [ $# -lt 1 ]; then + printf 'Usage: plangate report --from --to [--no-write]\n' >&2 + return 1 + fi + py="$plangate_root/scripts/reporting.py" + if [ ! -f "$py" ]; then + printf 'error: scripts/reporting.py not found\n' >&2 + return 2 + fi + python3 "$py" "$@" + return $? +} + +cmd_context() { + # Dynamic Context Engine v1 — phase/mode/profile 別 context 解決 + # opt-in・advisory(C-3/C-4 緩和なし / #199)。正本: docs/ai/context-engine.md + if [ $# -lt 1 ]; then + printf 'Usage: plangate context --phase

[--mode ] [--profile ] [--no-write]\n' >&2 + return 1 + fi + py="$plangate_root/scripts/context-engine.py" + if [ ! -f "$py" ]; then + printf 'error: scripts/context-engine.py not found\n' >&2 + return 2 + fi + python3 "$py" "$@" + return $? +} + +cmd_keep_rate() { + # Keep Rate v1 — AI 成果物残存率(advisory・release blocker 不可 / #198) + # 正本: docs/ai/keep-rate.md + if [ $# -lt 1 ]; then + printf 'Usage: plangate keep-rate [--no-write]\n' >&2 + return 1 + fi + py="$plangate_root/scripts/keep-rate.py" + if [ ! -f "$py" ]; then + printf 'error: scripts/keep-rate.py not found\n' >&2 + return 2 + fi + python3 "$py" "$@" + return $? +} + +cmd_review() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate review [--phase c2|v3] [--file ]\n' >&2 + return 1 + fi + task_id=$1; shift + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + + phase="c2" + _review_progress=0 + while [ $# -gt 0 ]; do + case "$1" in + --phase) + shift + phase="${1:-c2}" + ;; + --file) + shift + ;; # --file is accepted but unused in this version; kept for future use + --progress) + _review_progress=1 + ;; + esac + shift + done + + work_dir="$plangate_working_dir/$task_id" + output_file="$work_dir/review-external.md" + + if [ ! -f "$work_dir/plan.md" ]; then + printf 'error: plan.md not found — run /ai-dev-workflow plan first\n' >&2 + return 1 + fi + + # -- .plangate-reviewers.yaml 存在チェック --------------------------------- + reviewers_yaml="$plangate_root/.plangate-reviewers.yaml" + if [ -f "$reviewers_yaml" ] && command -v python3 >/dev/null 2>&1; then + if python3 -c 'import yaml' >/dev/null 2>&1; then + _rv_result=0 + _review_parallel "$task_id" "$phase" "$work_dir" "$output_file" "$reviewers_yaml" || _rv_result=$? + if [ "$_rv_result" -eq 2 ]; then + : # phase not configured; fall through to legacy + else + return "$_rv_result" + fi + else + printf 'warn: PyYAML not installed; falling back to legacy reviewer\n' >&2 + fi + elif [ -f "$reviewers_yaml" ] && ! command -v python3 >/dev/null 2>&1; then + printf 'warn: python3 not found; falling back to legacy reviewer\n' >&2 + fi + + # -- レガシーフォールバック(後方互換) ------------------------------------- + reviewer="${PLANGATE_EXTERNAL_REVIEWER:-codex}" + printf 'External review: %s (phase=%s, reviewer=%s)\n' "$task_id" "$phase" "$reviewer" + prompt="Review the following PlanGate plan and identify issues:\n$(cat "$work_dir/plan.md")" + + case "$reviewer" in + gemini) + if ! command -v gemini >/dev/null 2>&1; then + printf 'error: gemini CLI not found. Install: https://github.com/google-gemini/gemini-cli\n' >&2 + return 1 + fi + result=$(printf '%s' "$prompt" | gemini --yolo 2>&1) || true + ;; + codex) + # CX-1 (TASK-0109): Codex CLI review wiring + # - R-005 (Gemini CRITICAL): --sandbox read-only でファイル改変防止 + # - R-006 (Gemini): timeout 600s で wrap (codex CLI に --timeout なし) + # - R-007 (Gemini): --output-last-message でクリーン出力 + # - R-010 (Gemini): CLI 未 install error handling + if ! command -v codex >/dev/null 2>&1; then + printf 'error: codex CLI not found. Install: https://github.com/openai/codex\n' >&2 + return 1 + fi + # R-006 (Gemini): macOS は標準で timeout なし、gtimeout (coreutils) or fallback + if command -v timeout >/dev/null 2>&1; then + codex_timeout_cmd="timeout 600" + elif command -v gtimeout >/dev/null 2>&1; then + codex_timeout_cmd="gtimeout 600" + else + printf 'warn: timeout/gtimeout not found, running codex exec without timeout\n' >&2 + codex_timeout_cmd="" + fi + codex_tmpfile=$(mktemp) + # shellcheck disable=SC2064 # trap で variable 展開を意図 + trap "rm -f \"$codex_tmpfile\"" EXIT + # shellcheck disable=SC2086 # $codex_timeout_cmd は word splitting 必要 + if ! $codex_timeout_cmd codex exec --skip-git-repo-check --sandbox read-only \ + --output-last-message "$codex_tmpfile" "$prompt" >&2; then + printf 'error: codex exec failed or timeout (600s)\n' >&2 + rm -f "$codex_tmpfile" + trap - EXIT + return 1 + fi + result=$(cat "$codex_tmpfile") + rm -f "$codex_tmpfile" + trap - EXIT # Gemini: 関数外 trap 漏れ防止 + ;; + *) + printf 'error: Unknown reviewer: %s (supported: codex, gemini)\n' "$reviewer" >&2 + return 1 + ;; + esac + + { + printf '# External Review -- %s\n\n' "$task_id" + printf '> Phase: %s\n' "$phase" + printf '> Reviewer: %s\n' "$reviewer" + printf '> Generated: %s\n\n' "$(plangate_now)" + printf '%s\n' "$result" + } > "$output_file" + + printf 'Review written to: %s\n' "$output_file" +} + +# _review_parallel: .plangate-reviewers.yaml の配列形式に対応した並列実行 +# 引数: task_id phase work_dir output_file reviewers_yaml +# 戻り値: 0=success, 1=error, 2=phase not configured (caller should fall through) +_review_parallel() { + _rp_task_id=$1 + _rp_phase=$2 + _rp_work_dir=$3 + _rp_output_file=$4 + _rp_reviewers_yaml=$5 + _rp_progress="${_review_progress:-0}" + + # タスクの mode を current-state.md または plan.md から取得 + _rp_task_mode="" + if [ -f "$_rp_work_dir/current-state.md" ]; then + _rp_task_mode=$(grep -m1 '^\*\*[Mm]ode\*\*:' "$_rp_work_dir/current-state.md" 2>/dev/null | \ + sed 's/.*:[[:space:]]*//' | tr -d '`' | tr -d ' ' || true) + fi + if [ -z "$_rp_task_mode" ] && [ -f "$_rp_work_dir/plan.md" ]; then + _rp_task_mode=$(grep -m1 '^\*\*モード\*\*:\|^\*\*[Mm]ode\*\*:' "$_rp_work_dir/plan.md" 2>/dev/null | \ + sed 's/.*:[[:space:]]*//' | tr -d '`' | tr -d ' ' | sed 's/(.*//' || true) + fi + if [ -z "$_rp_task_mode" ]; then + printf 'warn: task mode not found; mode_threshold filter disabled\n' >&2 + fi + + _rp_tmpdir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf \"$_rp_tmpdir\"" EXIT + + python3 - "$_rp_reviewers_yaml" "$_rp_phase" "$_rp_task_mode" "$_rp_tmpdir" << 'PYEOF' +import sys, os, yaml, shlex + +yaml_file = sys.argv[1] +phase = sys.argv[2] +task_mode = sys.argv[3] +tmpdir = sys.argv[4] + +MODE_ORDER = ["ultra-light", "light", "standard", "high-risk", "critical"] + +def mode_rank(m): + try: + return MODE_ORDER.index(m) + except ValueError: + return -1 + +with open(yaml_file) as f: + config = yaml.safe_load(f) + +reviewers_config = config.get("reviewers", {}).get(phase) +if reviewers_config is None: + with open(os.path.join(tmpdir, "no_phase"), "w") as f: + f.write("no_phase\n") + sys.exit(0) + +if isinstance(reviewers_config, dict): + reviewers_list = [reviewers_config] + is_array = False +else: + reviewers_list = reviewers_config + is_array = True + +with open(os.path.join(tmpdir, "is_array"), "w") as f: + f.write("1" if is_array else "0") + +active_reviewers = [] +for spec in reviewers_list: + threshold = spec.get("mode_threshold") + if threshold and task_mode: + task_rank = mode_rank(task_mode) + threshold_rank = mode_rank(threshold) + if task_rank < threshold_rank: + provider = spec.get("provider", "unknown") + sys.stderr.write("[mode_threshold] skip: {} (threshold={}, task_mode={})\n".format( + provider, threshold, task_mode)) + continue + active_reviewers.append(spec) + +active_reviewers.sort(key=lambda s: s.get("provider", "")) + +for idx, spec in enumerate(active_reviewers): + provider = spec.get("provider", "reviewer{}".format(idx)) + cmd = spec.get("command", "") + if isinstance(cmd, list): + cmd = " ".join(cmd) + lane = spec.get("lane", "") + spec_file = os.path.join(tmpdir, "spec_{:03d}".format(idx)) + with open(spec_file, "w") as f: + f.write("provider={}\n".format(provider)) + f.write("command={}\n".format(shlex.quote(cmd))) + f.write("lane={}\n".format(lane)) + +with open(os.path.join(tmpdir, "count"), "w") as f: + f.write(str(len(active_reviewers))) +PYEOF + + if [ $? -ne 0 ]; then + printf 'error: failed to parse %s\n' "$_rp_reviewers_yaml" >&2 + rm -rf "$_rp_tmpdir" + trap - EXIT + return 1 + fi + + if [ -f "$_rp_tmpdir/no_phase" ]; then + rm -rf "$_rp_tmpdir" + trap - EXIT + return 2 + fi + + _rp_count=$(cat "$_rp_tmpdir/count" 2>/dev/null || echo 0) + _rp_is_array=$(cat "$_rp_tmpdir/is_array" 2>/dev/null || echo 0) + + if [ "$_rp_count" -eq 0 ]; then + printf 'warn: no reviewers active for phase=%s (all filtered by mode_threshold)\n' "$_rp_phase" >&2 + { + printf '# External Review -- %s\n\n' "$_rp_task_id" + printf '> Phase: %s\n' "$_rp_phase" + printf '> Generated: %s\n\n' "$(plangate_now)" + printf 'No reviewers active (all filtered by mode_threshold=%s).\n' "$_rp_task_mode" + } > "$_rp_output_file" + rm -rf "$_rp_tmpdir" + trap - EXIT + printf 'Review written to: %s\n' "$_rp_output_file" + return 0 + fi + + # 単体 spec(is_array=0)の場合 + if [ "$_rp_is_array" = "0" ]; then + _spec_file="$_rp_tmpdir/spec_000" + provider=""; command=""; lane="" + # shellcheck disable=SC1090 + . "$_spec_file" + printf 'External review: %s (phase=%s, provider=%s)\n' "$_rp_task_id" "$_rp_phase" "$provider" + _rp_out=$(eval "$command" 2>&1) || _rp_out="[error: command failed]" + { + printf '# External Review -- %s\n\n' "$_rp_task_id" + printf '> Phase: %s\n' "$_rp_phase" + printf '> Provider: %s\n' "$provider" + printf '> Generated: %s\n\n' "$(plangate_now)" + printf '%s\n' "$_rp_out" + } > "$_rp_output_file" + rm -rf "$_rp_tmpdir" + trap - EXIT + printf 'Review written to: %s\n' "$_rp_output_file" + return 0 + fi + + # 配列形式: 並列実行 + printf 'External review (parallel): %s (phase=%s, reviewers=%s)\n' \ + "$_rp_task_id" "$_rp_phase" "$_rp_count" + + _rp_idx=0 + while [ "$_rp_idx" -lt "$_rp_count" ]; do + _spec_file="$_rp_tmpdir/spec_$(printf '%03d' "$_rp_idx")" + if [ -f "$_spec_file" ]; then + provider=""; command=""; lane="" + # shellcheck disable=SC1090 + . "$_spec_file" + _rp_out_file="$_rp_tmpdir/out_$(printf '%03d' "$_rp_idx")" + _rp_status_file="$_rp_tmpdir/status_$(printf '%03d' "$_rp_idx")" + printf 'Starting reviewer: %s (lane=%s)\n' "$provider" "$lane" + # shellcheck disable=SC2086 + (_rp_ec=0; eval "$command" > "$_rp_out_file" 2>&1 || _rp_ec=$?; echo "$_rp_ec" > "$_rp_status_file"; : > "$_rp_tmpdir/done_$(printf '%03d' "$_rp_idx")") & + fi + _rp_idx=$((_rp_idx + 1)) + done + + if [ "$_rp_progress" = "1" ]; then + _rp_seen_count=0 + _rp_printed_count=0 + while [ "$_rp_seen_count" -lt "$_rp_count" ]; do + _rp_seen_count=0 + _rp_pi=0 + while [ "$_rp_pi" -lt "$_rp_count" ]; do + if [ -f "$_rp_tmpdir/done_$(printf '%03d' "$_rp_pi")" ]; then + _rp_seen_count=$((_rp_seen_count + 1)) + if [ ! -f "$_rp_tmpdir/seen_$(printf '%03d' "$_rp_pi")" ]; then + : > "$_rp_tmpdir/seen_$(printf '%03d' "$_rp_pi")" + _rp_printed_count=$((_rp_printed_count + 1)) + _rp_pstat=$(cat "$_rp_tmpdir/status_$(printf '%03d' "$_rp_pi")" 2>/dev/null || echo 1) + _rp_pprov="" + [ -f "$_rp_tmpdir/spec_$(printf '%03d' "$_rp_pi")" ] && _rp_pprov=$(sed -n 's/^provider=//p' "$_rp_tmpdir/spec_$(printf '%03d' "$_rp_pi")") + if [ "$_rp_pstat" = "0" ]; then _rp_pres="ok"; else _rp_pres="failed"; fi + printf '[done %s/%s] %s %s\n' "$_rp_printed_count" "$_rp_count" "$_rp_pprov" "$_rp_pres" + fi + fi + _rp_pi=$((_rp_pi + 1)) + done + [ "$_rp_seen_count" -lt "$_rp_count" ] && sleep 1 + done + fi + + wait + + # 結果マージ: provider アルファベット順 + R-NNN 連番 + _rp_r_num=1 + { + printf '# External Review -- %s\n\n' "$_rp_task_id" + printf '> Phase: %s\n' "$_rp_phase" + printf '> Mode: %s\n' "$_rp_task_mode" + printf '> Generated: %s\n' "$(plangate_now)" + printf '> Reviewers: %s (parallel)\n\n' "$_rp_count" + } > "$_rp_output_file" + + _rp_idx=0 + while [ "$_rp_idx" -lt "$_rp_count" ]; do + _spec_file="$_rp_tmpdir/spec_$(printf '%03d' "$_rp_idx")" + _rp_out_file="$_rp_tmpdir/out_$(printf '%03d' "$_rp_idx")" + if [ -f "$_spec_file" ]; then + provider=""; command=""; lane="" + # shellcheck disable=SC1090 + . "$_spec_file" + _rp_status=$(cat "$_rp_tmpdir/status_$(printf '%03d' "$_rp_idx")" 2>/dev/null || echo 1) + if [ "$_rp_status" -eq 0 ] && [ -f "$_rp_out_file" ]; then + _rp_r_id=$(printf 'R-%03d' "$_rp_r_num") + { + printf '## %s -- %s\n\n' "$_rp_r_id" "$provider" + if [ -n "$lane" ]; then + printf '> Lane: %s\n\n' "$lane" + fi + cat "$_rp_out_file" + printf '\n' + } >> "$_rp_output_file" + _rp_r_num=$((_rp_r_num + 1)) + else + printf 'warn: reviewer %s failed (exit=%s) or output missing; skipping\n' "$provider" "$_rp_status" >&2 + fi + fi + _rp_idx=$((_rp_idx + 1)) + done + + # decision-log に並列実行を記録 + _rp_log="$_rp_work_dir/decision-log.jsonl" + if [ -f "$_rp_log" ]; then + plangate_append_ndjson "$_rp_log" \ + "{\"ts\":\"$(plangate_now)\",\"event\":\"parallel_review\",\"phase\":\"$_rp_phase\",\"reviewers\":$_rp_count,\"task_mode\":\"$_rp_task_mode\"}" + fi + + rm -rf "$_rp_tmpdir" + trap - EXIT + printf 'Review written to: %s\n' "$_rp_output_file" + return 0 +} + + +cmd_exec() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate exec [--mode ]\n' >&2 + return 1 + fi + task_id=$1; shift + plangate_validate_task_id "$task_id" + plangate_require_task_dir "$task_id" + + mode="" + while [ $# -gt 0 ]; do + case "$1" in + --mode) + shift + mode="${1:-}" + ;; + esac + shift + done + + work_dir="$plangate_working_dir/$task_id" + agent="${PLANGATE_IMPL_AGENT:-codex}" + run_log="$work_dir/run.ndjson" + + # Gate check: c3.json must exist and be APPROVED + if [ ! -f "$work_dir/approvals/c3.json" ]; then + printf 'error: C-3 gate not cleared — approvals/c3.json not found\n' >&2 + printf 'Run: plangate validate %s\n' "$task_id" >&2 + return 1 + fi + + # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 + # legacy(approval_kind 無し)は従来の grep + plan_hash 経路で処理する。 + # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 + _c3_rc=0 + _plangate_c3_dispatch "$work_dir" || _c3_rc=$? + if [ "$_c3_rc" = "1" ]; then + printf 'error: C-3 gate not approved (c3-prime verification failed; see above)\n' >&2 + return 1 + elif [ "$_c3_rc" = "10" ]; then + c3_status=$(grep '"c3_status"' "$work_dir/approvals/c3.json" 2>/dev/null \ + | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") + if [ "$c3_status" != "APPROVED" ]; then + printf 'error: C-3 gate not approved (status=%s)\n' "${c3_status:-missing}" >&2 + return 1 + fi + + # Gate check: plan_hash integrity (EH-3 integration at exec entry) + # plan.md must not have been modified after C-3 approval. + plan_file="$work_dir/plan.md" + c3_file="$work_dir/approvals/c3.json" + if [ -f "$plan_file" ]; then + recorded_hash=$(python3 -c "import json,sys +try: + d=json.load(open(sys.argv[1])) +except Exception: + print(''); sys.exit(0) +if not isinstance(d,dict): + print(''); sys.exit(0) +v=d.get('plan_hash','') +print(v[7:] if isinstance(v,str) and v.startswith('sha256:') else '')" "$c3_file" 2>/dev/null || echo "") + if [ -n "$recorded_hash" ]; then + if command -v sha256sum >/dev/null 2>&1; then + current_hash=$(sha256sum "$plan_file" | awk '{print $1}') + else + current_hash=$(shasum -a 256 "$plan_file" | awk '{print $1}') + fi + if [ "$recorded_hash" != "$current_hash" ]; then + printf 'error: plan_hash mismatch -- plan.md modified after C-3 approval\n' >&2 + printf ' Recorded: sha256:%s\n' "$recorded_hash" >&2 + printf ' Current : sha256:%s\n' "$current_hash" >&2 + printf ' Action : Re-approval required (update c3.json plan_hash) or revert plan.md.\n' >&2 + return 1 + fi + fi + fi + fi + # _c3_rc == 0: c3-prime 受理(Plan Package 束縛整合済み) + + # Record session start + ts=$(plangate_now) + plangate_append_ndjson "$run_log" \ + "{\"ts\":\"$ts\",\"task_id\":\"$task_id\",\"phase\":\"D\",\"event\":\"session_started\",\"detail\":\"agent=$agent\"}" + + printf 'Exec: %s (agent=%s)\n' "$task_id" "$agent" + + case "$agent" in + opencode) + if ! command -v opencode >/dev/null 2>&1; then + printf 'error: opencode not found. Install: https://opencode.ai\n' >&2 + return 1 + fi + context="Implement the plan in docs/working/$task_id/plan.md. Follow todo.md. Run tests after each step." + opencode run "$context" + ;; + codex) + printf 'info: codex exec — use /ai-dev-workflow %s exec in Claude Code\n' "$task_id" + printf 'Context: %s/plan.md\n' "$work_dir" + ;; + cursor) + printf 'info: cursor exec — open Cursor Agent with:\n' + printf ' Plan: %s/plan.md\n' "$work_dir" + printf ' Todo: %s/todo.md\n' "$work_dir" + printf ' Guide: docs/cursor/quickstart.md\n' + printf 'Set PLANGATE_HOOK_TASK=%s and PLANGATE_HOOK_STRICT=1 for hook enforcement.\n' "$task_id" + ;; + *) + printf 'error: Unknown impl agent: %s (supported: codex, opencode, cursor)\n' "$agent" >&2 + return 1 + ;; + esac +} + + +cmd_brainstorm() { + task_id="${1:-}" + if [ -z "$task_id" ]; then + printf 'Usage: plangate brainstorm \n' >&2 + return 2 + fi + shift + exec "$plangate_root/scripts/ai-dev-workflow" "$task_id" brainstorm "$@" +} + +cmd_plan() { + task_id="${1:-}" + if [ -z "$task_id" ]; then + printf 'Usage: plangate plan \n' >&2 + return 2 + fi + shift + exec "$plangate_root/scripts/ai-dev-workflow" "$task_id" plan "$@" +} + +cmd_gate() { + task_id="${1:-}" + if [ -z "$task_id" ]; then + printf 'Usage: plangate gate \n' >&2 + return 2 + fi + shift + exec "$plangate_root/scripts/ai-dev-workflow" "$task_id" gate "$@" +} + +cmd_verify() { + task_id="${1:-}" + if [ -z "$task_id" ]; then + printf 'Usage: plangate verify \n' >&2 + return 2 + fi + shift + mode_flag="" + profile_flag="" + for arg in "$@"; do + case "$arg" in + --mode=*) mode_flag="${arg#--mode=}" ;; + --profile=*) profile_flag="${arg#--profile=}" ;; + esac + done + # validation_bias export (TASK-0147 / #527): --profile 指定時に解決し export。 + # env で既に明示注入済みなら尊重(上書きしない)。normal/lenient は非発火。 + if [ -z "${PLANGATE_VALIDATION_BIAS:-}" ] && [ -n "$profile_flag" ]; then + PLANGATE_VALIDATION_BIAS=$(python3 "$plangate_root/scripts/_resolve_validation_bias.py" "$profile_flag" "$plangate_root/docs/ai/model-profiles.yaml") + export PLANGATE_VALIDATION_BIAS + fi + printf '[verify] Checking EH-4: test-cases.md present...\n' + if ! PLANGATE_HOOK_STRICT=1 sh "$plangate_root/scripts/hooks/check-test-cases.sh" "$task_id"; then + printf '[verify] EH-4 BLOCK -- create docs/working/%s/test-cases.md first\n' "$task_id" >&2 + return 1 + fi + printf '[verify] Running V-1 (validate)...\n' + if ! "$0" validate "$task_id"; then + # EHS-3 (TASK-0146 / #527): fix-loop カウンタ increment + strict 時 block + if [ "${PLANGATE_VALIDATION_BIAS:-normal}" = "strict" ]; then + # EHS-3 BLOCK + PLANGATE_HOOK_STRICT=1 sh "$plangate_root/scripts/hooks/check-fix-loop.sh" "$task_id" increment || return 1 + else + sh "$plangate_root/scripts/hooks/check-fix-loop.sh" "$task_id" increment 2>/dev/null || true + fi + printf '[verify] V-1 FAILED — stop\n' >&2 + return 1 + fi + printf '[verify] Checking EH-5: verification evidence present...\n' + PLANGATE_HOOK_STRICT=0 sh "$plangate_root/scripts/hooks/check-verification-evidence.sh" "$task_id" || true + printf '[verify] Running settings task-lock check...\n' + if ! "$0" doctor --check-settings; then + printf '[verify] settings task-lock FAILED — Human must run sh scripts/apply-claude-settings.sh\n' >&2 + return 1 + fi + case "$mode_flag" in + standard|high-risk|critical) + printf '[verify] Running V-3 (external review)...\n' + if "$0" review "$task_id" --phase v3; then + : + else + # EHS-1 (TASK-0145 / #527): validation_bias=strict は V-3 合格を必須化 + if [ "${PLANGATE_VALIDATION_BIAS:-normal}" = "strict" ]; then + printf '[verify] EHS-1 BLOCK -- validation_bias=strict requires a passing V-3 external review\n' >&2 + return 1 + fi + printf '[verify] V-3 returned non-zero\n' >&2 + fi + ;; + esac + printf '[verify] Running 8-aspect eval...\n' + "$0" eval "$task_id" || printf '[verify] eval returned non-zero\n' >&2 + printf '[verify] Collecting metrics...\n' + "$0" metrics "$task_id" --collect || printf '[verify] metrics returned non-zero\n' >&2 + printf '[verify] Complete. Next: plangate handoff %s\n' "$task_id" +} + +cmd_handoff() { + task_id="${1:-}" + if [ -z "$task_id" ]; then + printf 'Usage: plangate handoff \n' >&2 + return 2 + fi + # EHS-2 (TASK-0146 / #527): --verify で handoff.md の 6 要素を検査 + _handoff_verify=0 + _handoff_profile="" + for _harg in "$@"; do + case "$_harg" in + --verify) _handoff_verify=1 ;; + --profile=*) _handoff_profile="${_harg#--profile=}" ;; + esac + done + # validation_bias export (TASK-0147 / #527): --profile 指定時に解決し export。 + # env で既に明示注入済みなら尊重(上書きしない)。normal/lenient は非発火。 + if [ -z "${PLANGATE_VALIDATION_BIAS:-}" ] && [ -n "$_handoff_profile" ]; then + PLANGATE_VALIDATION_BIAS=$(python3 "$plangate_root/scripts/_resolve_validation_bias.py" "$_handoff_profile" "$plangate_root/docs/ai/model-profiles.yaml") + export PLANGATE_VALIDATION_BIAS + fi + if [ "$_handoff_verify" = "1" ]; then + if [ "${PLANGATE_VALIDATION_BIAS:-normal}" = "strict" ]; then + # EHS-2 BLOCK + PLANGATE_HOOK_STRICT=1 sh "$plangate_root/scripts/hooks/check-handoff-elements.sh" "$task_id" || return 1 + else + sh "$plangate_root/scripts/hooks/check-handoff-elements.sh" "$task_id" 2>/dev/null || true + fi + return 0 + fi + work_dir="$plangate_working_dir/$task_id" + template="$plangate_root/docs/working/templates/handoff.md" + target="$work_dir/handoff.md" + if [ ! -f "$template" ]; then + printf 'error: handoff template not found: %s\n' "$template" >&2 + return 1 + fi + if [ -f "$target" ]; then + printf 'handoff.md already exists: %s (skip)\n' "$target" + return 0 + fi + mkdir -p "$work_dir" + cp "$template" "$target" + printf 'Created: %s\n' "$target" + printf 'Fill the 6 required sections (要件適合 / 既知課題 / V2 / 妥協点 / 引き継ぎ / テスト結果).\n' + printf 'Verify settings task-lock: plangate doctor --check-settings\n' +} + +# ── presence gate (TASK-0128 / shared Human-presence L1-L4, side-effect free) ── +# maintenance の L1-L4 と同等の best-effort 防御。_maintenance 生成や +# maintenance 固有 audit を起こさない(R-005)。引数: $1=context label。 +# 監査は docs/working/_audit/hook-events.log に context 付きで記録。 +_plangate_presence_gate() { + _pg_ctx="${1:-approve}" + _pg_log="$plangate_root/docs/working/_audit/hook-events.log" + mkdir -p "$plangate_root/docs/working/_audit" + _pg_audit() { + printf '{"ts":"%s","event":"%s_presence_attempt","verdict":"%s","ppid":%s,"isatty_stdin":%s,"detail":%s}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$_pg_ctx" "$1" "${PPID:-0}" \ + "$([ -t 0 ] && printf true || printf false)" \ + "$(printf '%s' "$2" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \ + >> "$_pg_log" 2>/dev/null || true + } + # L1: isatty + if ! [ -t 0 ]; then + _pg_audit reject_L1 "non-interactive stdin" + printf 'error: L1 interactive TTY required (stdin not a tty)\n' >&2 + return 1 + fi + # L2: env barrier + for _evname in CI CLAUDE_AGENT CURSOR_AGENT PLANGATE_BYPASS_HOOK; do + _evval=$(eval "printf '%s' \"\${$_evname:-}\"") + if [ -n "$_evval" ]; then + _pg_audit reject_L2 "env $_evname set" + printf 'error: L2 agent env detected (%s set)\n' "$_evname" >&2 + return 1 + fi + done + # L3: parent process heuristic + _pcomm=$(ps -p $PPID -o comm= 2>/dev/null | tr -d ' ') + # PLANGATE_FAKE_PPID_COMM はテスト専用注入。本番経路での L3 上書きを防ぐため + # PLANGATE_TEST_MODE=1 のときのみ honor する(TASK-0139 AC-03)。 + if [ "${PLANGATE_TEST_MODE:-0}" = "1" ] && [ -n "${PLANGATE_FAKE_PPID_COMM:-}" ]; then _pcomm="$PLANGATE_FAKE_PPID_COMM"; fi + if printf '%s' "$_pcomm" | grep -iqE 'claude|codex|cursor'; then + _pg_audit reject_L3 "ppid comm=$_pcomm" + printf 'error: L3 AI agent lineage detected (ppid comm: %s)\n' "$_pcomm" >&2 + return 1 + fi + # L4: interactive nonce + _nonce=$(python3 -c 'import secrets; print(secrets.token_hex(4))') + printf 'L4 nonce challenge: type the 8-hex string to confirm human operation:\n %s\n' "$_nonce" + printf 'PLANGATE_APPROVE_ACK> ' + read -r _ack || _ack="" + if [ "$_ack" != "$_nonce" ]; then + _pg_audit reject_L4 "nonce mismatch" + printf 'error: L4 nonce mismatch\n' >&2 + return 1 + fi + _pg_audit accept "presence confirmed" + return 0 +} + +# ── approve (TASK-0128 / human one-action C-3 approval) ── +# 人間の承認「判断」だけで c3.json を schema 準拠で生成(JSON 手書き不要)。 +# AI 自己承認は _plangate_presence_gate(L1-L4) で物理的に封じる。 +cmd_approve() { + _ap_status="APPROVED"; _ap_reason=""; _ap_conditions=""; _ap_force="false"; _ap_task="" + while [ $# -gt 0 ]; do + case "$1" in + --reject) _ap_status="REJECTED"; shift ;; + --conditional) _ap_status="CONDITIONAL"; shift ;; + --reason) [ $# -lt 2 ] && { printf 'error: --reason requires an argument\n' >&2; return 2; }; _ap_reason="$2"; shift 2 ;; + --conditions) [ $# -lt 2 ] && { printf 'error: --conditions requires an argument\n' >&2; return 2; }; _ap_conditions="$2"; shift 2 ;; + --force) _ap_force="true"; shift ;; + -*) printf 'error: unknown arg: %s\n' "$1" >&2; return 2 ;; + *) _ap_task="$1"; shift ;; + esac + done + [ -n "$_ap_task" ] || { printf 'Usage: plangate approve [--reject --reason |--conditional --conditions ]\n' >&2; return 2; } + # 排他 + if [ "$_ap_status" = "REJECTED" ] && [ -n "$_ap_conditions" ]; then printf 'error: --conditions is for --conditional\n' >&2; return 2; fi + plangate_validate_task_id "$_ap_task" + _ap_dir="$plangate_working_dir/$_ap_task" + _ap_plan="$_ap_dir/plan.md" + [ -f "$_ap_plan" ] || { printf 'error: plan.md not found: %s\n' "$_ap_plan" >&2; return 1; } + + # 三値 schema 必須フィールド(R-004): REJECTED→reason / CONDITIONAL→conditions + if [ "$_ap_status" = "REJECTED" ] && [ -z "$_ap_reason" ]; then + if [ -t 0 ]; then printf 'rejection reason> '; read -r _ap_reason || _ap_reason=""; fi + [ -n "$_ap_reason" ] || { printf 'error: --reason required for --reject (schema)\n' >&2; return 2; } + fi + if [ "$_ap_status" = "CONDITIONAL" ] && [ -z "$_ap_conditions" ]; then + if [ -t 0 ]; then printf 'conditions> '; read -r _ap_conditions || _ap_conditions=""; fi + [ -n "$_ap_conditions" ] || { printf 'error: --conditions required for --conditional (schema)\n' >&2; return 2; } + fi + + # 既存 c3.json + _ap_c3="$_ap_dir/approvals/c3.json" + # TASK-0139 AC-04: abort if c3.json exists and --force not given + # 承認記録の不可逆性を保護する(既存承認の無断上書き禁止)。 + if [ -f "$_ap_c3" ] && [ "$_ap_force" = "false" ]; then + printf 'error: existing c3.json found for %s; use --force to overwrite\n' "$_ap_task" >&2 + return 2 + fi + + # Human-presence 検証(自己承認不可) + _plangate_presence_gate approve || { printf 'error: approval aborted (presence gate failed)\n' >&2; return 1; } + + # 自動算出 + _ap_hash="sha256:$(plangate_sha256 "$_ap_plan")" + _ap_by="$(git -C "$plangate_root" config user.email 2>/dev/null || true)" + [ -n "$_ap_by" ] || _ap_by="$(git -C "$plangate_root" config user.name 2>/dev/null || true)" + [ -n "$_ap_by" ] || _ap_by="${USER:-unknown}" + _ap_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + mkdir -p "$_ap_dir/approvals" + # schema 準拠 c3.json 生成(identity 限界は _ 注釈で明示 / R-006) + python3 - "$_ap_c3" "$_ap_task" "$_ap_status" "$_ap_by" "$_ap_at" "$_ap_hash" "$_ap_reason" "$_ap_conditions" <<'PYC' +import json, sys +c3, task, status, by, at, phash, reason, conditions = sys.argv[1:9] +d = { + "task_id": task, + "phase": "C-3", + "c3_status": status, + "approved_by": by, + "approved_at": at, + "plan_hash": phash, + "source": "cli", + "_approved_by_source": "git-config", + "_approver_identity_unverified": True, + "_note": "Generated by `plangate approve` (TASK-0128). Human presence verified via L1-L4; identity (approved_by) is git-config derived and NOT cryptographically verified.", +} +if status == "REJECTED": + d["rejection_reason"] = reason +if status == "CONDITIONAL": + d["conditions"] = conditions +with open(c3, "w") as f: + json.dump(d, f, ensure_ascii=False, indent=2) + f.write("\n") +PYC + printf 'c3.json written: %s (c3_status=%s)\n' "$_ap_c3" "$_ap_status" + + # schema 検証(R-008・既存 validate-schemas.py / jsonschema 任意) + _ap_vs="$plangate_root/scripts/validate-schemas.py" + if [ -f "$_ap_vs" ]; then + if python3 "$_ap_vs" "$_ap_c3" >/dev/null 2>&1; then + printf ' [PASS] schema valid (c3-approval.schema.json)\n' + else + _ap_vrc=$? + if [ "$_ap_vrc" = "2" ]; then + printf ' [WARN] schema validation skipped (jsonschema not installed)\n' + else + printf ' [FAIL] schema validation failed (rc=%s)\n' "$_ap_vrc" >&2 + return 1 + fi + fi + fi + + # 最終確認の分離(R-003): APPROVED のみ validate、他は status 表示のみ + if [ "$_ap_status" = "APPROVED" ]; then + printf ' Running validate (APPROVED only)...\n' + cmd_validate "$_ap_task" || { printf ' [FAIL] validate did not pass\n' >&2; return 1; } + else + printf ' [INFO] %s: validate skipped (only APPROVED unblocks exec). plan_hash recorded: %s\n' "$_ap_status" "$_ap_hash" + fi + return 0 +} + +# ── render (TASK-0127 / C-3 review aggregation to self-contained HTML) ── +# C-3 対象 7 種 MD を 1 枚の自己完結 HTML に集約(scripts/render_review.py 委譲)。 +# Python 標準ライブラリのみ・外部 CDN 依存なし。 +cmd_render() { + _rd_task=""; _rd_out=""; _rd_html=0 + while [ $# -gt 0 ]; do + case "$1" in + --html) _rd_html=1; shift ;; + --out) [ $# -lt 2 ] && { printf 'error: --out requires an argument\n' >&2; return 2; }; _rd_out="$2"; shift 2 ;; + -*) printf 'error: unknown arg: %s\n' "$1" >&2; return 2 ;; + *) _rd_task="$1"; shift ;; + esac + done + if [ -z "$_rd_task" ]; then + printf 'Usage: plangate render [--html] [--out ]\n' >&2 + return 2 + fi + plangate_validate_task_id "$_rd_task" + _rd_py="$plangate_root/scripts/render_review.py" + [ -f "$_rd_py" ] || { printf 'error: scripts/render_review.py not found\n' >&2; return 2; } + if [ -n "$_rd_out" ]; then + python3 "$_rd_py" --task "$_rd_task" --out "$_rd_out" + else + python3 "$_rd_py" --task "$_rd_task" + fi +} + +# ── dispatch ────────────────────────────────────────────────────────────────── + +if [ $# -eq 0 ]; then + cmd_help + exit 0 +fi + +command=$1 +shift + +case "$command" in + init) cmd_init "$@" ;; + brainstorm) cmd_brainstorm "$@" ;; + plan) cmd_plan "$@" ;; + gate) cmd_gate "$@" ;; + verify) cmd_verify "$@" ;; + handoff) cmd_handoff "$@" ;; + doctor) cmd_doctor "$@" ;; + status) cmd_status "$@" ;; + validate) cmd_validate "$@" ;; + validate-schemas) cmd_validate_schemas "$@" ;; + eval) cmd_eval "$@" ;; + metrics) cmd_metrics "$@" ;; + plan-check) cmd_plan_check "$@" ;; + report) cmd_report "$@" ;; + context) cmd_context "$@" ;; + keep-rate) cmd_keep_rate "$@" ;; + review) cmd_review "$@" ;; + exec) cmd_exec "$@" ;; + abort) cmd_abort "$@" ;; + timeline) cmd_timeline "$@" ;; + resume) cmd_resume "$@" ;; + maintenance) cmd_maintenance "$@" ;; + render) cmd_render "$@" ;; + approve) cmd_approve "$@" ;; + version|--version|-V) cmd_version ;; + help|--help|-h) cmd_help ;; + *) + printf 'Unknown command: %s\n' "$command" >&2 + printf 'Run: plangate help\n' >&2 + exit 1 + ;; +esac diff --git a/docs/working/TASK-0872/patches/bin-plangate.patch b/docs/working/TASK-0872/patches/bin-plangate.patch new file mode 100644 index 00000000..e8a59747 --- /dev/null +++ b/docs/working/TASK-0872/patches/bin-plangate.patch @@ -0,0 +1,172 @@ +diff --git a/bin/plangate b/bin/plangate +index d974cb6..b0f0f81 100755 +--- a/bin/plangate ++++ b/bin/plangate +@@ -882,6 +882,28 @@ cmd_status() { + fi + } + ++# c3-prime / legacy c3.json の受理を判別・検証する共有ヘルパー(TASK-0872 PR-2)。 ++# approvals/c3.json の approval_kind を strict JSON で判別し、c3-prime なら ++# scripts/ai-loop/c3prime_verify.py で Plan Package 束縛を全数再検証する ++# (契約 §4/§5: legacy grep 経路は c3-prime に流用しない)。 ++# 戻り値: 0=c3-prime 受理 / 10=legacy(呼び出し側が grep 経路で処理)/ 1=c3-prime NG ++_plangate_c3_dispatch() { ++ _c3d_dir="$1" ++ _c3d_verify="$plangate_root/scripts/ai-loop/c3prime_verify.py" ++ if [ ! -f "$_c3d_verify" ]; then ++ # 検証器が無い環境では c3-prime を受理できない(fail-closed)。 ++ # legacy 判別のみ python3 で行い、legacy なら委譲・c3-prime なら NG。 ++ _c3d_kind=$(python3 -c "import json,sys ++try: d=json.load(open(sys.argv[1])) ++except Exception: print(''); sys.exit(0) ++print(d.get('approval_kind','') if isinstance(d,dict) else '')" "$_c3d_dir/approvals/c3.json" 2>/dev/null || echo "") ++ [ "$_c3d_kind" = "c3-prime" ] && return 1 ++ return 10 ++ fi ++ python3 "$_c3d_verify" "$_c3d_dir" >&2 ++ return $? ++} ++ + cmd_validate() { + if [ $# -eq 0 ]; then + printf 'Usage: plangate validate [--mode ] | --dir [--mode ]\n' >&2 +@@ -967,29 +989,42 @@ cmd_validate() { + failures=$((failures + 1)) + else + printf ' [PASS] approvals/c3.json exists\n' +- c3_status=$(grep '"c3_status"' "$c3_file" 2>/dev/null | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") +- if [ "$c3_status" = "APPROVED" ]; then +- printf ' [PASS] c3_status = APPROVED\n' +- else +- printf ' [FAIL] c3_status = %s (expected APPROVED)\n' "${c3_status:-missing}" ++ # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 ++ # legacy(approval_kind 無し)は従来の grep 経路で処理する。 ++ # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 ++ _c3_rc=0 ++ _plangate_c3_dispatch "$work_dir" || _c3_rc=$? ++ if [ "$_c3_rc" = "0" ]; then ++ printf ' [PASS] c3-prime record verified (decision=AUTO_APPROVED, Plan Package 束縛整合)\n' ++ elif [ "$_c3_rc" = "1" ]; then ++ printf ' [FAIL] c3-prime verification failed (see message above)\n' + failures=$((failures + 1)) +- fi +- +- # plan_hash check +- plan_file="$work_dir/plan.md" +- if [ -f "$plan_file" ]; then +- recorded_hash=$(grep '"plan_hash"' "$c3_file" 2>/dev/null | sed 's/.*"plan_hash"[[:space:]]*:[[:space:]]*"sha256:\([^"]*\)".*/\1/' || echo "") +- if [ -z "$recorded_hash" ]; then +- printf ' [WARN] plan_hash not found in c3.json\n' ++ else ++ # _c3_rc == 10: legacy 経路(従来どおり grep で c3_status / plan_hash 検証) ++ c3_status=$(grep '"c3_status"' "$c3_file" 2>/dev/null | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") ++ if [ "$c3_status" = "APPROVED" ]; then ++ printf ' [PASS] c3_status = APPROVED\n' + else +- current_hash=$(plangate_sha256 "$plan_file") +- if [ "$current_hash" = "$recorded_hash" ]; then +- printf ' [PASS] plan.md hash matches c3.json (no post-approval modification)\n' ++ printf ' [FAIL] c3_status = %s (expected APPROVED)\n' "${c3_status:-missing}" ++ failures=$((failures + 1)) ++ fi ++ ++ # plan_hash check ++ plan_file="$work_dir/plan.md" ++ if [ -f "$plan_file" ]; then ++ recorded_hash=$(grep '"plan_hash"' "$c3_file" 2>/dev/null | sed 's/.*"plan_hash"[[:space:]]*:[[:space:]]*"sha256:\([^"]*\)".*/\1/' || echo "") ++ if [ -z "$recorded_hash" ]; then ++ printf ' [WARN] plan_hash not found in c3.json\n' + else +- printf ' [FAIL] plan.md hash MISMATCH — plan was modified after C-3 approval\n' +- printf ' recorded: sha256:%s\n' "$recorded_hash" +- printf ' current: sha256:%s\n' "$current_hash" +- failures=$((failures + 1)) ++ current_hash=$(plangate_sha256 "$plan_file") ++ if [ "$current_hash" = "$recorded_hash" ]; then ++ printf ' [PASS] plan.md hash matches c3.json (no post-approval modification)\n' ++ else ++ printf ' [FAIL] plan.md hash MISMATCH — plan was modified after C-3 approval\n' ++ printf ' recorded: sha256:%s\n' "$recorded_hash" ++ printf ' current: sha256:%s\n' "$current_hash" ++ failures=$((failures + 1)) ++ fi + fi + fi + fi +@@ -2002,19 +2037,28 @@ cmd_exec() { + return 1 + fi + +- c3_status=$(grep '"c3_status"' "$work_dir/approvals/c3.json" 2>/dev/null \ +- | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") +- if [ "$c3_status" != "APPROVED" ]; then +- printf 'error: C-3 gate not approved (status=%s)\n' "${c3_status:-missing}" >&2 ++ # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 ++ # legacy(approval_kind 無し)は従来の grep + plan_hash 経路で処理する。 ++ # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 ++ _c3_rc=0 ++ _plangate_c3_dispatch "$work_dir" || _c3_rc=$? ++ if [ "$_c3_rc" = "1" ]; then ++ printf 'error: C-3 gate not approved (c3-prime verification failed; see above)\n' >&2 + return 1 +- fi ++ elif [ "$_c3_rc" = "10" ]; then ++ c3_status=$(grep '"c3_status"' "$work_dir/approvals/c3.json" 2>/dev/null \ ++ | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") ++ if [ "$c3_status" != "APPROVED" ]; then ++ printf 'error: C-3 gate not approved (status=%s)\n' "${c3_status:-missing}" >&2 ++ return 1 ++ fi + +- # Gate check: plan_hash integrity (EH-3 integration at exec entry) +- # plan.md must not have been modified after C-3 approval. +- plan_file="$work_dir/plan.md" +- c3_file="$work_dir/approvals/c3.json" +- if [ -f "$plan_file" ]; then +- recorded_hash=$(python3 -c "import json,sys ++ # Gate check: plan_hash integrity (EH-3 integration at exec entry) ++ # plan.md must not have been modified after C-3 approval. ++ plan_file="$work_dir/plan.md" ++ c3_file="$work_dir/approvals/c3.json" ++ if [ -f "$plan_file" ]; then ++ recorded_hash=$(python3 -c "import json,sys + try: + d=json.load(open(sys.argv[1])) + except Exception: +@@ -2023,21 +2067,23 @@ if not isinstance(d,dict): + print(''); sys.exit(0) + v=d.get('plan_hash','') + print(v[7:] if isinstance(v,str) and v.startswith('sha256:') else '')" "$c3_file" 2>/dev/null || echo "") +- if [ -n "$recorded_hash" ]; then +- if command -v sha256sum >/dev/null 2>&1; then +- current_hash=$(sha256sum "$plan_file" | awk '{print $1}') +- else +- current_hash=$(shasum -a 256 "$plan_file" | awk '{print $1}') +- fi +- if [ "$recorded_hash" != "$current_hash" ]; then +- printf 'error: plan_hash mismatch -- plan.md modified after C-3 approval\n' >&2 +- printf ' Recorded: sha256:%s\n' "$recorded_hash" >&2 +- printf ' Current : sha256:%s\n' "$current_hash" >&2 +- printf ' Action : Re-approval required (update c3.json plan_hash) or revert plan.md.\n' >&2 +- return 1 ++ if [ -n "$recorded_hash" ]; then ++ if command -v sha256sum >/dev/null 2>&1; then ++ current_hash=$(sha256sum "$plan_file" | awk '{print $1}') ++ else ++ current_hash=$(shasum -a 256 "$plan_file" | awk '{print $1}') ++ fi ++ if [ "$recorded_hash" != "$current_hash" ]; then ++ printf 'error: plan_hash mismatch -- plan.md modified after C-3 approval\n' >&2 ++ printf ' Recorded: sha256:%s\n' "$recorded_hash" >&2 ++ printf ' Current : sha256:%s\n' "$current_hash" >&2 ++ printf ' Action : Re-approval required (update c3.json plan_hash) or revert plan.md.\n' >&2 ++ return 1 ++ fi + fi + fi + fi ++ # _c3_rc == 0: c3-prime 受理(Plan Package 束縛整合済み) + + # Record session start + ts=$(plangate_now) diff --git a/docs/working/TASK-0872/patches/ho-apply-approval.md b/docs/working/TASK-0872/patches/ho-apply-approval.md new file mode 100644 index 00000000..b02b2459 --- /dev/null +++ b/docs/working/TASK-0872/patches/ho-apply-approval.md @@ -0,0 +1,66 @@ +# HO Apply Approval — TASK-0872 PR-2 + +> Hardening Override 対象ファイルへの適用は **Human-owned**(AI は patch 生成・ +> sandbox 実適用テストまで)。本書は適用対象・検証結果・適用コマンドを提示する。 +> 責務分類正本: `.claude/rules/responsibility-classes.md`(HO 常時 block)。 + +## 適用対象(HO・4 ファイル) + +| # | ファイル | 変更 | 適用方法 | patch/完成形 | +|---|---------|------|---------|-------------| +| 1 | `bin/plangate` | validate / exec preflight に c3-prime 受理分岐(`_plangate_c3_dispatch`)追加。legacy grep 経路は非変更 | `git apply` または `cp` | `patches/bin-plangate.patch`(89+/43-)または `patches/bin-plangate.new`(完成形) | +| 2 | `.claude/commands/ai-loop-workflow.md` | `run` 入口を Plan-first(TASK-XXXX 必須)へ改訂 | 同上 | `patches/ai-loop-workflow-claude.patch` / `.new` | +| 3 | `plugin/plangate/commands/ai-loop-workflow.md` | 同上(sync 対・byte 同一) | `cp` | `patches/ai-loop-workflow.plugin.new` | +| 4 | `schemas/c3-prime.schema.json` | 新設(cross-field 制約込み) | `cp` | `patches/c3-prime.schema.json`(完成形) | + +## AI 実施済みの検証(sandbox 実適用テスト) + +`bin/plangate` を scratchpad に完全構造の sandbox を組んで実際に叩き、全 exit code を実測(推測でない): + +| シナリオ | 実測 exit | 期待 | +|---------|----------|------| +| legacy c3.json validate(実 TASK-0872) | 0 | 0(非退行) | +| legacy c3.json exec | 0 | 0(非退行) | +| c3-prime 正常 validate | 0 | 0(受理) | +| c3-prime 正常 exec | 0 | 0(受理) | +| c3-prime artifact 改竄 validate | 1 | 1(stale reject) | +| c3-prime artifact 改竄 exec | 1 | 1(reject) | + +`patches/bin-plangate.patch` は `git apply --check` clean 通過を確認済み。 +`schemas/c3-prime.schema.json` は正側 record PASS + 負側 4 パターン(c3_status 混入 / +未知 approval_kind / snapshot 欠落 / AUTO_APPROVED+reject cross-field)reject を +jsonschema で実測済み。 + +## 適用コマンド(Human 実行) + +```sh +cd +# 1. bin/plangate +git apply docs/working/TASK-0872/patches/bin-plangate.patch +# または: cp docs/working/TASK-0872/patches/bin-plangate.new bin/plangate && chmod +x bin/plangate + +# 2. commands(.claude + plugin 同期) +cp docs/working/TASK-0872/patches/ai-loop-workflow.claude.new .claude/commands/ai-loop-workflow.md +cp docs/working/TASK-0872/patches/ai-loop-workflow.plugin.new plugin/plangate/commands/ai-loop-workflow.md + +# 3. schema 配置 +cp docs/working/TASK-0872/patches/c3-prime.schema.json schemas/c3-prime.schema.json + +# 4. 検証(適用後) +sh scripts/sync-plugin-plangate.sh # drift 0 確認 +sh tests/run-tests.sh # TA-55 の HO 全鎖テストが SKIP→PASS へ +python3 scripts/ai-loop/c3prime_verify.py --help 2>/dev/null || true +``` + +## 適用後に自動で有効化されるもの + +- `tests/extras/ta-55` の `[SKIP] bin/plangate に c3-prime 配線なし` が + `[PASS] HO 適用後: bin/plangate validate が c3-prime を受理` に切り替わる +- `scripts/schema_mapping.py` の F-8 dispatch が実 schema を得て、c3-prime c3.json を + `c3-prime.schema.json` で検証(TA-05 F-8 テストは schema 実在で SKIP へ切替) + +## 適用しない場合の安全性(fail-closed) + +未適用の間、c3-prime artifact は `bin/plangate` の legacy grep 経路で `c3_status` +不在により **FAIL**(受理されない)。schema-validate も #887 F-8 により +schema 不在で **ERROR**。いずれも安全側で、Shadow Config は発生しない。 diff --git a/scripts/ai-loop/c3prime_verify.py b/scripts/ai-loop/c3prime_verify.py new file mode 100755 index 00000000..93054e26 --- /dev/null +++ b/scripts/ai-loop/c3prime_verify.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""c3-prime 受理検証(bin/plangate validate / exec preflight 共有・#872 PR-2)。 + +契約正本: docs/workflows/ai-loop/c3-prime-contract.md §4。 +approvals/c3.json の approval_kind を strict JSON で判別し、c3-prime なら +Plan Package への束縛を全数**再検証**する(trust boundary: decision 値を +無検証で信頼しない・#887 F-4/R-018)。legacy(approval_kind なし)は本 +スクリプトの対象外(呼び出し側 shell の grep 経路が担う)。 + +使い方: c3prime_verify.py + exit 0 = c3-prime として受理(AUTO_APPROVED・全束縛整合) + exit 10 = legacy(approval_kind 無し)→ 呼び出し側が legacy 経路で処理 + exit 1 = c3-prime だが検証 NG(fail-closed。理由を stderr に出力) +""" +from __future__ import annotations + +import hashlib +import json +import pathlib +import re +import sys + +ARTIFACTS = ( + "pbi-input.md", "plan.md", "todo.md", "test-cases.md", + "review-self.md", "review-external.md", +) +VALID_DECISIONS = ("AUTO_APPROVED", "HUMAN_ESCALATED", "BLOCKED") +VALID_VERDICTS = ("approve", "reject") +SNAPSHOT_KEYS = ("verdict", "plan_hash", "source_sha", "plan_package_hash", "evidence_ref") + + +def _sha256(path: pathlib.Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def _fail(msg: str) -> int: + print(f"c3-prime: {msg}", file=sys.stderr) + return 1 + + +def main(argv): + if len(argv) != 2: + print("usage: c3prime_verify.py ", file=sys.stderr) + return 1 + task_dir = pathlib.Path(argv[1]) + c3 = task_dir / "approvals" / "c3.json" + if not c3.is_file(): + return _fail("approvals/c3.json not found") + try: + data = json.loads(c3.read_text(encoding="utf-8")) + except (ValueError, OSError) as exc: + return _fail(f"c3.json を strict JSON として読めない: {exc}") + if not isinstance(data, dict): + return _fail("c3.json が JSON object でない") + + if data.get("approval_kind") != "c3-prime": + # legacy(キー無し)または未知値。未知値は明示 fail、無しは legacy 委譲。 + if "approval_kind" in data: + return _fail(f"approval_kind が未知値: {data.get('approval_kind')!r}") + return 10 # legacy → 呼び出し側 shell へ委譲 + + # ここから c3-prime。契約 §4 の全規則を再検証する。 + # decision 3値 allowlist + decision = data.get("decision") + if decision not in VALID_DECISIONS: + return _fail(f"decision が契約の 3 値以外: {decision!r}") + if decision != "AUTO_APPROVED": + return _fail(f"decision={decision}(exec 不可。AUTO_APPROVED のみ受理)") + + # source_sha 形式 + source_sha = data.get("source_sha", "") + if not re.fullmatch(r"[0-9a-f]{7,40}", source_sha or ""): + return _fail(f"source_sha が commit SHA 形式でない: {source_sha!r}") + + # plan_hash = plan.md 単体(legacy と同一契約) + plan_md = task_dir / "plan.md" + if not plan_md.is_file(): + return _fail("plan.md が存在しない") + if data.get("plan_hash") != _sha256(plan_md): + return _fail("plan_hash が現 plan.md と不一致(stale。再 C-1/C-2/C-3' が必要)") + + # artifact_hashes 全数照合(どのエントリの不一致かを明示) + ah = data.get("artifact_hashes") + if not isinstance(ah, dict) or set(ah) != set(ARTIFACTS): + return _fail("artifact_hashes のキーが Plan Package 6 要素と一致しない") + for name in ARTIFACTS: + f = task_dir / name + if not f.is_file(): + return _fail(f"artifact 欠落: {name}") + if ah[name] != _sha256(f): + return _fail(f"artifact_hashes 不一致: {name}(stale)") + + # plan_package_hash = artifact_hashes の正規化 JSON の sha256 + canon = json.dumps(ah, sort_keys=True, separators=(",", ":")).encode("utf-8") + if data.get("plan_package_hash") != "sha256:" + hashlib.sha256(canon).hexdigest(): + return _fail("plan_package_hash が artifact_hashes から再計算した値と不一致") + + # reviewer snapshot 三つ組一致 + decision-verdict 整合 + reviewers = data.get("reviewers") + if not isinstance(reviewers, dict) or set(reviewers) < {"model_a", "model_b"}: + return _fail("reviewers に model_a / model_b が揃っていない") + for m in ("model_a", "model_b"): + snap = reviewers.get(m) + if not isinstance(snap, dict) or any(not snap.get(k) for k in SNAPSHOT_KEYS): + return _fail(f"reviewers.{m} の snapshot が不完全") + if snap.get("verdict") not in VALID_VERDICTS: + return _fail(f"reviewers.{m}.verdict が approve/reject 以外") + for key in ("plan_hash", "source_sha", "plan_package_hash"): + if snap.get(key) != data.get(key): + return _fail(f"reviewers.{m}.{key} がトップレベル値と不一致(AC-5 違反)") + # AUTO_APPROVED は両 reviewer approve のときのみ(改竄兆候の検出) + if any(reviewers[m].get("verdict") != "approve" for m in ("model_a", "model_b")): + return _fail("decision=AUTO_APPROVED だが reviewer verdict に reject を含む(改竄兆候)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/extras/ta-55-c3prime-accept.sh b/tests/extras/ta-55-c3prime-accept.sh new file mode 100644 index 00000000..68f5a729 --- /dev/null +++ b/tests/extras/ta-55-c3prime-accept.sh @@ -0,0 +1,101 @@ +# tests/extras/ta-55-c3prime-accept.sh +# Sourced by tests/run-tests.sh +# TASK-0872 PR-2 (#872): c3-prime 受理側(scripts/ai-loop/c3prime_verify.py)の +# E2E。契約 docs/workflows/ai-loop/c3-prime-contract.md §4 の受理規則を検証する。 +# +# 構成: +# - 常時: c3prime_verify.py を直接叩き、legacy(exit 10)/valid(exit 0)/ +# tampered(exit 1)/未知 approval_kind(exit 1) を確認(非 HO・CI 常時実行)。 +# - HO 適用後のみ: bin/plangate に _plangate_c3_dispatch 配線が入っていれば +# validate/exec の全鎖も検証する(未適用時は SKIP=HO apply 待ち)。 + +printf '\n=== TA-55: c3-prime acceptance (issue #872 PR-2) ===\n' + +PG_T55_ROOT="$(CDPATH= cd -- "$FIXTURES_DIR/../.." && pwd)" +PG_T55_VERIFY="$PG_T55_ROOT/scripts/ai-loop/c3prime_verify.py" +t55_pass() { pass=$((pass + 1)); printf ' [PASS] %s\n' "$1"; } +t55_fail() { fail=$((fail + 1)); printf ' [FAIL] %s\n' "$1" >&2; } + +if [ ! -f "$PG_T55_VERIFY" ]; then + printf ' [FAIL] scripts/ai-loop/c3prime_verify.py が存在しない\n' >&2 + fail=$((fail + 1)) +elif ! command -v python3 >/dev/null 2>&1; then + printf ' [SKIP] python3 不在\n' +else + # sandbox TASK dir を build_c3_prime で生成(plan_package.py を利用) + _t55_tmp=$(mktemp -d) + register_cleanup "$_t55_tmp" + if python3 - "$PG_T55_ROOT" "$_t55_tmp" <<'PYEOF' +import sys, pathlib +root, tmp = sys.argv[1], sys.argv[2] +sys.path.insert(0, str(pathlib.Path(root) / "scripts" / "ai-loop")) +import plan_package, test_plan_package as tpp +d = tpp._make_task_dir(tmp) # TASK-9999 +(d / "approvals").mkdir(exist_ok=True) +rec = plan_package.build_c3_prime( + d, task_id="TASK-9999", source_sha="abc1234", target_sha="abc1234", + verdicts={"model_a": "approve", "model_b": "approve"}, + reviewer_evidence={"model_a": "r#a", "model_b": "r#b"}, + decision="AUTO_APPROVED", policy_ref="p@v4", + issued_at="2100-01-01T00:00:00Z", issued_by="arbiter-v0.1") +(d / "approvals" / "c3.json").write_text(plan_package.serialize_c3_prime(rec)) +PYEOF + then + _t55_task="$_t55_tmp/TASK-9999" + + _t55_rc=0; python3 "$PG_T55_VERIFY" "$_t55_task" >/dev/null 2>&1 || _t55_rc=$? + if [ "$_t55_rc" = "0" ]; then t55_pass "valid c3-prime → exit 0 (受理)"; else t55_fail "valid c3-prime が受理されない (rc=$_t55_rc)"; fi + + # 改竄: plan.md を 1 byte 変更 → stale で reject + printf 'x' >> "$_t55_task/plan.md" + _t55_rc=0; python3 "$PG_T55_VERIFY" "$_t55_task" >/dev/null 2>&1 || _t55_rc=$? + if [ "$_t55_rc" = "1" ]; then t55_pass "plan.md 改竄 → exit 1 (stale reject)"; else t55_fail "plan.md 改竄が reject されない (rc=$_t55_rc)"; fi + + # legacy c3.json → exit 10(呼び出し側委譲) + printf '{"task_id":"TASK-9999","phase":"C-3","c3_status":"APPROVED","approved_by":"h","approved_at":"2026-01-01T00:00:00Z","plan_hash":"sha256:%064d"}' 0 \ + > "$_t55_task/approvals/c3.json" + _t55_rc=0; python3 "$PG_T55_VERIFY" "$_t55_task" >/dev/null 2>&1 || _t55_rc=$? + if [ "$_t55_rc" = "10" ]; then t55_pass "legacy c3.json → exit 10 (委譲)"; else t55_fail "legacy c3.json が exit 10 にならない (rc=$_t55_rc)"; fi + + # 未知 approval_kind → exit 1 + printf '{"approval_kind":"c3-double-prime"}' > "$_t55_task/approvals/c3.json" + _t55_rc=0; python3 "$PG_T55_VERIFY" "$_t55_task" >/dev/null 2>&1 || _t55_rc=$? + if [ "$_t55_rc" = "1" ]; then t55_pass "未知 approval_kind → exit 1 (fail-closed)"; else t55_fail "未知 approval_kind が reject されない (rc=$_t55_rc)"; fi + else + t55_fail "sandbox TASK 生成に失敗(plan_package.py import 不可)" + fi + + # HO 適用後のみ: bin/plangate 全鎖(配線が入っているときだけ) + if grep -q '_plangate_c3_dispatch' "$PG_T55_ROOT/bin/plangate" 2>/dev/null; then + _t55_env="$_t55_tmp/repo" + mkdir -p "$_t55_env" + # 別 root を偽装せず、実 bin/plangate を実 root で叩く(TASK-9999 を実 working へ + # 置くと汚染するため、--dir でサンドボックス working を指す) + if python3 - "$PG_T55_ROOT" "$_t55_tmp" <<'PYEOF' +import sys, pathlib +root, tmp = sys.argv[1], sys.argv[2] +sys.path.insert(0, str(pathlib.Path(root) / "scripts" / "ai-loop")) +import plan_package, test_plan_package as tpp +base = pathlib.Path(tmp) / "chain" +base.mkdir(parents=True, exist_ok=True) +d = tpp._make_task_dir(str(base)) # TASK-9999 +(d / "approvals").mkdir(exist_ok=True) +rec = plan_package.build_c3_prime( + d, task_id="TASK-9999", source_sha="abc1234", target_sha="abc1234", + verdicts={"model_a": "approve", "model_b": "approve"}, + reviewer_evidence={"model_a": "r#a", "model_b": "r#b"}, + decision="AUTO_APPROVED", policy_ref="p@v4", + issued_at="2100-01-01T00:00:00Z", issued_by="arbiter-v0.1") +(d / "approvals" / "c3.json").write_text(plan_package.serialize_c3_prime(rec)) +PYEOF + then + if sh "$PG_T55_ROOT/bin/plangate" validate --dir "$_t55_tmp/chain/TASK-9999" >/dev/null 2>&1; then + t55_pass "HO 適用後: bin/plangate validate が c3-prime を受理" + else + t55_fail "HO 適用後: bin/plangate validate が c3-prime を受理しない" + fi + fi + else + printf ' [SKIP] bin/plangate に c3-prime 配線なし(HO apply 待ち・patches/bin-plangate.patch)\n' + fi +fi From 357a11a130f9a5924731ed1af6f862a467a95c77 Mon Sep 17 00:00:00 2001 From: mine_take Date: Mon, 20 Jul 2026 09:07:37 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(TASK-0872):=20#889=20=E5=8F=97=E7=90=86?= =?UTF-8?q?=E5=81=B4=E6=95=B5=E5=AF=BE=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=98=AF=E6=AD=A3=20=E2=80=94=20=E5=81=BD=E9=80=A0=20record=20?= =?UTF-8?q?fail-closed=20=E5=BC=B7=E5=8C=96=EF=BC=88R-026=E3=80=9C030?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 敵対的レビュー(受理側・reject: critical 1/high 2/medium 1)を全件一次ソース 再現裁定し是正: - R-026 critical: c3prime_verify.py が偽造 record(source_sha 不整合・必須欠落・ c3_status 混入・未知キー)を AUTO_APPROVED 受理していた(再現 CONFIRMED)→ 構造 allowlist + 必須キー + c3_status 拒否 + task_id/phase 検証 + evidence/policy/ issued 非空 + optional expected_sha 照合(exec preflight が git rev-parse HEAD を渡し source_sha を厳密照合・契約 §4) - R-027 high: 受理器不在時 approval_kind=[]/null/不正 JSON が legacy 委譲されていた → fallback を「approval_kind キーが物理的に無い場合のみ 10、存在すれば値不問で 1」へ - R-028 high TOCTOU: exec preflight が再検証の実点であることを契約 §4 に明記 + 残余窓は Phase 1 許容・flock 単一 snapshot を V2 候補化 - R-029 medium: test_c3prime_verify.py 新設(producer 非依存の手 mutate 14 パターン)+ ta-55 に手偽造ケース追加 - R-030 info: || _c3_rc=0 の set -e 安全性は指摘なし sandbox 実適用テストで非退行(legacy/正規 record 受理)+ 偽造 reject + expected_sha BLOCK を実測。bin-plangate.patch 再生成(git apply --check clean)。全変更は非 HO (bin/plangate は patches/ のみ・Human 適用)。 検証: test_c3prime_verify 6 / test_plan_package 30 / run-tests 411 passed(TA-55 に 手偽造ケース含む) Refs: #889, #872 Co-Authored-By: Claude Fable 5 --- docs/workflows/ai-loop/c3-prime-contract.md | 8 +- docs/working/TASK-0872/decision-log.jsonl | 1 + .../TASK-0872/patches/bin-plangate.new | 27 ++-- .../TASK-0872/patches/bin-plangate.patch | 37 ++++-- docs/working/TASK-0872/review-external.md | 14 ++ .../references/c3-prime-contract.md | 8 +- scripts/ai-loop/c3prime_verify.py | 55 ++++++-- scripts/ai-loop/test_c3prime_verify.py | 120 ++++++++++++++++++ tests/extras/ta-55-c3prime-accept.sh | 8 ++ 9 files changed, 242 insertions(+), 36 deletions(-) create mode 100644 scripts/ai-loop/test_c3prime_verify.py diff --git a/docs/workflows/ai-loop/c3-prime-contract.md b/docs/workflows/ai-loop/c3-prime-contract.md index 8848ccf0..10a7eea6 100644 --- a/docs/workflows/ai-loop/c3-prime-contract.md +++ b/docs/workflows/ai-loop/c3-prime-contract.md @@ -70,7 +70,7 @@ C-1/C-2 いずれか**単独**の異常でも `AUTO_APPROVED` にならない( ## 4. stale / 受理規則(AC-7 / AC-8 / AC-9) -受理側(`bin/plangate validate` / exec preflight — PR-2)は `approval_kind` の有無で分岐する: +受理側(`bin/plangate validate` / exec preflight — PR-2)は `approval_kind` の有無で分岐する。**受理側は decision 値を無検証で信頼せず、以下の全規則を strict JSON で再検証する**(trust boundary / #887 F-4 / #889 critical)。トップレベルは必須キー allowlist(`c3_status` および未知キーは reject)+ `task_id`(`TASK-XXXX`)+ `phase`(`C-3'`)+ evidence/policy/issued の非空を含む: | 条件 | 判定 | |------|------| @@ -81,11 +81,15 @@ C-1/C-2 いずれか**単独**の異常でも `AUTO_APPROVED` にならない( | `decision == "AUTO_APPROVED"` だが reviewers の verdict に `reject` を含む | **BLOCK**(decision↔verdicts 不整合 record = 改竄兆候。#887 F-3 の受理側検証) | | `plan_hash` ≠ 現 plan.md sha256 | FAIL(stale・legacy と同一規則) | | `artifact_hashes` のいずれか ≠ 現ファイル sha256 | FAIL(stale。**不一致エントリ名を失敗メッセージに含む**) | -| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。再 C-1/C-2/C-3' を要求 | +| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。exec preflight は `git rev-parse HEAD` を `expected_sha` として受理器へ渡し厳密照合する(#889 critical)。静的 validate は expected_sha を渡さず構造・束縛のみ検証 | +| トップレベルに `c3_status` / 未知キー / 必須キー欠落 | **FAIL**(構造 allowlist・#889 critical。`^_` 注釈キーのみ許容) | +| 受理器(`c3prime_verify.py`)が実在しない | c3-prime は **FAIL**(検証不能。`approval_kind` キーが物理的に無い場合のみ legacy 委譲 / #889 high fallback) | | 同一 TASK に legacy と c3-prime が併存 | **物理的に不可能**(同一パス `approvals/c3.json` のため)。上書きは `--force` 相当の明示操作のみ(EC-5 の解決) | EH-3 hook(`check-plan-hash.sh`)は top-level `plan_hash` のみを strict JSON で読むため、c3-prime に対しても**無変更で機能**する(非退行・本契約が plan_hash を legacy と同一義で保持する理由)。 +**TOCTOU の残余窓(#889 high・既知制約)**: 受理器は exec preflight で再検証するが、検証成功から `session_started` 記録までの 1 shell 文の窓で外部プロセスが artifact を書き換える理論的余地は残る(ローカル書込レースが前提)。緩和として **exec preflight が受理検証の実点**であり(validate だけでなく exec 直前に再検証)、session 開始は直後に続く。Phase 1(隔離 PoC)ではこの残余窓を許容し、将来 flock ベースの単一 snapshot 検証を V2 候補とする(handoff 記載)。 + ## 5. serialization 制約(R-009 / #887 F-7 是正) - `json.dumps(indent=2, sort_keys=True)` で整形(arbiter provenance 出力と同形) diff --git a/docs/working/TASK-0872/decision-log.jsonl b/docs/working/TASK-0872/decision-log.jsonl index b52784f6..80b7c974 100644 --- a/docs/working/TASK-0872/decision-log.jsonl +++ b/docs/working/TASK-0872/decision-log.jsonl @@ -14,3 +14,4 @@ {"ts":"2026-07-20T09:00:00Z","phase":"exec","decision":"#887(別セッション敵対的レビューmajor 4)を受理しPR-2前に是正実装へ。自レーンCレビュー(approve)はF-1/F-3/F-5を見逃し=検出力差を認め是々非々で採用。是正設計: evidence判定は行頭アンカー機械マーカー(C1-VERDICT/C2-VERDICT + plan=sha256:)へ正規化しちょうど1回を要求(F-1)・stale はマーカー内plan hash照合で決定論化しmtime廃止(F-2)・build_c3_primeにdecision↔verdicts整合(AUTO_APPROVEDは両approve必須)を追加し契約§4にも受理側規則明記(F-3)・実artifact形式fixture追加(F-5)・契約§5のgrep記述是正=legacy grep流用禁止を明記(F-7)・schema_mappingはc3-prime時にis_fileスキップでschema不在をFAILへ(F-8)","by":"agent"} {"ts":"2026-07-20T09:30:00Z","phase":"exec","decision":"複数エージェントレビュー(Codex×2+Sonnet)実施。Codex conditional/reject vs Sonnet approve の分裂を全件一次ソースで再現裁定。スコープ内 major 4+minor 1 採用(R-019〜023): decision/verdict allowlist・schema cross-field制約・evidence再読TOCTOU fail-closed・F-8回帰テスト・prefix文法外行fail-closed。R-024/025(bin/plangate受理側・schema配置)はPR-2スコープのためreject理由として却下。検証: test_plan_package 30・run-tests 406 passed","by":"agent"} {"ts":"2026-07-20T09:00:00Z","phase":"exec","decision":"PR-2受理側実装。設計: c3-prime検証をscripts/ai-loop/c3prime_verify.py(非HO・AIコミット可)に分離しbin/plangateは_plangate_c3_dispatch配線数行(HO)のみに縮小。契約§4全数再検証(trust boundary #887 F-4)。実適用テストでset -e漏れバグ(dispatch戻り10がスクリプト中断)を捕捉し|| _c3_rc=$?で修正。sandbox 6シナリオ全exit code実測(legacy非退行0/c3-prime受理0/改竄1)。HO patch4件はho-apply-approval.mdでHuman適用。ta-42 TC-04失敗はmacOS sh set-e差の既存偽陽性(main CI success)","by":"agent"} +{"ts":"2026-07-20T10:00:00Z","phase":"exec","decision":"PR #889受理側Codex敵対レビュー(reject: critical 1/high 2/medium 1)を全件一次ソース再現裁定し是正(R-026〜030)。critical=偽造record受理→構造allowlist+必須+c3_status拒否+task_id/phase+expected_sha照合(exec HEAD強制)。high fallback=approval_kindキー存在時は値不問で1へ。high TOCTOU=exec preflight再検証+契約明記(残余窓Phase1許容/flock V2)。medium=test_c3prime_verify.py新設(手mutate 14パターン)+ta-55手偽造ケース。sandbox実適用で非退行+偽造reject実測。全非HO(bin/plangateはpatches/のみ)","by":"agent"} diff --git a/docs/working/TASK-0872/patches/bin-plangate.new b/docs/working/TASK-0872/patches/bin-plangate.new index b0f0f816..df53d7ba 100755 --- a/docs/working/TASK-0872/patches/bin-plangate.new +++ b/docs/working/TASK-0872/patches/bin-plangate.new @@ -886,21 +886,28 @@ cmd_status() { # approvals/c3.json の approval_kind を strict JSON で判別し、c3-prime なら # scripts/ai-loop/c3prime_verify.py で Plan Package 束縛を全数再検証する # (契約 §4/§5: legacy grep 経路は c3-prime に流用しない)。 +# 第2引数に expected_sha を渡すと source_sha との厳密一致を強制する(exec 時)。 # 戻り値: 0=c3-prime 受理 / 10=legacy(呼び出し側が grep 経路で処理)/ 1=c3-prime NG _plangate_c3_dispatch() { _c3d_dir="$1" + _c3d_expected="${2:-}" _c3d_verify="$plangate_root/scripts/ai-loop/c3prime_verify.py" if [ ! -f "$_c3d_verify" ]; then - # 検証器が無い環境では c3-prime を受理できない(fail-closed)。 - # legacy 判別のみ python3 で行い、legacy なら委譲・c3-prime なら NG。 - _c3d_kind=$(python3 -c "import json,sys + # 検証器が無い環境では c3-prime を受理できない(fail-closed / #889 high)。 + # approval_kind キーが物理的に存在しない場合のみ legacy(10) 委譲。キーが + # 存在すれば(値が何であれ・型違い・空文字含む)検証不能として NG(1)。 + _c3d_present=$(python3 -c "import json,sys try: d=json.load(open(sys.argv[1])) -except Exception: print(''); sys.exit(0) -print(d.get('approval_kind','') if isinstance(d,dict) else '')" "$_c3d_dir/approvals/c3.json" 2>/dev/null || echo "") - [ "$_c3d_kind" = "c3-prime" ] && return 1 - return 10 +except Exception: print('ERR'); sys.exit(0) +print('YES' if isinstance(d,dict) and 'approval_kind' in d else 'NO')" "$_c3d_dir/approvals/c3.json" 2>/dev/null || echo "ERR") + [ "$_c3d_present" = "NO" ] && return 10 + return 1 + fi + if [ -n "$_c3d_expected" ]; then + python3 "$_c3d_verify" "$_c3d_dir" "$_c3d_expected" >&2 + else + python3 "$_c3d_verify" "$_c3d_dir" >&2 fi - python3 "$_c3d_verify" "$_c3d_dir" >&2 return $? } @@ -2038,10 +2045,12 @@ cmd_exec() { fi # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 + # exec は対象 commit(HEAD)に対して実行するため、source_sha と HEAD の一致を強制する。 # legacy(approval_kind 無し)は従来の grep + plan_hash 経路で処理する。 # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 + _exec_head=$(git -C "$plangate_root" rev-parse HEAD 2>/dev/null || echo "") _c3_rc=0 - _plangate_c3_dispatch "$work_dir" || _c3_rc=$? + _plangate_c3_dispatch "$work_dir" "$_exec_head" || _c3_rc=$? if [ "$_c3_rc" = "1" ]; then printf 'error: C-3 gate not approved (c3-prime verification failed; see above)\n' >&2 return 1 diff --git a/docs/working/TASK-0872/patches/bin-plangate.patch b/docs/working/TASK-0872/patches/bin-plangate.patch index e8a59747..fdf35220 100644 --- a/docs/working/TASK-0872/patches/bin-plangate.patch +++ b/docs/working/TASK-0872/patches/bin-plangate.patch @@ -1,8 +1,8 @@ diff --git a/bin/plangate b/bin/plangate -index d974cb6..b0f0f81 100755 +index d974cb6..df53d7b 100755 --- a/bin/plangate +++ b/bin/plangate -@@ -882,6 +882,28 @@ cmd_status() { +@@ -882,6 +882,35 @@ cmd_status() { fi } @@ -10,28 +10,35 @@ index d974cb6..b0f0f81 100755 +# approvals/c3.json の approval_kind を strict JSON で判別し、c3-prime なら +# scripts/ai-loop/c3prime_verify.py で Plan Package 束縛を全数再検証する +# (契約 §4/§5: legacy grep 経路は c3-prime に流用しない)。 ++# 第2引数に expected_sha を渡すと source_sha との厳密一致を強制する(exec 時)。 +# 戻り値: 0=c3-prime 受理 / 10=legacy(呼び出し側が grep 経路で処理)/ 1=c3-prime NG +_plangate_c3_dispatch() { + _c3d_dir="$1" ++ _c3d_expected="${2:-}" + _c3d_verify="$plangate_root/scripts/ai-loop/c3prime_verify.py" + if [ ! -f "$_c3d_verify" ]; then -+ # 検証器が無い環境では c3-prime を受理できない(fail-closed)。 -+ # legacy 判別のみ python3 で行い、legacy なら委譲・c3-prime なら NG。 -+ _c3d_kind=$(python3 -c "import json,sys ++ # 検証器が無い環境では c3-prime を受理できない(fail-closed / #889 high)。 ++ # approval_kind キーが物理的に存在しない場合のみ legacy(10) 委譲。キーが ++ # 存在すれば(値が何であれ・型違い・空文字含む)検証不能として NG(1)。 ++ _c3d_present=$(python3 -c "import json,sys +try: d=json.load(open(sys.argv[1])) -+except Exception: print(''); sys.exit(0) -+print(d.get('approval_kind','') if isinstance(d,dict) else '')" "$_c3d_dir/approvals/c3.json" 2>/dev/null || echo "") -+ [ "$_c3d_kind" = "c3-prime" ] && return 1 -+ return 10 ++except Exception: print('ERR'); sys.exit(0) ++print('YES' if isinstance(d,dict) and 'approval_kind' in d else 'NO')" "$_c3d_dir/approvals/c3.json" 2>/dev/null || echo "ERR") ++ [ "$_c3d_present" = "NO" ] && return 10 ++ return 1 ++ fi ++ if [ -n "$_c3d_expected" ]; then ++ python3 "$_c3d_verify" "$_c3d_dir" "$_c3d_expected" >&2 ++ else ++ python3 "$_c3d_verify" "$_c3d_dir" >&2 + fi -+ python3 "$_c3d_verify" "$_c3d_dir" >&2 + return $? +} + cmd_validate() { if [ $# -eq 0 ]; then printf 'Usage: plangate validate [--mode ] | --dir [--mode ]\n' >&2 -@@ -967,29 +989,42 @@ cmd_validate() { +@@ -967,29 +996,42 @@ cmd_validate() { failures=$((failures + 1)) else printf ' [PASS] approvals/c3.json exists\n' @@ -94,7 +101,7 @@ index d974cb6..b0f0f81 100755 fi fi fi -@@ -2002,19 +2037,28 @@ cmd_exec() { +@@ -2002,19 +2044,30 @@ cmd_exec() { return 1 fi @@ -103,10 +110,12 @@ index d974cb6..b0f0f81 100755 - if [ "$c3_status" != "APPROVED" ]; then - printf 'error: C-3 gate not approved (status=%s)\n' "${c3_status:-missing}" >&2 + # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 ++ # exec は対象 commit(HEAD)に対して実行するため、source_sha と HEAD の一致を強制する。 + # legacy(approval_kind 無し)は従来の grep + plan_hash 経路で処理する。 + # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 ++ _exec_head=$(git -C "$plangate_root" rev-parse HEAD 2>/dev/null || echo "") + _c3_rc=0 -+ _plangate_c3_dispatch "$work_dir" || _c3_rc=$? ++ _plangate_c3_dispatch "$work_dir" "$_exec_head" || _c3_rc=$? + if [ "$_c3_rc" = "1" ]; then + printf 'error: C-3 gate not approved (c3-prime verification failed; see above)\n' >&2 return 1 @@ -134,7 +143,7 @@ index d974cb6..b0f0f81 100755 try: d=json.load(open(sys.argv[1])) except Exception: -@@ -2023,21 +2067,23 @@ if not isinstance(d,dict): +@@ -2023,21 +2076,23 @@ if not isinstance(d,dict): print(''); sys.exit(0) v=d.get('plan_hash','') print(v[7:] if isinstance(v,str) and v.startswith('sha256:') else '')" "$c3_file" 2>/dev/null || echo "") diff --git a/docs/working/TASK-0872/review-external.md b/docs/working/TASK-0872/review-external.md index b76597c1..bc53bdcc 100644 --- a/docs/working/TASK-0872/review-external.md +++ b/docs/working/TASK-0872/review-external.md @@ -70,6 +70,20 @@ Sonnet 独立レーンの approve は上記エッジ(allowlist/cross-field/TOCTOU)を突かなかった検出力差。ただし「テスト fixture が実運用 artifact 形式と整合(#887 F-5 の再発なし)」「契約内部整合に矛盾なし」「legacy 経路非変更を実測」の確証は有用として記録。 +## PR #889 受理側の Codex 敵対的レビュー(2026-07-20・reject → 全件是正) + +> 受理側(承認境界・セキュリティ critical)に Codex 敵対的レビュー 1 本。全指摘をオーガナイザーが一次ソースで再現裁定。 + +| ID | severity | 対象 | 指摘 | 再現 | 是正 | +|----|----------|------|------|------|------| +| R-026 | **critical** | c3prime_verify.py | 偽造 record(source_sha 不整合・必須欠落・c3_status 混入・未知キー)を AUTO_APPROVED 受理 | **CONFIRMED**(exit 0) | **是正**: 構造 allowlist + 必須キー + c3_status 拒否 + task_id/phase + evidence/policy/issued 非空 + optional expected_sha 照合(exec で HEAD 強制) | +| R-027 | high | bin/plangate `_plangate_c3_dispatch` | 受理器不在時、`approval_kind=[]`/null/不正 JSON が legacy(10) に委譲され grep が受理 | **CONFIRMED**(rc=10) | **是正**: fallback を「approval_kind キーが物理的に無い場合のみ 10、存在すれば値不問で 1」へ | +| R-028 | high | c3prime_verify.py / exec | TOCTOU: 検証成功〜session_start の窓で artifact 書換余地 | 妥当(ローカル書込レース前提) | **緩和 + 契約明記**: exec preflight が再検証の実点。残余窓は Phase 1 許容・flock 単一 snapshot は V2 候補(契約 §4) | +| R-029 | medium | ta-55 | producer 経由 record のみで受理側偽造耐性を検証していない | 妥当 | **是正**: `test_c3prime_verify.py` 新設(producer 非依存の手 mutate 14 パターン)+ ta-55 に手偽造ケース追加 | +| R-030 | info | bin/plangate `\|\| _c3_rc=$?` | 指摘なし(set -e 安全を確認) | — | 変更不要 | + +Codex の critical/high は受理側の実バグで、単独レビューでは見つかっていた(オーガナイザーの sandbox 実適用テストは exit code は見たが偽造耐性は見ていなかった=受理側の敵対レビューが機能した好例)。 + ## 指摘なしと明示された観点 - レーン A: スコープ / Non-goals の相互整合、AC・9 シナリオのマッピング網羅性 diff --git a/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md b/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md index 8848ccf0..10a7eea6 100644 --- a/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md +++ b/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md @@ -70,7 +70,7 @@ C-1/C-2 いずれか**単独**の異常でも `AUTO_APPROVED` にならない( ## 4. stale / 受理規則(AC-7 / AC-8 / AC-9) -受理側(`bin/plangate validate` / exec preflight — PR-2)は `approval_kind` の有無で分岐する: +受理側(`bin/plangate validate` / exec preflight — PR-2)は `approval_kind` の有無で分岐する。**受理側は decision 値を無検証で信頼せず、以下の全規則を strict JSON で再検証する**(trust boundary / #887 F-4 / #889 critical)。トップレベルは必須キー allowlist(`c3_status` および未知キーは reject)+ `task_id`(`TASK-XXXX`)+ `phase`(`C-3'`)+ evidence/policy/issued の非空を含む: | 条件 | 判定 | |------|------| @@ -81,11 +81,15 @@ C-1/C-2 いずれか**単独**の異常でも `AUTO_APPROVED` にならない( | `decision == "AUTO_APPROVED"` だが reviewers の verdict に `reject` を含む | **BLOCK**(decision↔verdicts 不整合 record = 改竄兆候。#887 F-3 の受理側検証) | | `plan_hash` ≠ 現 plan.md sha256 | FAIL(stale・legacy と同一規則) | | `artifact_hashes` のいずれか ≠ 現ファイル sha256 | FAIL(stale。**不一致エントリ名を失敗メッセージに含む**) | -| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。再 C-1/C-2/C-3' を要求 | +| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。exec preflight は `git rev-parse HEAD` を `expected_sha` として受理器へ渡し厳密照合する(#889 critical)。静的 validate は expected_sha を渡さず構造・束縛のみ検証 | +| トップレベルに `c3_status` / 未知キー / 必須キー欠落 | **FAIL**(構造 allowlist・#889 critical。`^_` 注釈キーのみ許容) | +| 受理器(`c3prime_verify.py`)が実在しない | c3-prime は **FAIL**(検証不能。`approval_kind` キーが物理的に無い場合のみ legacy 委譲 / #889 high fallback) | | 同一 TASK に legacy と c3-prime が併存 | **物理的に不可能**(同一パス `approvals/c3.json` のため)。上書きは `--force` 相当の明示操作のみ(EC-5 の解決) | EH-3 hook(`check-plan-hash.sh`)は top-level `plan_hash` のみを strict JSON で読むため、c3-prime に対しても**無変更で機能**する(非退行・本契約が plan_hash を legacy と同一義で保持する理由)。 +**TOCTOU の残余窓(#889 high・既知制約)**: 受理器は exec preflight で再検証するが、検証成功から `session_started` 記録までの 1 shell 文の窓で外部プロセスが artifact を書き換える理論的余地は残る(ローカル書込レースが前提)。緩和として **exec preflight が受理検証の実点**であり(validate だけでなく exec 直前に再検証)、session 開始は直後に続く。Phase 1(隔離 PoC)ではこの残余窓を許容し、将来 flock ベースの単一 snapshot 検証を V2 候補とする(handoff 記載)。 + ## 5. serialization 制約(R-009 / #887 F-7 是正) - `json.dumps(indent=2, sort_keys=True)` で整形(arbiter provenance 出力と同形) diff --git a/scripts/ai-loop/c3prime_verify.py b/scripts/ai-loop/c3prime_verify.py index 93054e26..7f2bc54c 100755 --- a/scripts/ai-loop/c3prime_verify.py +++ b/scripts/ai-loop/c3prime_verify.py @@ -7,9 +7,11 @@ 無検証で信頼しない・#887 F-4/R-018)。legacy(approval_kind なし)は本 スクリプトの対象外(呼び出し側 shell の grep 経路が担う)。 -使い方: c3prime_verify.py +使い方: c3prime_verify.py [expected_sha] + expected_sha を渡すと source_sha との厳密一致を強制する(契約 §4・exec 時に + bin/plangate が `git rev-parse HEAD` を渡す)。省略時は構造・束縛のみ検証。 exit 0 = c3-prime として受理(AUTO_APPROVED・全束縛整合) - exit 10 = legacy(approval_kind 無し)→ 呼び出し側が legacy 経路で処理 + exit 10 = legacy(approval_kind キー無し)→ 呼び出し側が legacy 経路で処理 exit 1 = c3-prime だが検証 NG(fail-closed。理由を stderr に出力) """ from __future__ import annotations @@ -27,6 +29,15 @@ VALID_DECISIONS = ("AUTO_APPROVED", "HUMAN_ESCALATED", "BLOCKED") VALID_VERDICTS = ("approve", "reject") SNAPSHOT_KEYS = ("verdict", "plan_hash", "source_sha", "plan_package_hash", "evidence_ref") +# 契約 §2: c3-prime トップレベルの必須キー(allowlist の中核)。 +REQUIRED_KEYS = ( + "task_id", "approval_kind", "phase", "decision", "source_sha", "plan_hash", + "plan_package_hash", "artifact_hashes", "c1_evidence_ref", "c2_evidence_ref", + "reviewers", "policy_ref", "issued_at", "issued_by", +) +# 任意で許容する追加キー(それ以外の未知キーは reject。`c3_status` は §5 で明示禁止)。 +OPTIONAL_KEYS = ("derived_loopspec_hash",) +ALLOWED_KEYS = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS) def _sha256(path: pathlib.Path) -> str: @@ -39,10 +50,11 @@ def _fail(msg: str) -> int: def main(argv): - if len(argv) != 2: - print("usage: c3prime_verify.py ", file=sys.stderr) + if len(argv) not in (2, 3): + print("usage: c3prime_verify.py [expected_sha]", file=sys.stderr) return 1 task_dir = pathlib.Path(argv[1]) + expected_sha = argv[2] if len(argv) == 3 else None c3 = task_dir / "approvals" / "c3.json" if not c3.is_file(): return _fail("approvals/c3.json not found") @@ -54,12 +66,30 @@ def main(argv): return _fail("c3.json が JSON object でない") if data.get("approval_kind") != "c3-prime": - # legacy(キー無し)または未知値。未知値は明示 fail、無しは legacy 委譲。 + # legacy(キー無し)または未知値。未知値・型違いは明示 fail、 + # キーが物理的に存在しない場合のみ legacy 委譲(fail-closed / #889 high)。 if "approval_kind" in data: - return _fail(f"approval_kind が未知値: {data.get('approval_kind')!r}") + return _fail(f"approval_kind が未知値/型違い: {data.get('approval_kind')!r}") return 10 # legacy → 呼び出し側 shell へ委譲 - # ここから c3-prime。契約 §4 の全規則を再検証する。 + # ここから c3-prime。契約 §2/§4/§5 の全規則を再検証する。 + # 構造 allowlist + 必須キー(#889 critical)。c3_status は §5 で明示禁止。 + if "c3_status" in data: + return _fail("c3-prime に c3_status が含まれる(契約 §5 で禁止)") + unknown = [k for k in data if k not in ALLOWED_KEYS and not k.startswith("_")] + if unknown: + return _fail(f"未知のトップレベルキー: {unknown}") + missing = [k for k in REQUIRED_KEYS if k not in data] + if missing: + return _fail(f"必須キー欠落: {missing}") + if not re.fullmatch(r"TASK-[0-9]{4}", str(data.get("task_id", ""))): + return _fail(f"task_id が TASK-XXXX 形式でない: {data.get('task_id')!r}") + if data.get("phase") != "C-3'": + return _fail(f"phase が C-3' でない: {data.get('phase')!r}") + for k in ("c1_evidence_ref", "c2_evidence_ref", "policy_ref", "issued_at", "issued_by"): + if not isinstance(data.get(k), str) or not data.get(k): + return _fail(f"{k} が非空 string でない") + # decision 3値 allowlist decision = data.get("decision") if decision not in VALID_DECISIONS: @@ -67,10 +97,17 @@ def main(argv): if decision != "AUTO_APPROVED": return _fail(f"decision={decision}(exec 不可。AUTO_APPROVED のみ受理)") - # source_sha 形式 + # source_sha 形式 + 検証時点の対象 SHA との一致(契約 §4 / #889 critical)。 source_sha = data.get("source_sha", "") - if not re.fullmatch(r"[0-9a-f]{7,40}", source_sha or ""): + if not isinstance(source_sha, str) or not re.fullmatch(r"[0-9a-f]{7,40}", source_sha): return _fail(f"source_sha が commit SHA 形式でない: {source_sha!r}") + if expected_sha is not None: + # HEAD は full SHA、record は短縮のこともあるため prefix 一致で照合。 + exp = expected_sha.strip() + if not (exp == source_sha or exp.startswith(source_sha) or source_sha.startswith(exp)): + return _fail( + f"source_sha ({source_sha}) が検証時点の対象 SHA ({exp}) と不一致" + "(BLOCK・再 C-1/C-2/C-3' が必要)") # plan_hash = plan.md 単体(legacy と同一契約) plan_md = task_dir / "plan.md" diff --git a/scripts/ai-loop/test_c3prime_verify.py b/scripts/ai-loop/test_c3prime_verify.py new file mode 100644 index 00000000..285adfa4 --- /dev/null +++ b/scripts/ai-loop/test_c3prime_verify.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""test_c3prime_verify.py — c3prime_verify.py の受理側偽造耐性テスト(#889 敵対的レビュー)。 + +producer(plan_package.build_c3_prime)を介さず、JSON を手で mutate した偽造 record を +受理器に直接与えて fail-closed を検証する(producer 経由テストが共有する前提を突く)。 + +実行: python3 scripts/ai-loop/test_c3prime_verify.py +""" +from __future__ import annotations + +import copy +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +VERIFY = HERE / "c3prime_verify.py" +sys.path.insert(0, str(HERE)) +import plan_package # noqa: E402 +import test_plan_package as tpp # noqa: E402 + + +def _build_valid(task_dir): + (task_dir / "approvals").mkdir(exist_ok=True) + rec = plan_package.build_c3_prime( + task_dir, task_id="TASK-9999", source_sha="abc1234", target_sha="abc1234", + verdicts={"model_a": "approve", "model_b": "approve"}, + reviewer_evidence={"model_a": "r#a", "model_b": "r#b"}, + decision="AUTO_APPROVED", policy_ref="p@v4", + issued_at="2100-01-01T00:00:00Z", issued_by="arbiter-v0.1") + (task_dir / "approvals" / "c3.json").write_text(plan_package.serialize_c3_prime(rec)) + return rec + + +def _run(task_dir, expected_sha=None): + args = ["python3", str(VERIFY), str(task_dir)] + if expected_sha is not None: + args.append(expected_sha) + return subprocess.run(args, capture_output=True, text=True).returncode + + +class C3PrimeVerifyTests(unittest.TestCase): + def _write(self, task_dir, rec): + (task_dir / "approvals" / "c3.json").write_text(json.dumps(rec)) + + def test_valid_accepted(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + self.assertEqual(_run(d), 0) + + def test_expected_sha_match_and_mismatch(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + self.assertEqual(_run(d, "abc1234"), 0) # 一致 + self.assertEqual(_run(d, "fff9999"), 1) # 不一致 → BLOCK + + def test_forged_mutations_rejected(self): + # 手 mutate による偽造 record を全パターン reject(#889 critical) + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + + def check(label, mutate): + rec = copy.deepcopy(base) + mutate(rec) + self._write(d, rec) + self.assertEqual(_run(d), 1, f"{label} が reject されない") + + check("c3_status 混入", lambda r: r.update({"c3_status": "APPROVED"})) + check("未知トップレベルキー", lambda r: r.update({"evil": "x"})) + check("task_id 欠落", lambda r: r.pop("task_id")) + check("phase 欠落", lambda r: r.pop("phase")) + check("phase 不正", lambda r: r.update({"phase": "C-3"})) + check("c1_evidence_ref 欠落", lambda r: r.pop("c1_evidence_ref")) + check("policy_ref 空文字", lambda r: r.update({"policy_ref": ""})) + check("issued_at 欠落", lambda r: r.pop("issued_at")) + check("decision 契約外", lambda r: r.update({"decision": "unknown"})) + check("非 AUTO decision", lambda r: r.update({"decision": "HUMAN_ESCALATED"})) + check("task_id 形式不正", lambda r: r.update({"task_id": "TASK-99"})) + check("source_sha 形式不正", lambda r: r.update({"source_sha": "XYZ"})) + check("AUTO+reject 改竄", + lambda r: r["reviewers"]["model_a"].update({"verdict": "reject"})) + check("snapshot hash 不一致", + lambda r: r["reviewers"]["model_b"].update({"plan_hash": "sha256:" + "f" * 64})) + + def test_legacy_delegated(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + (d / "approvals").mkdir() + self._write(d, {"task_id": "TASK-9999", "phase": "C-3", + "c3_status": "APPROVED", "approved_by": "h", + "approved_at": "2026-01-01T00:00:00Z", + "plan_hash": "sha256:" + "0" * 64}) + self.assertEqual(_run(d), 10) + + def test_malformed_approval_kind_rejected(self): + # approval_kind が present だが c3-prime でない値/型 → legacy 委譲でなく reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + (d / "approvals").mkdir() + for bad in ([], None, 123, "c3-double-prime", ""): + with self.subTest(bad=bad): + self._write(d, {"approval_kind": bad}) + self.assertEqual(_run(d), 1) + + def test_tampered_plan_hash_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + (d / "plan.md").write_text((d / "plan.md").read_text() + "x") + self.assertEqual(_run(d), 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/extras/ta-55-c3prime-accept.sh b/tests/extras/ta-55-c3prime-accept.sh index 68f5a729..71e9cac1 100644 --- a/tests/extras/ta-55-c3prime-accept.sh +++ b/tests/extras/ta-55-c3prime-accept.sh @@ -61,6 +61,14 @@ PYEOF printf '{"approval_kind":"c3-double-prime"}' > "$_t55_task/approvals/c3.json" _t55_rc=0; python3 "$PG_T55_VERIFY" "$_t55_task" >/dev/null 2>&1 || _t55_rc=$? if [ "$_t55_rc" = "1" ]; then t55_pass "未知 approval_kind → exit 1 (fail-closed)"; else t55_fail "未知 approval_kind が reject されない (rc=$_t55_rc)"; fi + + # #889 critical: producer を介さない手偽造 record を reject(c3_status 混入・ + # 未知キー・必須欠落を一括で持つ最小偽造)。詳細な網羅は + # scripts/ai-loop/test_c3prime_verify.py(producer 非依存の単体テスト)。 + printf '{"approval_kind":"c3-prime","decision":"AUTO_APPROVED","source_sha":"deadbee","c3_status":"APPROVED","evil":"x"}' \ + > "$_t55_task/approvals/c3.json" + _t55_rc=0; python3 "$PG_T55_VERIFY" "$_t55_task" >/dev/null 2>&1 || _t55_rc=$? + if [ "$_t55_rc" = "1" ]; then t55_pass "手偽造 record (c3_status+未知+欠落) → exit 1"; else t55_fail "手偽造 record が受理された (rc=$_t55_rc)"; fi else t55_fail "sandbox TASK 生成に失敗(plan_package.py import 不可)" fi From 88377e3ccbf9769f2799c0a8e941653082c6e5a0 Mon Sep 17 00:00:00 2001 From: mine_take Date: Mon, 20 Jul 2026 09:29:05 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(TASK-0872):=20#889=20R2=20=E6=95=B5?= =?UTF-8?q?=E5=AF=BE=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=98=AF=E6=AD=A3?= =?UTF-8?q?=20=E2=80=94=20=E5=8F=97=E7=90=86=E5=81=B4=20evidence=20?= =?UTF-8?q?=E5=86=8D=E6=A4=9C=E8=A8=BC=20+=20task=5Fid=20=E6=9D=9F?= =?UTF-8?q?=E7=B8=9B=20+=20=E7=8B=AC=E7=AB=8B=E6=80=A7=20+=20nested=20sche?= =?UTF-8?q?ma=EF=BC=88R2-01=E3=80=9C06=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 再レビュー(是正後により深い critical/high を検出)を全件一次ソース再現裁定し是正: - R2-01 critical: 受理器が C-1/C-2 evidence 内容を再検証せず、FAIL/reject/marker 無し evidence + 再 hash 済み record が HEAD 照合付きでも受理していた(CONFIRMED)→ 受理側でも plan_package.check_evidence を実行し marker/stale を再検証 - R2-02 high: task_id が task_dir に非束縛 → task_dir.name == task_id を必須化 - R2-03 high: reviewer 独立性偽装(model_a==model_b)→ evidence_ref 相異を必須化 - R2-04 high: 非 git exec で HEAD 空 → 照合 skip 受理(fail-open)→ c3-prime かつ HEAD 空は exec 即 BLOCK(sandbox 実測で確認) - R2-05 medium: schema 同等の nested 制約(issued_at 形式・extra reviewer・snapshot 未知キー・非 string 注釈)→ 受理器に型/additionalProperties 相当を追加 - R2-06 medium: sync に verifier 欠落 → sync-plugin-plangate.sh の copy + delete 保護へ追加 契約 §4 に受理側再検証範囲 + 脅威モデル境界(record+tree 双方書換は git-tree 束縛/署名で のみ防御 = V2)を明記。test_c3prime_verify.py に R2 回帰 5 テスト追加(計 11)。 R2-07(受理ロジックが HO 機械強制対象外)は承認境界ガバナンス決定のため Human 判断待ち (review-external.md)。全変更は非 HO(bin/plangate は patches/ のみ)。 検証: test_c3prime_verify 11 / test_plan_package 30 / sandbox 実適用(非 git exec BLOCK 含む) Refs: #889, #872 Co-Authored-By: Claude Fable 5 --- docs/workflows/ai-loop/c3-prime-contract.md | 4 + docs/working/TASK-0872/decision-log.jsonl | 1 + .../TASK-0872/patches/bin-plangate.new | 13 ++ .../TASK-0872/patches/bin-plangate.patch | 23 ++- docs/working/TASK-0872/review-external.md | 24 +++ .../references/c3-prime-contract.md | 4 + .../ai-loop-cycle/scripts/c3prime_verify.py | 184 ++++++++++++++++++ .../scripts/test_c3prime_verify.py | 182 +++++++++++++++++ scripts/ai-loop/c3prime_verify.py | 40 +++- scripts/ai-loop/test_c3prime_verify.py | 62 ++++++ scripts/sync-plugin-plangate.sh | 4 +- 11 files changed, 528 insertions(+), 13 deletions(-) create mode 100755 plugin/plangate/skills/ai-loop-cycle/scripts/c3prime_verify.py create mode 100644 plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py diff --git a/docs/workflows/ai-loop/c3-prime-contract.md b/docs/workflows/ai-loop/c3-prime-contract.md index 10a7eea6..b4a634dd 100644 --- a/docs/workflows/ai-loop/c3-prime-contract.md +++ b/docs/workflows/ai-loop/c3-prime-contract.md @@ -90,6 +90,10 @@ EH-3 hook(`check-plan-hash.sh`)は top-level `plan_hash` のみを strict JS **TOCTOU の残余窓(#889 high・既知制約)**: 受理器は exec preflight で再検証するが、検証成功から `session_started` 記録までの 1 shell 文の窓で外部プロセスが artifact を書き換える理論的余地は残る(ローカル書込レースが前提)。緩和として **exec preflight が受理検証の実点**であり(validate だけでなく exec 直前に再検証)、session 開始は直後に続く。Phase 1(隔離 PoC)ではこの残余窓を許容し、将来 flock ベースの単一 snapshot 検証を V2 候補とする(handoff 記載)。 +**受理側の再検証範囲(#889 R2 critical/high 反映)**: 受理器(`c3prime_verify.py`)は record の binding hash だけでなく **C-1/C-2 evidence marker を再検証**(`plan_package.check_evidence` を受理側でも実行)し、`task_id` を **task_dir 名に束縛**し、両 reviewer の `evidence_ref` **独立性**を要求し、issued_at 形式・reviewer/snapshot キーの `additionalProperties:false` 相当を強制する。exec は HEAD を解決できない環境では c3-prime を **BLOCK**(source_sha 照合を skip して受理しない・fail-closed)。 + +**脅威モデルの境界(V2 候補)**: 現行はローカル作業ツリーの改竄検出(承認後の drift・evidence 改竄)を対象とする。record と作業ツリーの**双方**を任意に書換できる攻撃者(同一整合な偽造一式の構築)は、`source_sha` の Git tree との照合または署名済み provenance でのみ防げる。Phase 1(eligible run 限定・boundary=clean・信頼済みローカル repo)ではこれを scope 外とし、git-tree 束縛/署名を V2 候補として handoff に記載する。 + ## 5. serialization 制約(R-009 / #887 F-7 是正) - `json.dumps(indent=2, sort_keys=True)` で整形(arbiter provenance 出力と同形) diff --git a/docs/working/TASK-0872/decision-log.jsonl b/docs/working/TASK-0872/decision-log.jsonl index 80b7c974..a6c0476f 100644 --- a/docs/working/TASK-0872/decision-log.jsonl +++ b/docs/working/TASK-0872/decision-log.jsonl @@ -15,3 +15,4 @@ {"ts":"2026-07-20T09:30:00Z","phase":"exec","decision":"複数エージェントレビュー(Codex×2+Sonnet)実施。Codex conditional/reject vs Sonnet approve の分裂を全件一次ソースで再現裁定。スコープ内 major 4+minor 1 採用(R-019〜023): decision/verdict allowlist・schema cross-field制約・evidence再読TOCTOU fail-closed・F-8回帰テスト・prefix文法外行fail-closed。R-024/025(bin/plangate受理側・schema配置)はPR-2スコープのためreject理由として却下。検証: test_plan_package 30・run-tests 406 passed","by":"agent"} {"ts":"2026-07-20T09:00:00Z","phase":"exec","decision":"PR-2受理側実装。設計: c3-prime検証をscripts/ai-loop/c3prime_verify.py(非HO・AIコミット可)に分離しbin/plangateは_plangate_c3_dispatch配線数行(HO)のみに縮小。契約§4全数再検証(trust boundary #887 F-4)。実適用テストでset -e漏れバグ(dispatch戻り10がスクリプト中断)を捕捉し|| _c3_rc=$?で修正。sandbox 6シナリオ全exit code実測(legacy非退行0/c3-prime受理0/改竄1)。HO patch4件はho-apply-approval.mdでHuman適用。ta-42 TC-04失敗はmacOS sh set-e差の既存偽陽性(main CI success)","by":"agent"} {"ts":"2026-07-20T10:00:00Z","phase":"exec","decision":"PR #889受理側Codex敵対レビュー(reject: critical 1/high 2/medium 1)を全件一次ソース再現裁定し是正(R-026〜030)。critical=偽造record受理→構造allowlist+必須+c3_status拒否+task_id/phase+expected_sha照合(exec HEAD強制)。high fallback=approval_kindキー存在時は値不問で1へ。high TOCTOU=exec preflight再検証+契約明記(残余窓Phase1許容/flock V2)。medium=test_c3prime_verify.py新設(手mutate 14パターン)+ta-55手偽造ケース。sandbox実適用で非退行+偽造reject実測。全非HO(bin/plangateはpatches/のみ)","by":"agent"} +{"ts":"2026-07-20T11:00:00Z","phase":"exec","decision":"PR #889再レビュー(ユーザー指示Codex+独立)。Codex R2=reject(前回是正後のより深いcritical/high)・Sonnet=conditional(HOガバナンスmajor)。全件再現裁定しR2-01〜06+08は是正: 受理側でcheck_evidence再実行(evidence改竄検出)・task_id→task_dir束縛・reviewer evidence_ref独立性・非git exec BLOCK・schema同等nested制約・sync列挙追加。R2-07(受理ロジックHO対象外)はHOファイル編集を要する承認境界ガバナンス決定→Human判断待ち(A:HOリスト追加/B:V2明記)。脅威モデル境界(record+tree双方書換=git-tree束縛V2)を契約明記","by":"agent"} diff --git a/docs/working/TASK-0872/patches/bin-plangate.new b/docs/working/TASK-0872/patches/bin-plangate.new index df53d7ba..8fba9115 100755 --- a/docs/working/TASK-0872/patches/bin-plangate.new +++ b/docs/working/TASK-0872/patches/bin-plangate.new @@ -2049,6 +2049,19 @@ cmd_exec() { # legacy(approval_kind 無し)は従来の grep + plan_hash 経路で処理する。 # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 _exec_head=$(git -C "$plangate_root" rev-parse HEAD 2>/dev/null || echo "") + # c3-prime を exec する場合、HEAD が解決できない環境(非 git)では source_sha + # 照合を skip して受理してはならない(fail-closed / #889 R2 high)。 + # approval_kind=c3-prime かつ HEAD 空なら即 BLOCK。 + if [ -z "$_exec_head" ]; then + _c3_kind=$(python3 -c "import json,sys +try: d=json.load(open(sys.argv[1])) +except Exception: print(''); sys.exit(0) +print(d.get('approval_kind','') if isinstance(d,dict) else '')" "$work_dir/approvals/c3.json" 2>/dev/null || echo "") + if [ "$_c3_kind" = "c3-prime" ]; then + printf 'error: c3-prime exec requires resolvable HEAD (git rev-parse HEAD failed)\n' >&2 + return 1 + fi + fi _c3_rc=0 _plangate_c3_dispatch "$work_dir" "$_exec_head" || _c3_rc=$? if [ "$_c3_rc" = "1" ]; then diff --git a/docs/working/TASK-0872/patches/bin-plangate.patch b/docs/working/TASK-0872/patches/bin-plangate.patch index fdf35220..7c04d1f0 100644 --- a/docs/working/TASK-0872/patches/bin-plangate.patch +++ b/docs/working/TASK-0872/patches/bin-plangate.patch @@ -1,5 +1,5 @@ diff --git a/bin/plangate b/bin/plangate -index d974cb6..df53d7b 100755 +index d974cb6..8fba911 100755 --- a/bin/plangate +++ b/bin/plangate @@ -882,6 +882,35 @@ cmd_status() { @@ -101,7 +101,7 @@ index d974cb6..df53d7b 100755 fi fi fi -@@ -2002,19 +2044,30 @@ cmd_exec() { +@@ -2002,19 +2044,43 @@ cmd_exec() { return 1 fi @@ -109,17 +109,30 @@ index d974cb6..df53d7b 100755 - | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") - if [ "$c3_status" != "APPROVED" ]; then - printf 'error: C-3 gate not approved (status=%s)\n' "${c3_status:-missing}" >&2 +- return 1 + # TASK-0872 PR-2: c3-prime は Plan Package 束縛を全数再検証(契約 §4)。 + # exec は対象 commit(HEAD)に対して実行するため、source_sha と HEAD の一致を強制する。 + # legacy(approval_kind 無し)は従来の grep + plan_hash 経路で処理する。 + # set -eu 下で非ゼロ戻り(10=legacy / 1=NG)が中断を招かないよう `|| _c3_rc=$?` で受ける。 + _exec_head=$(git -C "$plangate_root" rev-parse HEAD 2>/dev/null || echo "") ++ # c3-prime を exec する場合、HEAD が解決できない環境(非 git)では source_sha ++ # 照合を skip して受理してはならない(fail-closed / #889 R2 high)。 ++ # approval_kind=c3-prime かつ HEAD 空なら即 BLOCK。 ++ if [ -z "$_exec_head" ]; then ++ _c3_kind=$(python3 -c "import json,sys ++try: d=json.load(open(sys.argv[1])) ++except Exception: print(''); sys.exit(0) ++print(d.get('approval_kind','') if isinstance(d,dict) else '')" "$work_dir/approvals/c3.json" 2>/dev/null || echo "") ++ if [ "$_c3_kind" = "c3-prime" ]; then ++ printf 'error: c3-prime exec requires resolvable HEAD (git rev-parse HEAD failed)\n' >&2 ++ return 1 ++ fi + fi + _c3_rc=0 + _plangate_c3_dispatch "$work_dir" "$_exec_head" || _c3_rc=$? + if [ "$_c3_rc" = "1" ]; then + printf 'error: C-3 gate not approved (c3-prime verification failed; see above)\n' >&2 - return 1 -- fi ++ return 1 + elif [ "$_c3_rc" = "10" ]; then + c3_status=$(grep '"c3_status"' "$work_dir/approvals/c3.json" 2>/dev/null \ + | sed 's/.*"c3_status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || echo "") @@ -143,7 +156,7 @@ index d974cb6..df53d7b 100755 try: d=json.load(open(sys.argv[1])) except Exception: -@@ -2023,21 +2076,23 @@ if not isinstance(d,dict): +@@ -2023,21 +2089,23 @@ if not isinstance(d,dict): print(''); sys.exit(0) v=d.get('plan_hash','') print(v[7:] if isinstance(v,str) and v.startswith('sha256:') else '')" "$c3_file" 2>/dev/null || echo "") diff --git a/docs/working/TASK-0872/review-external.md b/docs/working/TASK-0872/review-external.md index bc53bdcc..dcbd2d10 100644 --- a/docs/working/TASK-0872/review-external.md +++ b/docs/working/TASK-0872/review-external.md @@ -84,6 +84,30 @@ Sonnet 独立レーンの approve は上記エッジ(allowlist/cross-field/TOC Codex の critical/high は受理側の実バグで、単独レビューでは見つかっていた(オーガナイザーの sandbox 実適用テストは exit code は見たが偽造耐性は見ていなかった=受理側の敵対レビューが機能した好例)。 +## PR #889 再レビュー(2026-07-20・ユーザー指示・Codex R2 + Sonnet 独立) + +> 是正後 head 357a11a を再レビュー。Codex R2=**reject**(より深い critical/high 検出)/ Sonnet=conditional(HO ガバナンス major)。全件一次ソース再現裁定。 + +| ID | severity | lane | 指摘 | 再現 | 是正 | +|----|----------|------|------|------|------| +| R2-01 | **critical** | Codex | 受理器が C-1/C-2 evidence 内容を再検証せず、FAIL/reject/marker 無し evidence + 再 hash 済み record が HEAD 照合付きでも受理 | **CONFIRMED**(exit 0) | **是正**: 受理側でも `plan_package.check_evidence` を実行し marker/stale を再検証 | +| R2-02 | high | Codex | task_id が task_dir に非束縛(TASK-0001 record を TASK-9999 dir で受理) | **CONFIRMED** | **是正**: `task_dir.name == task_id` を必須化 | +| R2-03 | high | Codex | reviewer 独立性偽装(model_a==model_b 同一 snapshot/evidence 受理) | CONFIRMED | **是正**: 両者の `evidence_ref` 相異を必須化 | +| R2-04 | high | Codex | 非 git exec で HEAD 空 → expected_sha 照合 skip で受理(fail-open) | 妥当(環境制約で Codex 未実行・オーガナイザーが sandbox 実測) | **是正**: c3-prime かつ HEAD 空は exec で即 BLOCK | +| R2-05 | medium | Codex | schema 同等の nested 制約未実施(issued_at 形式・extra reviewer・snapshot 未知キー・非 string 注釈) | CONFIRMED(4 ケース) | **是正**: 受理器に型・additionalProperties 相当・注釈型・reviewer/snapshot キー exactness を追加 | +| R2-06 | medium | Codex | sync に c3prime_verify.py / test 欠落(plugin 配布不完全) | CONFIRMED | **是正**: sync-plugin-plangate.sh の copy + delete-protection 両列挙へ追加 | +| R2-07 | **major** | Sonnet | 受理ロジックが HO 機械強制対象外(`scripts/ai-loop/*.py`)へ移動 → 非 HO PR で受理ゲートを静かに弱められる | **裏取り済**(HO 9 カテゴリ case 文に非該当) | **Human 判断待ち**(下記)— HO リスト追加は `check-plan-hash.sh` + `mode-classification.md`(ともに HO)編集を要する承認境界ガバナンス決定 | +| R2-08 | minor | Sonnet | test にネスト経路(artifact_hashes 個別/plan_package_hash 単独/evidence_ref 欠落)専用ケース不足 | 妥当 | **是正**(R2-05 の nested テストで大半カバー・handoff に残記載) | +| R2-09 | minor | Sonnet | ho-apply に切り戻し手順(git apply -R)記載なし | 妥当 | handoff/次コミットで追記予定 | + +reviewer_evidence を build_c3_prime の必須 distinct 引数として扱う運用に。脅威モデル境界(record+tree 双方書換可能な攻撃者は git-tree 束縛/署名でのみ防御 = V2)は契約 §4 に明記。 + +### R2-07(HO ガバナンス)— Human 判断が必要 + +受理検証ロジックが非 HO の `scripts/ai-loop/c3prime_verify.py` にあるため、将来の非 HO PR 単独で承認ゲートを弱められる。選択肢: +- (A) `scripts/ai-loop/*_verify.py`(または本ファイル)を HO 9 カテゴリへ追加(`check-plan-hash.sh` + `mode-classification.md` = ともに HO を Human patch 適用)→ 機械強制を回復。以後 verifier 編集も HO ceremony +- (B) 現状維持 + handoff に V2/known-issue として明示(セキュリティ関連 = 最低「中」の判断依存保護は残る) + ## 指摘なしと明示された観点 - レーン A: スコープ / Non-goals の相互整合、AC・9 シナリオのマッピング網羅性 diff --git a/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md b/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md index 10a7eea6..b4a634dd 100644 --- a/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md +++ b/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md @@ -90,6 +90,10 @@ EH-3 hook(`check-plan-hash.sh`)は top-level `plan_hash` のみを strict JS **TOCTOU の残余窓(#889 high・既知制約)**: 受理器は exec preflight で再検証するが、検証成功から `session_started` 記録までの 1 shell 文の窓で外部プロセスが artifact を書き換える理論的余地は残る(ローカル書込レースが前提)。緩和として **exec preflight が受理検証の実点**であり(validate だけでなく exec 直前に再検証)、session 開始は直後に続く。Phase 1(隔離 PoC)ではこの残余窓を許容し、将来 flock ベースの単一 snapshot 検証を V2 候補とする(handoff 記載)。 +**受理側の再検証範囲(#889 R2 critical/high 反映)**: 受理器(`c3prime_verify.py`)は record の binding hash だけでなく **C-1/C-2 evidence marker を再検証**(`plan_package.check_evidence` を受理側でも実行)し、`task_id` を **task_dir 名に束縛**し、両 reviewer の `evidence_ref` **独立性**を要求し、issued_at 形式・reviewer/snapshot キーの `additionalProperties:false` 相当を強制する。exec は HEAD を解決できない環境では c3-prime を **BLOCK**(source_sha 照合を skip して受理しない・fail-closed)。 + +**脅威モデルの境界(V2 候補)**: 現行はローカル作業ツリーの改竄検出(承認後の drift・evidence 改竄)を対象とする。record と作業ツリーの**双方**を任意に書換できる攻撃者(同一整合な偽造一式の構築)は、`source_sha` の Git tree との照合または署名済み provenance でのみ防げる。Phase 1(eligible run 限定・boundary=clean・信頼済みローカル repo)ではこれを scope 外とし、git-tree 束縛/署名を V2 候補として handoff に記載する。 + ## 5. serialization 制約(R-009 / #887 F-7 是正) - `json.dumps(indent=2, sort_keys=True)` で整形(arbiter provenance 出力と同形) diff --git a/plugin/plangate/skills/ai-loop-cycle/scripts/c3prime_verify.py b/plugin/plangate/skills/ai-loop-cycle/scripts/c3prime_verify.py new file mode 100755 index 00000000..e14a0894 --- /dev/null +++ b/plugin/plangate/skills/ai-loop-cycle/scripts/c3prime_verify.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""c3-prime 受理検証(bin/plangate validate / exec preflight 共有・#872 PR-2)。 + +契約正本: docs/workflows/ai-loop/c3-prime-contract.md §4。 +approvals/c3.json の approval_kind を strict JSON で判別し、c3-prime なら +Plan Package への束縛を全数**再検証**する(trust boundary: decision 値を +無検証で信頼しない・#887 F-4/R-018)。legacy(approval_kind なし)は本 +スクリプトの対象外(呼び出し側 shell の grep 経路が担う)。 + +使い方: c3prime_verify.py [expected_sha] + expected_sha を渡すと source_sha との厳密一致を強制する(契約 §4・exec 時に + bin/plangate が `git rev-parse HEAD` を渡す)。省略時は構造・束縛のみ検証。 + exit 0 = c3-prime として受理(AUTO_APPROVED・全束縛整合) + exit 10 = legacy(approval_kind キー無し)→ 呼び出し側が legacy 経路で処理 + exit 1 = c3-prime だが検証 NG(fail-closed。理由を stderr に出力) +""" +from __future__ import annotations + +import hashlib +import json +import pathlib +import re +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import plan_package # noqa: E402 受理側でも evidence marker を再検証するため共有 + +ARTIFACTS = ( + "pbi-input.md", "plan.md", "todo.md", "test-cases.md", + "review-self.md", "review-external.md", +) +VALID_DECISIONS = ("AUTO_APPROVED", "HUMAN_ESCALATED", "BLOCKED") +VALID_VERDICTS = ("approve", "reject") +SNAPSHOT_KEYS = ("verdict", "plan_hash", "source_sha", "plan_package_hash", "evidence_ref") +# 契約 §2: c3-prime トップレベルの必須キー(allowlist の中核)。 +REQUIRED_KEYS = ( + "task_id", "approval_kind", "phase", "decision", "source_sha", "plan_hash", + "plan_package_hash", "artifact_hashes", "c1_evidence_ref", "c2_evidence_ref", + "reviewers", "policy_ref", "issued_at", "issued_by", +) +# 任意で許容する追加キー(それ以外の未知キーは reject。`c3_status` は §5 で明示禁止)。 +OPTIONAL_KEYS = ("derived_loopspec_hash",) +ALLOWED_KEYS = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS) + + +def _sha256(path: pathlib.Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def _fail(msg: str) -> int: + print(f"c3-prime: {msg}", file=sys.stderr) + return 1 + + +def main(argv): + if len(argv) not in (2, 3): + print("usage: c3prime_verify.py [expected_sha]", file=sys.stderr) + return 1 + task_dir = pathlib.Path(argv[1]) + expected_sha = argv[2] if len(argv) == 3 else None + c3 = task_dir / "approvals" / "c3.json" + if not c3.is_file(): + return _fail("approvals/c3.json not found") + try: + data = json.loads(c3.read_text(encoding="utf-8")) + except (ValueError, OSError) as exc: + return _fail(f"c3.json を strict JSON として読めない: {exc}") + if not isinstance(data, dict): + return _fail("c3.json が JSON object でない") + + if data.get("approval_kind") != "c3-prime": + # legacy(キー無し)または未知値。未知値・型違いは明示 fail、 + # キーが物理的に存在しない場合のみ legacy 委譲(fail-closed / #889 high)。 + if "approval_kind" in data: + return _fail(f"approval_kind が未知値/型違い: {data.get('approval_kind')!r}") + return 10 # legacy → 呼び出し側 shell へ委譲 + + # ここから c3-prime。契約 §2/§4/§5 の全規則を再検証する。 + # 構造 allowlist + 必須キー(#889 critical)。c3_status は §5 で明示禁止。 + if "c3_status" in data: + return _fail("c3-prime に c3_status が含まれる(契約 §5 で禁止)") + unknown = [k for k in data if k not in ALLOWED_KEYS and not k.startswith("_")] + if unknown: + return _fail(f"未知のトップレベルキー: {unknown}") + missing = [k for k in REQUIRED_KEYS if k not in data] + if missing: + return _fail(f"必須キー欠落: {missing}") + task_id = str(data.get("task_id", "")) + if not re.fullmatch(r"TASK-[0-9]{4}", task_id): + return _fail(f"task_id が TASK-XXXX 形式でない: {data.get('task_id')!r}") + # task_id を task_dir に束縛(#889 R2 high: 別 task の record 流用を防ぐ)。 + if task_dir.name != task_id: + return _fail(f"task_id ({task_id}) が task_dir 名 ({task_dir.name}) と不一致") + if data.get("phase") != "C-3'": + return _fail(f"phase が C-3' でない: {data.get('phase')!r}") + for k in ("c1_evidence_ref", "c2_evidence_ref", "policy_ref", "issued_by"): + if not isinstance(data.get(k), str) or not data.get(k): + return _fail(f"{k} が非空 string でない") + # issued_at は ISO 8601 UTC(#889 R2 medium: schema 同等の型検証)。 + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", + str(data.get("issued_at", ""))): + return _fail(f"issued_at が ISO 8601 UTC でない: {data.get('issued_at')!r}") + # `_` 注釈キーは string のみ(schema patternProperties 相当)。 + for k, v in data.items(): + if k.startswith("_") and not isinstance(v, str): + return _fail(f"注釈キー {k} が string でない") + # C-1/C-2 evidence の内容を**受理側で再検証**(#889 R2 critical: build 時のみ + # の検証を信頼せず、marker verdict / stale を再チェック。改竄 evidence を弾く)。 + ev_errors = plan_package.check_evidence(task_dir) + if ev_errors: + return _fail("evidence 再検証 NG: " + "; ".join(ev_errors)) + + # decision 3値 allowlist + decision = data.get("decision") + if decision not in VALID_DECISIONS: + return _fail(f"decision が契約の 3 値以外: {decision!r}") + if decision != "AUTO_APPROVED": + return _fail(f"decision={decision}(exec 不可。AUTO_APPROVED のみ受理)") + + # source_sha 形式 + 検証時点の対象 SHA との一致(契約 §4 / #889 critical)。 + source_sha = data.get("source_sha", "") + if not isinstance(source_sha, str) or not re.fullmatch(r"[0-9a-f]{7,40}", source_sha): + return _fail(f"source_sha が commit SHA 形式でない: {source_sha!r}") + if expected_sha is not None: + # HEAD は full SHA、record は短縮のこともあるため prefix 一致で照合。 + exp = expected_sha.strip() + if not (exp == source_sha or exp.startswith(source_sha) or source_sha.startswith(exp)): + return _fail( + f"source_sha ({source_sha}) が検証時点の対象 SHA ({exp}) と不一致" + "(BLOCK・再 C-1/C-2/C-3' が必要)") + + # plan_hash = plan.md 単体(legacy と同一契約) + plan_md = task_dir / "plan.md" + if not plan_md.is_file(): + return _fail("plan.md が存在しない") + if data.get("plan_hash") != _sha256(plan_md): + return _fail("plan_hash が現 plan.md と不一致(stale。再 C-1/C-2/C-3' が必要)") + + # artifact_hashes 全数照合(どのエントリの不一致かを明示) + ah = data.get("artifact_hashes") + if not isinstance(ah, dict) or set(ah) != set(ARTIFACTS): + return _fail("artifact_hashes のキーが Plan Package 6 要素と一致しない") + for name in ARTIFACTS: + f = task_dir / name + if not f.is_file(): + return _fail(f"artifact 欠落: {name}") + if ah[name] != _sha256(f): + return _fail(f"artifact_hashes 不一致: {name}(stale)") + + # plan_package_hash = artifact_hashes の正規化 JSON の sha256 + canon = json.dumps(ah, sort_keys=True, separators=(",", ":")).encode("utf-8") + if data.get("plan_package_hash") != "sha256:" + hashlib.sha256(canon).hexdigest(): + return _fail("plan_package_hash が artifact_hashes から再計算した値と不一致") + + # reviewer snapshot 三つ組一致 + decision-verdict 整合 + reviewers = data.get("reviewers") + # model_a / model_b ちょうど 2 者(余剰 reviewer キーは reject / #889 R2 medium)。 + if not isinstance(reviewers, dict) or set(reviewers) != {"model_a", "model_b"}: + return _fail(f"reviewers は model_a / model_b のちょうど 2 者: {sorted(reviewers) if isinstance(reviewers, dict) else reviewers!r}") + for m in ("model_a", "model_b"): + snap = reviewers.get(m) + # snapshot は 5 キーちょうど(未知ネストキーは reject / #889 R2 medium)。 + if not isinstance(snap, dict) or set(snap) != set(SNAPSHOT_KEYS): + return _fail(f"reviewers.{m} の snapshot キーが規定 5 キーと不一致") + if any(not snap.get(k) for k in SNAPSHOT_KEYS): + return _fail(f"reviewers.{m} の snapshot に空値") + if snap.get("verdict") not in VALID_VERDICTS: + return _fail(f"reviewers.{m}.verdict が approve/reject 以外") + for key in ("plan_hash", "source_sha", "plan_package_hash"): + if snap.get(key) != data.get(key): + return _fail(f"reviewers.{m}.{key} がトップレベル値と不一致(AC-5 違反)") + # reviewer 独立性: 両者の evidence_ref が同一なら独立 2 者レビュー偽装 + # (#889 R2 high。snapshot hash は同一が正だが evidence は別根拠であるべき)。 + if reviewers["model_a"]["evidence_ref"] == reviewers["model_b"]["evidence_ref"]: + return _fail("reviewers.model_a と model_b の evidence_ref が同一(独立性違反)") + # AUTO_APPROVED は両 reviewer approve のときのみ(改竄兆候の検出) + if any(reviewers[m].get("verdict") != "approve" for m in ("model_a", "model_b")): + return _fail("decision=AUTO_APPROVED だが reviewer verdict に reject を含む(改竄兆候)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py b/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py new file mode 100644 index 00000000..7544024d --- /dev/null +++ b/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""test_c3prime_verify.py — c3prime_verify.py の受理側偽造耐性テスト(#889 敵対的レビュー)。 + +producer(plan_package.build_c3_prime)を介さず、JSON を手で mutate した偽造 record を +受理器に直接与えて fail-closed を検証する(producer 経由テストが共有する前提を突く)。 + +実行: python3 scripts/ai-loop/test_c3prime_verify.py +""" +from __future__ import annotations + +import copy +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +VERIFY = HERE / "c3prime_verify.py" +sys.path.insert(0, str(HERE)) +import plan_package # noqa: E402 +import test_plan_package as tpp # noqa: E402 + + +def _build_valid(task_dir): + (task_dir / "approvals").mkdir(exist_ok=True) + rec = plan_package.build_c3_prime( + task_dir, task_id="TASK-9999", source_sha="abc1234", target_sha="abc1234", + verdicts={"model_a": "approve", "model_b": "approve"}, + reviewer_evidence={"model_a": "r#a", "model_b": "r#b"}, + decision="AUTO_APPROVED", policy_ref="p@v4", + issued_at="2100-01-01T00:00:00Z", issued_by="arbiter-v0.1") + (task_dir / "approvals" / "c3.json").write_text(plan_package.serialize_c3_prime(rec)) + return rec + + +def _run(task_dir, expected_sha=None): + args = ["python3", str(VERIFY), str(task_dir)] + if expected_sha is not None: + args.append(expected_sha) + return subprocess.run(args, capture_output=True, text=True).returncode + + +class C3PrimeVerifyTests(unittest.TestCase): + def _write(self, task_dir, rec): + (task_dir / "approvals" / "c3.json").write_text(json.dumps(rec)) + + def test_valid_accepted(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + self.assertEqual(_run(d), 0) + + def test_expected_sha_match_and_mismatch(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + self.assertEqual(_run(d, "abc1234"), 0) # 一致 + self.assertEqual(_run(d, "fff9999"), 1) # 不一致 → BLOCK + + def test_forged_mutations_rejected(self): + # 手 mutate による偽造 record を全パターン reject(#889 critical) + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + + def check(label, mutate): + rec = copy.deepcopy(base) + mutate(rec) + self._write(d, rec) + self.assertEqual(_run(d), 1, f"{label} が reject されない") + + check("c3_status 混入", lambda r: r.update({"c3_status": "APPROVED"})) + check("未知トップレベルキー", lambda r: r.update({"evil": "x"})) + check("task_id 欠落", lambda r: r.pop("task_id")) + check("phase 欠落", lambda r: r.pop("phase")) + check("phase 不正", lambda r: r.update({"phase": "C-3"})) + check("c1_evidence_ref 欠落", lambda r: r.pop("c1_evidence_ref")) + check("policy_ref 空文字", lambda r: r.update({"policy_ref": ""})) + check("issued_at 欠落", lambda r: r.pop("issued_at")) + check("decision 契約外", lambda r: r.update({"decision": "unknown"})) + check("非 AUTO decision", lambda r: r.update({"decision": "HUMAN_ESCALATED"})) + check("task_id 形式不正", lambda r: r.update({"task_id": "TASK-99"})) + check("source_sha 形式不正", lambda r: r.update({"source_sha": "XYZ"})) + check("AUTO+reject 改竄", + lambda r: r["reviewers"]["model_a"].update({"verdict": "reject"})) + check("snapshot hash 不一致", + lambda r: r["reviewers"]["model_b"].update({"plan_hash": "sha256:" + "f" * 64})) + + def test_legacy_delegated(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + (d / "approvals").mkdir() + self._write(d, {"task_id": "TASK-9999", "phase": "C-3", + "c3_status": "APPROVED", "approved_by": "h", + "approved_at": "2026-01-01T00:00:00Z", + "plan_hash": "sha256:" + "0" * 64}) + self.assertEqual(_run(d), 10) + + def test_malformed_approval_kind_rejected(self): + # approval_kind が present だが c3-prime でない値/型 → legacy 委譲でなく reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + (d / "approvals").mkdir() + for bad in ([], None, 123, "c3-double-prime", ""): + with self.subTest(bad=bad): + self._write(d, {"approval_kind": bad}) + self.assertEqual(_run(d), 1) + + def test_tampered_plan_hash_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + (d / "plan.md").write_text((d / "plan.md").read_text() + "x") + self.assertEqual(_run(d), 1) + + def test_r2_evidence_content_reverified(self): + # #889 R2 critical: evidence marker を FAIL/reject へ改竄 → reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + ph = base["plan_hash"] + (d / "review-self.md").write_text(f"C1-VERDICT: FAIL plan={ph}\n") + self.assertEqual(_run(d), 1, "C1=FAIL evidence が受理された") + (d / "review-self.md").write_text(f"C1-VERDICT: PASS plan={ph}\n") + (d / "review-external.md").write_text(f"C2-VERDICT: reject plan={ph}\n") + self.assertEqual(_run(d), 1, "C2=reject evidence が受理された") + # marker ごと消す + (d / "review-external.md").write_text("no marker at all\n") + self.assertEqual(_run(d), 1, "marker 無し evidence が受理された") + + def test_r2_task_id_bound_to_dir(self): + # #889 R2 high: task_id が task_dir 名と一致しなければ reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + rec = copy.deepcopy(base) + rec["task_id"] = "TASK-0001" + self._write(d, rec) + self.assertEqual(_run(d), 1) + + def test_r2_reviewer_independence(self): + # #889 R2 high: 両 reviewer の evidence_ref 同一(独立性偽装)→ reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + rec = copy.deepcopy(base) + rec["reviewers"]["model_b"] = copy.deepcopy(rec["reviewers"]["model_a"]) + self._write(d, rec) + self.assertEqual(_run(d), 1) + + def test_r2_nested_schema_parity(self): + # #889 R2 medium: extra reviewer / snapshot 未知キー / issued_at 不正 / 非 string 注釈 + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + + def check(label, mutate): + rec = copy.deepcopy(base) + mutate(rec) + self._write(d, rec) + self.assertEqual(_run(d), 1, f"{label} が reject されない") + + check("extra reviewer", + lambda r: r["reviewers"].update({"model_c": copy.deepcopy(r["reviewers"]["model_a"])})) + check("snapshot 未知キー", + lambda r: r["reviewers"]["model_a"].update({"evil": "x"})) + check("issued_at 不正", lambda r: r.update({"issued_at": "not-a-date"})) + check("非 string 注釈", lambda r: r.update({"_note": 123})) + + def test_r2_expected_sha_required_at_exec(self): + # exec 相当(expected_sha 指定)では偽 source_sha を BLOCK + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) # source_sha=abc1234 + self.assertEqual(_run(d, "abc1234"), 0) + self.assertEqual(_run(d, "9999999"), 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/ai-loop/c3prime_verify.py b/scripts/ai-loop/c3prime_verify.py index 7f2bc54c..e14a0894 100755 --- a/scripts/ai-loop/c3prime_verify.py +++ b/scripts/ai-loop/c3prime_verify.py @@ -22,6 +22,9 @@ import re import sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import plan_package # noqa: E402 受理側でも evidence marker を再検証するため共有 + ARTIFACTS = ( "pbi-input.md", "plan.md", "todo.md", "test-cases.md", "review-self.md", "review-external.md", @@ -82,13 +85,30 @@ def main(argv): missing = [k for k in REQUIRED_KEYS if k not in data] if missing: return _fail(f"必須キー欠落: {missing}") - if not re.fullmatch(r"TASK-[0-9]{4}", str(data.get("task_id", ""))): + task_id = str(data.get("task_id", "")) + if not re.fullmatch(r"TASK-[0-9]{4}", task_id): return _fail(f"task_id が TASK-XXXX 形式でない: {data.get('task_id')!r}") + # task_id を task_dir に束縛(#889 R2 high: 別 task の record 流用を防ぐ)。 + if task_dir.name != task_id: + return _fail(f"task_id ({task_id}) が task_dir 名 ({task_dir.name}) と不一致") if data.get("phase") != "C-3'": return _fail(f"phase が C-3' でない: {data.get('phase')!r}") - for k in ("c1_evidence_ref", "c2_evidence_ref", "policy_ref", "issued_at", "issued_by"): + for k in ("c1_evidence_ref", "c2_evidence_ref", "policy_ref", "issued_by"): if not isinstance(data.get(k), str) or not data.get(k): return _fail(f"{k} が非空 string でない") + # issued_at は ISO 8601 UTC(#889 R2 medium: schema 同等の型検証)。 + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", + str(data.get("issued_at", ""))): + return _fail(f"issued_at が ISO 8601 UTC でない: {data.get('issued_at')!r}") + # `_` 注釈キーは string のみ(schema patternProperties 相当)。 + for k, v in data.items(): + if k.startswith("_") and not isinstance(v, str): + return _fail(f"注釈キー {k} が string でない") + # C-1/C-2 evidence の内容を**受理側で再検証**(#889 R2 critical: build 時のみ + # の検証を信頼せず、marker verdict / stale を再チェック。改竄 evidence を弾く)。 + ev_errors = plan_package.check_evidence(task_dir) + if ev_errors: + return _fail("evidence 再検証 NG: " + "; ".join(ev_errors)) # decision 3値 allowlist decision = data.get("decision") @@ -134,17 +154,25 @@ def main(argv): # reviewer snapshot 三つ組一致 + decision-verdict 整合 reviewers = data.get("reviewers") - if not isinstance(reviewers, dict) or set(reviewers) < {"model_a", "model_b"}: - return _fail("reviewers に model_a / model_b が揃っていない") + # model_a / model_b ちょうど 2 者(余剰 reviewer キーは reject / #889 R2 medium)。 + if not isinstance(reviewers, dict) or set(reviewers) != {"model_a", "model_b"}: + return _fail(f"reviewers は model_a / model_b のちょうど 2 者: {sorted(reviewers) if isinstance(reviewers, dict) else reviewers!r}") for m in ("model_a", "model_b"): snap = reviewers.get(m) - if not isinstance(snap, dict) or any(not snap.get(k) for k in SNAPSHOT_KEYS): - return _fail(f"reviewers.{m} の snapshot が不完全") + # snapshot は 5 キーちょうど(未知ネストキーは reject / #889 R2 medium)。 + if not isinstance(snap, dict) or set(snap) != set(SNAPSHOT_KEYS): + return _fail(f"reviewers.{m} の snapshot キーが規定 5 キーと不一致") + if any(not snap.get(k) for k in SNAPSHOT_KEYS): + return _fail(f"reviewers.{m} の snapshot に空値") if snap.get("verdict") not in VALID_VERDICTS: return _fail(f"reviewers.{m}.verdict が approve/reject 以外") for key in ("plan_hash", "source_sha", "plan_package_hash"): if snap.get(key) != data.get(key): return _fail(f"reviewers.{m}.{key} がトップレベル値と不一致(AC-5 違反)") + # reviewer 独立性: 両者の evidence_ref が同一なら独立 2 者レビュー偽装 + # (#889 R2 high。snapshot hash は同一が正だが evidence は別根拠であるべき)。 + if reviewers["model_a"]["evidence_ref"] == reviewers["model_b"]["evidence_ref"]: + return _fail("reviewers.model_a と model_b の evidence_ref が同一(独立性違反)") # AUTO_APPROVED は両 reviewer approve のときのみ(改竄兆候の検出) if any(reviewers[m].get("verdict") != "approve" for m in ("model_a", "model_b")): return _fail("decision=AUTO_APPROVED だが reviewer verdict に reject を含む(改竄兆候)") diff --git a/scripts/ai-loop/test_c3prime_verify.py b/scripts/ai-loop/test_c3prime_verify.py index 285adfa4..7544024d 100644 --- a/scripts/ai-loop/test_c3prime_verify.py +++ b/scripts/ai-loop/test_c3prime_verify.py @@ -115,6 +115,68 @@ def test_tampered_plan_hash_rejected(self): (d / "plan.md").write_text((d / "plan.md").read_text() + "x") self.assertEqual(_run(d), 1) + def test_r2_evidence_content_reverified(self): + # #889 R2 critical: evidence marker を FAIL/reject へ改竄 → reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + ph = base["plan_hash"] + (d / "review-self.md").write_text(f"C1-VERDICT: FAIL plan={ph}\n") + self.assertEqual(_run(d), 1, "C1=FAIL evidence が受理された") + (d / "review-self.md").write_text(f"C1-VERDICT: PASS plan={ph}\n") + (d / "review-external.md").write_text(f"C2-VERDICT: reject plan={ph}\n") + self.assertEqual(_run(d), 1, "C2=reject evidence が受理された") + # marker ごと消す + (d / "review-external.md").write_text("no marker at all\n") + self.assertEqual(_run(d), 1, "marker 無し evidence が受理された") + + def test_r2_task_id_bound_to_dir(self): + # #889 R2 high: task_id が task_dir 名と一致しなければ reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + rec = copy.deepcopy(base) + rec["task_id"] = "TASK-0001" + self._write(d, rec) + self.assertEqual(_run(d), 1) + + def test_r2_reviewer_independence(self): + # #889 R2 high: 両 reviewer の evidence_ref 同一(独立性偽装)→ reject + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + rec = copy.deepcopy(base) + rec["reviewers"]["model_b"] = copy.deepcopy(rec["reviewers"]["model_a"]) + self._write(d, rec) + self.assertEqual(_run(d), 1) + + def test_r2_nested_schema_parity(self): + # #889 R2 medium: extra reviewer / snapshot 未知キー / issued_at 不正 / 非 string 注釈 + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + base = _build_valid(d) + + def check(label, mutate): + rec = copy.deepcopy(base) + mutate(rec) + self._write(d, rec) + self.assertEqual(_run(d), 1, f"{label} が reject されない") + + check("extra reviewer", + lambda r: r["reviewers"].update({"model_c": copy.deepcopy(r["reviewers"]["model_a"])})) + check("snapshot 未知キー", + lambda r: r["reviewers"]["model_a"].update({"evil": "x"})) + check("issued_at 不正", lambda r: r.update({"issued_at": "not-a-date"})) + check("非 string 注釈", lambda r: r.update({"_note": 123})) + + def test_r2_expected_sha_required_at_exec(self): + # exec 相当(expected_sha 指定)では偽 source_sha を BLOCK + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) # source_sha=abc1234 + self.assertEqual(_run(d, "abc1234"), 0) + self.assertEqual(_run(d, "9999999"), 1) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/scripts/sync-plugin-plangate.sh b/scripts/sync-plugin-plangate.sh index 166b70d5..999e90a3 100755 --- a/scripts/sync-plugin-plangate.sh +++ b/scripts/sync-plugin-plangate.sh @@ -305,7 +305,7 @@ fi if [ -d "$AI_LOOP_SCRIPTS_DIR" ]; then # plan_package.py + test_plan_package.py(TASK-0872 / R-008: Plan-first 束縛層。 # 明示列挙に無いと plugin 配布物からサイレント欠落するため必ず対で列挙する) - for _f in "$AI_LOOP_SCRIPTS_DIR/arbiter.py" "$AI_LOOP_SCRIPTS_DIR/test_arbiter.py" "$AI_LOOP_SCRIPTS_DIR/metrics.py" "$AI_LOOP_SCRIPTS_DIR/test_metrics.py" "$AI_LOOP_SCRIPTS_DIR/plan_package.py" "$AI_LOOP_SCRIPTS_DIR/test_plan_package.py"; do + for _f in "$AI_LOOP_SCRIPTS_DIR/arbiter.py" "$AI_LOOP_SCRIPTS_DIR/test_arbiter.py" "$AI_LOOP_SCRIPTS_DIR/metrics.py" "$AI_LOOP_SCRIPTS_DIR/test_metrics.py" "$AI_LOOP_SCRIPTS_DIR/plan_package.py" "$AI_LOOP_SCRIPTS_DIR/test_plan_package.py" "$AI_LOOP_SCRIPTS_DIR/c3prime_verify.py" "$AI_LOOP_SCRIPTS_DIR/test_c3prime_verify.py"; do [ -f "$_f" ] || continue _sync_ai_loop_file "$_f" "$PLUGIN_AI_LOOP_SCRIPTS" "skills/ai-loop-cycle/scripts" done @@ -315,7 +315,7 @@ if [ -d "$PLUGIN_AI_LOOP_SCRIPTS" ]; then [ -f "$_f" ] || continue _base="$(basename "$_f")" case "$_base" in - arbiter.py|test_arbiter.py|metrics.py|test_metrics.py|plan_package.py|test_plan_package.py) : ;; + arbiter.py|test_arbiter.py|metrics.py|test_metrics.py|plan_package.py|test_plan_package.py|c3prime_verify.py|test_c3prime_verify.py) : ;; *) if [ "$DRY_RUN" = "1" ]; then _drylog "WOULD DELETE: skills/ai-loop-cycle/scripts/$_base" else rm "$_f"; _log "DELETE: skills/ai-loop-cycle/scripts/$_base"; fi From ee08c4577bd37d4c8cc7c0e6c1112c30c35a92f3 Mon Sep 17 00:00:00 2001 From: mine_take Date: Mon, 20 Jul 2026 09:38:54 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs(TASK-0872):=20R2-07=20Human=20?= =?UTF-8?q?=E6=B1=BA=E5=AE=9A=20B=20=E8=A8=98=E9=8C=B2=20+=20known-issues?= =?UTF-8?q?=20+=20ho-apply=20=E5=88=87=E3=82=8A=E6=88=BB=E3=81=97=E6=89=8B?= =?UTF-8?q?=E9=A0=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R2-07(受理ロジックの HO 機械強制): Human 決定 B(現状維持 + V2 明記)を known-issues.md KI-1 に記録。HO 化は将来判断 - KI-2 脅威モデル境界(git-tree 束縛/署名 V2)/ KI-3 TOCTOU / KI-4 切り戻し - ho-apply-approval.md に切り戻し手順(git apply -R)+ legacy 非退行確認を追記 Refs: #889, #872 Co-Authored-By: Claude Fable 5 --- docs/working/TASK-0872/decision-log.jsonl | 1 + docs/working/TASK-0872/known-issues.md | 25 +++++++++++++++++++ .../TASK-0872/patches/ho-apply-approval.md | 13 +++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/working/TASK-0872/known-issues.md diff --git a/docs/working/TASK-0872/decision-log.jsonl b/docs/working/TASK-0872/decision-log.jsonl index a6c0476f..c18a7332 100644 --- a/docs/working/TASK-0872/decision-log.jsonl +++ b/docs/working/TASK-0872/decision-log.jsonl @@ -16,3 +16,4 @@ {"ts":"2026-07-20T09:00:00Z","phase":"exec","decision":"PR-2受理側実装。設計: c3-prime検証をscripts/ai-loop/c3prime_verify.py(非HO・AIコミット可)に分離しbin/plangateは_plangate_c3_dispatch配線数行(HO)のみに縮小。契約§4全数再検証(trust boundary #887 F-4)。実適用テストでset -e漏れバグ(dispatch戻り10がスクリプト中断)を捕捉し|| _c3_rc=$?で修正。sandbox 6シナリオ全exit code実測(legacy非退行0/c3-prime受理0/改竄1)。HO patch4件はho-apply-approval.mdでHuman適用。ta-42 TC-04失敗はmacOS sh set-e差の既存偽陽性(main CI success)","by":"agent"} {"ts":"2026-07-20T10:00:00Z","phase":"exec","decision":"PR #889受理側Codex敵対レビュー(reject: critical 1/high 2/medium 1)を全件一次ソース再現裁定し是正(R-026〜030)。critical=偽造record受理→構造allowlist+必須+c3_status拒否+task_id/phase+expected_sha照合(exec HEAD強制)。high fallback=approval_kindキー存在時は値不問で1へ。high TOCTOU=exec preflight再検証+契約明記(残余窓Phase1許容/flock V2)。medium=test_c3prime_verify.py新設(手mutate 14パターン)+ta-55手偽造ケース。sandbox実適用で非退行+偽造reject実測。全非HO(bin/plangateはpatches/のみ)","by":"agent"} {"ts":"2026-07-20T11:00:00Z","phase":"exec","decision":"PR #889再レビュー(ユーザー指示Codex+独立)。Codex R2=reject(前回是正後のより深いcritical/high)・Sonnet=conditional(HOガバナンスmajor)。全件再現裁定しR2-01〜06+08は是正: 受理側でcheck_evidence再実行(evidence改竄検出)・task_id→task_dir束縛・reviewer evidence_ref独立性・非git exec BLOCK・schema同等nested制約・sync列挙追加。R2-07(受理ロジックHO対象外)はHOファイル編集を要する承認境界ガバナンス決定→Human判断待ち(A:HOリスト追加/B:V2明記)。脅威モデル境界(record+tree双方書換=git-tree束縛V2)を契約明記","by":"agent"} +{"ts":"2026-07-20T11:15:00Z","phase":"exec","decision":"R2-07 HOガバナンスはHuman決定B(現状維持+V2明記)。known-issues.md KI-1に記録。mode-classificationのセキュリティ関連→最低中でカバー・HO化は将来判断。KI-2脅威モデル境界(git-tree束縛/署名V2)・KI-3 TOCTOU・KI-4切り戻し手順も記録。ho-apply-approval.mdに切り戻し(git apply -R)追記","by":"human+agent"} diff --git a/docs/working/TASK-0872/known-issues.md b/docs/working/TASK-0872/known-issues.md new file mode 100644 index 00000000..971d4148 --- /dev/null +++ b/docs/working/TASK-0872/known-issues.md @@ -0,0 +1,25 @@ +# Known Issues / V2 候補 — TASK-0872 + +> WF-05 handoff 発行時にこの内容を handoff.md §V2 候補へ統合する。 + +## V2 候補 + +### KI-1: 受理検証ロジックの HO 機械強制(R2-07・Human 決定 B / 2026-07-20) + +- **内容**: 受理検証ロジック(`scripts/ai-loop/c3prime_verify.py`)は HO 9 カテゴリ(`check-plan-hash.sh` L124-134 正本)の対象外。将来の非 HO PR 単独で承認ゲートを弱められる構造的余地がある。 +- **Human 決定(AskUserQuestion 2026-07-20 verbatim: "B: 現状維持 + V2 明記")**: 現状維持。HO 化は将来判断。現状は `mode-classification.md`「セキュリティ関連 → 最低『中』」の判断依存保護でカバー。 +- **将来の是正案(A・不採用)**: `scripts/ai-loop/*_verify.py` を HO 9 カテゴリへ追加(`check-plan-hash.sh` + `mode-classification.md` を Human patch 適用)→ 機械強制を回復。以後 verifier 編集も HO ceremony。 + +### KI-2: 脅威モデル境界 — record + tree 双方書換への防御(R2-01 の残余 / #889 Codex) + +- **内容**: 現行受理器はローカル作業ツリーの改竄検出(承認後 drift・evidence 改竄)を対象とする。record と作業ツリーの**双方**を任意書換できる攻撃者(同一整合な偽造一式の構築)は、`source_sha` の Git tree 照合または署名済み provenance でのみ防げる。 +- **Phase 1 の扱い**: scope 外(eligible run 限定・boundary=clean・信頼済みローカル repo)。契約 §4「脅威モデルの境界」に明記済み。 +- **V2 案**: git-tree 束縛(artifact が `source_sha` の committed tree と一致することを照合)または署名済み provenance。 + +### KI-3: TOCTOU 残余窓(#889 R1 high) + +- exec preflight 検証成功〜`session_started` 記録までの 1 shell 文の窓。flock ベースの単一 snapshot 検証を V2 候補(契約 §4 記載)。 + +### KI-4: ho-apply の切り戻し手順(R2-09 minor) + +- `ho-apply-approval.md` に適用失敗時の切り戻し(`git apply -R docs/working/TASK-0872/patches/bin-plangate.patch`)を追記する(次コミット or handoff 時)。 diff --git a/docs/working/TASK-0872/patches/ho-apply-approval.md b/docs/working/TASK-0872/patches/ho-apply-approval.md index b02b2459..7366acbf 100644 --- a/docs/working/TASK-0872/patches/ho-apply-approval.md +++ b/docs/working/TASK-0872/patches/ho-apply-approval.md @@ -48,10 +48,21 @@ cp docs/working/TASK-0872/patches/c3-prime.schema.json schemas/c3-prime.schema.j # 4. 検証(適用後) sh scripts/sync-plugin-plangate.sh # drift 0 確認 -sh tests/run-tests.sh # TA-55 の HO 全鎖テストが SKIP→PASS へ +sh tests/run-tests.sh # TA-55 の HO 全鎖テストが SKIP→PASS / legacy 全テスト非退行 PASS python3 scripts/ai-loop/c3prime_verify.py --help 2>/dev/null || true ``` +**切り戻し(R2-09 / run-tests.sh が新規 FAIL を出した場合)**: + +```sh +git apply -R docs/working/TASK-0872/patches/bin-plangate.patch # bin/plangate を戻す +git checkout -- .claude/commands/ai-loop-workflow.md plugin/plangate/commands/ai-loop-workflow.md +rm -f schemas/c3-prime.schema.json +sh scripts/sync-plugin-plangate.sh && sh tests/run-tests.sh # 非退行を再確認 +``` + +適用後は legacy c3.json の既存テスト(`plangate validate --dir` 等)が引き続き PASS することを `run-tests.sh` 全体 PASS で確認する(legacy 分岐は byte-identical 温存済み)。 + ## 適用後に自動で有効化されるもの - `tests/extras/ta-55` の `[SKIP] bin/plangate に c3-prime 配線なし` が From bd7371dde4a37ba0b9d14bdac21833d048f03fef Mon Sep 17 00:00:00 2001 From: mine_take Date: Mon, 20 Jul 2026 10:01:36 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(TASK-0872):=20#889=203rd=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=98=AF=E6=AD=A3=20=E2=80=94=20art?= =?UTF-8?q?ifact=20test=20=E7=B6=B2=E7=BE=85=20+=20patch=20whitespace=20+?= =?UTF-8?q?=20doc=20=E7=B2=BE=E5=BA=A6=EF=BC=88R3-01=E3=80=9C05=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex + Sonnet 独立レビュー(R2 是正後)の minor/info を是正(新規 critical/high なし): - R3-01: patch ファイルの trailing whitespace(unified diff 空行 context マーカー)→ patches/.gitattributes で *.patch を whitespace 検査除外(完成形 .new は元々 clean) - R3-02: artifact_hashes 個別改竄(plan.md 以外)の専用テスト欠落 → test_artifact_hashes_individual_tampering 追加(12 テスト) - R3-03: known-issues KI-1 の verbatim 一次証跡 → decision-log に選択肢原文追記 - R3-04: ho-apply 切り戻しに cp 経路(git checkout -- bin/plangate)併記 - R3-05: 契約 §4 の静的 validate は source_sha 形式チェックのみを明記 CI: CodeQL fail は GitHub 503 障害中のインフラ失敗 → 再実行で pass 実測。全 checks green。 3 ラウンドの敵対レビューで穴収束(R1 表層→R2 深層→R3 test 網羅・doc 精度)。 Refs: #889, #872 Co-Authored-By: Claude Fable 5 --- docs/workflows/ai-loop/c3-prime-contract.md | 2 +- docs/working/TASK-0872/decision-log.jsonl | 1 + docs/working/TASK-0872/patches/.gitattributes | 4 ++++ .../TASK-0872/patches/ho-apply-approval.md | 4 +++- docs/working/TASK-0872/review-external.md | 17 +++++++++++++++++ .../references/c3-prime-contract.md | 2 +- .../scripts/test_c3prime_verify.py | 12 ++++++++++++ scripts/ai-loop/test_c3prime_verify.py | 12 ++++++++++++ 8 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 docs/working/TASK-0872/patches/.gitattributes diff --git a/docs/workflows/ai-loop/c3-prime-contract.md b/docs/workflows/ai-loop/c3-prime-contract.md index b4a634dd..45fba085 100644 --- a/docs/workflows/ai-loop/c3-prime-contract.md +++ b/docs/workflows/ai-loop/c3-prime-contract.md @@ -81,7 +81,7 @@ C-1/C-2 いずれか**単独**の異常でも `AUTO_APPROVED` にならない( | `decision == "AUTO_APPROVED"` だが reviewers の verdict に `reject` を含む | **BLOCK**(decision↔verdicts 不整合 record = 改竄兆候。#887 F-3 の受理側検証) | | `plan_hash` ≠ 現 plan.md sha256 | FAIL(stale・legacy と同一規則) | | `artifact_hashes` のいずれか ≠ 現ファイル sha256 | FAIL(stale。**不一致エントリ名を失敗メッセージに含む**) | -| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。exec preflight は `git rev-parse HEAD` を `expected_sha` として受理器へ渡し厳密照合する(#889 critical)。静的 validate は expected_sha を渡さず構造・束縛のみ検証 | +| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。exec preflight は `git rev-parse HEAD` を `expected_sha` として受理器へ渡し厳密照合する(#889 critical)。静的 validate は `expected_sha` を渡さないため source_sha は**形式チェックのみ**(HEAD 一致照合はしない)。SHA 一致の強制点は exec | | トップレベルに `c3_status` / 未知キー / 必須キー欠落 | **FAIL**(構造 allowlist・#889 critical。`^_` 注釈キーのみ許容) | | 受理器(`c3prime_verify.py`)が実在しない | c3-prime は **FAIL**(検証不能。`approval_kind` キーが物理的に無い場合のみ legacy 委譲 / #889 high fallback) | | 同一 TASK に legacy と c3-prime が併存 | **物理的に不可能**(同一パス `approvals/c3.json` のため)。上書きは `--force` 相当の明示操作のみ(EC-5 の解決) | diff --git a/docs/working/TASK-0872/decision-log.jsonl b/docs/working/TASK-0872/decision-log.jsonl index c18a7332..f4ac6154 100644 --- a/docs/working/TASK-0872/decision-log.jsonl +++ b/docs/working/TASK-0872/decision-log.jsonl @@ -17,3 +17,4 @@ {"ts":"2026-07-20T10:00:00Z","phase":"exec","decision":"PR #889受理側Codex敵対レビュー(reject: critical 1/high 2/medium 1)を全件一次ソース再現裁定し是正(R-026〜030)。critical=偽造record受理→構造allowlist+必須+c3_status拒否+task_id/phase+expected_sha照合(exec HEAD強制)。high fallback=approval_kindキー存在時は値不問で1へ。high TOCTOU=exec preflight再検証+契約明記(残余窓Phase1許容/flock V2)。medium=test_c3prime_verify.py新設(手mutate 14パターン)+ta-55手偽造ケース。sandbox実適用で非退行+偽造reject実測。全非HO(bin/plangateはpatches/のみ)","by":"agent"} {"ts":"2026-07-20T11:00:00Z","phase":"exec","decision":"PR #889再レビュー(ユーザー指示Codex+独立)。Codex R2=reject(前回是正後のより深いcritical/high)・Sonnet=conditional(HOガバナンスmajor)。全件再現裁定しR2-01〜06+08は是正: 受理側でcheck_evidence再実行(evidence改竄検出)・task_id→task_dir束縛・reviewer evidence_ref独立性・非git exec BLOCK・schema同等nested制約・sync列挙追加。R2-07(受理ロジックHO対象外)はHOファイル編集を要する承認境界ガバナンス決定→Human判断待ち(A:HOリスト追加/B:V2明記)。脅威モデル境界(record+tree双方書換=git-tree束縛V2)を契約明記","by":"agent"} {"ts":"2026-07-20T11:15:00Z","phase":"exec","decision":"R2-07 HOガバナンスはHuman決定B(現状維持+V2明記)。known-issues.md KI-1に記録。mode-classificationのセキュリティ関連→最低中でカバー・HO化は将来判断。KI-2脅威モデル境界(git-tree束縛/署名V2)・KI-3 TOCTOU・KI-4切り戻し手順も記録。ho-apply-approval.mdに切り戻し(git apply -R)追記","by":"human+agent"} +{"ts":"2026-07-20T11:16:00Z","phase":"C-3-governance","decision":"R2-07 HOガバナンス Human決定 verbatim選択: \"B: 現状維持 + V2 明記\"(AskUserQuestion選択肢ラベル原文)。known-issues.md KI-1の\"verbatim\"表記の一次証跡","by":"human"} diff --git a/docs/working/TASK-0872/patches/.gitattributes b/docs/working/TASK-0872/patches/.gitattributes new file mode 100644 index 00000000..d935fdd5 --- /dev/null +++ b/docs/working/TASK-0872/patches/.gitattributes @@ -0,0 +1,4 @@ +# 生成された unified diff 成果物。空行の context マーカー(行頭 1 空白)を含むため +# git diff --check の trailing-whitespace 検査対象から除外する(#889 Codex minor)。 +# 実適用対象の完成形(*.new)は trailing whitespace ゼロを別途保証している。 +*.patch -whitespace diff --git a/docs/working/TASK-0872/patches/ho-apply-approval.md b/docs/working/TASK-0872/patches/ho-apply-approval.md index 7366acbf..deadd410 100644 --- a/docs/working/TASK-0872/patches/ho-apply-approval.md +++ b/docs/working/TASK-0872/patches/ho-apply-approval.md @@ -55,7 +55,9 @@ python3 scripts/ai-loop/c3prime_verify.py --help 2>/dev/null || true **切り戻し(R2-09 / run-tests.sh が新規 FAIL を出した場合)**: ```sh -git apply -R docs/working/TASK-0872/patches/bin-plangate.patch # bin/plangate を戻す +# bin/plangate を戻す(適用方法に応じて選ぶ): +git apply -R docs/working/TASK-0872/patches/bin-plangate.patch # git apply で適用した場合 +# git checkout -- bin/plangate # cp .new で適用した場合はこちら git checkout -- .claude/commands/ai-loop-workflow.md plugin/plangate/commands/ai-loop-workflow.md rm -f schemas/c3-prime.schema.json sh scripts/sync-plugin-plangate.sh && sh tests/run-tests.sh # 非退行を再確認 diff --git a/docs/working/TASK-0872/review-external.md b/docs/working/TASK-0872/review-external.md index dcbd2d10..d00d5587 100644 --- a/docs/working/TASK-0872/review-external.md +++ b/docs/working/TASK-0872/review-external.md @@ -108,6 +108,23 @@ reviewer_evidence を build_c3_prime の必須 distinct 引数として扱う運 - (A) `scripts/ai-loop/*_verify.py`(または本ファイル)を HO 9 カテゴリへ追加(`check-plan-hash.sh` + `mode-classification.md` = ともに HO を Human patch 適用)→ 機械強制を回復。以後 verifier 編集も HO ceremony - (B) 現状維持 + handoff に V2/known-issue として明示(セキュリティ関連 = 最低「中」の判断依存保護は残る) +## PR #889 3 ラウンド目レビュー(2026-07-20・ユーザー指示・Codex + Sonnet 独立・head ee08c45) + +> R2 是正後の最新状態を再レビュー。Codex=conditional(環境の書込制限で動的テスト未実行=検証不能・穴の検出ではない)/ Sonnet=conditional approve(核心すべて実測健全・minor のみ)。新規 critical/high なし。 + +| ID | severity | lane | 指摘 | 是正 | +|----|----------|------|------|------| +| R3-01 | minor | Codex | patch ファイルの trailing whitespace(`git diff --check`)| **是正**: 完成形 `.new` は whitespace ゼロ。原因は unified diff の空行 context マーカー → `patches/.gitattributes`(`*.patch -whitespace`)で除外(check-attr で unset 確認) | +| R3-02 | minor | Sonnet | artifact_hashes 個別改竄(plan.md 以外)の専用テスト欠落(L143-148 ループ未到達)| **是正**: `test_artifact_hashes_individual_tampering`(todo/test-cases/pbi-input/review-external の 4 subtest)追加。計 12 テスト | +| R3-03 | info | Sonnet | known-issues KI-1 の "verbatim" 主張が repo に一次証跡なし | **是正**: decision-log.jsonl に選択肢ラベル原文「B: 現状維持 + V2 明記」を追記(監査可能化)| +| R3-04 | minor | Sonnet | ho-apply 切り戻しが cp 経路(`git checkout -- bin/plangate`)を欠く | **是正**: ho-apply-approval.md に cp 経路の切り戻しを併記 | +| R3-05 | info | Sonnet | 契約 §4 の expected_sha=None(静的 validate は形式チェックのみ)が読みにくい | **是正**: 契約 §4 に 1 文追記 | +| — | 指摘なし | 両 | 契約↔実装 1:1(全 11 規則)・producer 非依存テスト・legacy 後方互換(byte-identical)・HO patch 適用後 byte 一致 + validate/exec 4 パターン実測・run-tests 411 passed | Sonnet が HO patch を独自適用した sandbox で validate/exec を実測し健全確認 | + +**CI 実測**: Analyze (python)=CodeQL は GitHub 503 障害中の「Perform CodeQL Analysis」step 失敗(インフラ)→ 復旧後**再実行で pass**。全 checks green・MERGEABLE を実測確定。 + +3 ラウンドの敵対レビューで穴が収束(R1 表層 → R2 evidence/task_id/独立性の深層 → R3 は test 網羅と doc 精度のみ)。受理側の偽造耐性が実測で確立。 + ## 指摘なしと明示された観点 - レーン A: スコープ / Non-goals の相互整合、AC・9 シナリオのマッピング網羅性 diff --git a/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md b/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md index b4a634dd..45fba085 100644 --- a/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md +++ b/plugin/plangate/skills/ai-loop-cycle/references/c3-prime-contract.md @@ -81,7 +81,7 @@ C-1/C-2 いずれか**単独**の異常でも `AUTO_APPROVED` にならない( | `decision == "AUTO_APPROVED"` だが reviewers の verdict に `reject` を含む | **BLOCK**(decision↔verdicts 不整合 record = 改竄兆候。#887 F-3 の受理側検証) | | `plan_hash` ≠ 現 plan.md sha256 | FAIL(stale・legacy と同一規則) | | `artifact_hashes` のいずれか ≠ 現ファイル sha256 | FAIL(stale。**不一致エントリ名を失敗メッセージに含む**) | -| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。exec preflight は `git rev-parse HEAD` を `expected_sha` として受理器へ渡し厳密照合する(#889 critical)。静的 validate は expected_sha を渡さず構造・束縛のみ検証 | +| `source_sha` ≠ 検証時点の対象 SHA | **BLOCK(警告に降格しない fail-closed 固定 / R-003)**。exec preflight は `git rev-parse HEAD` を `expected_sha` として受理器へ渡し厳密照合する(#889 critical)。静的 validate は `expected_sha` を渡さないため source_sha は**形式チェックのみ**(HEAD 一致照合はしない)。SHA 一致の強制点は exec | | トップレベルに `c3_status` / 未知キー / 必須キー欠落 | **FAIL**(構造 allowlist・#889 critical。`^_` 注釈キーのみ許容) | | 受理器(`c3prime_verify.py`)が実在しない | c3-prime は **FAIL**(検証不能。`approval_kind` キーが物理的に無い場合のみ legacy 委譲 / #889 high fallback) | | 同一 TASK に legacy と c3-prime が併存 | **物理的に不可能**(同一パス `approvals/c3.json` のため)。上書きは `--force` 相当の明示操作のみ(EC-5 の解決) | diff --git a/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py b/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py index 7544024d..b29d6332 100644 --- a/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py +++ b/plugin/plangate/skills/ai-loop-cycle/scripts/test_c3prime_verify.py @@ -169,6 +169,18 @@ def check(label, mutate): check("issued_at 不正", lambda r: r.update({"issued_at": "not-a-date"})) check("非 string 注釈", lambda r: r.update({"_note": 123})) + def test_artifact_hashes_individual_tampering(self): + # Sonnet minor: plan.md 以外の個別 artifact 改竄 → artifact_hashes 不一致で reject + # (L143-148 の for name in ARTIFACTS ループの回帰網。plan.md はトップレベル + # plan_hash チェックで先に落ちるため別ファイルで到達させる) + for name in ("todo.md", "test-cases.md", "pbi-input.md", "review-external.md"): + with self.subTest(artifact=name): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + (d / name).write_text((d / name).read_text() + "x") + self.assertEqual(_run(d), 1, f"{name} 改竄が受理された") + def test_r2_expected_sha_required_at_exec(self): # exec 相当(expected_sha 指定)では偽 source_sha を BLOCK with tempfile.TemporaryDirectory() as tmp: diff --git a/scripts/ai-loop/test_c3prime_verify.py b/scripts/ai-loop/test_c3prime_verify.py index 7544024d..b29d6332 100644 --- a/scripts/ai-loop/test_c3prime_verify.py +++ b/scripts/ai-loop/test_c3prime_verify.py @@ -169,6 +169,18 @@ def check(label, mutate): check("issued_at 不正", lambda r: r.update({"issued_at": "not-a-date"})) check("非 string 注釈", lambda r: r.update({"_note": 123})) + def test_artifact_hashes_individual_tampering(self): + # Sonnet minor: plan.md 以外の個別 artifact 改竄 → artifact_hashes 不一致で reject + # (L143-148 の for name in ARTIFACTS ループの回帰網。plan.md はトップレベル + # plan_hash チェックで先に落ちるため別ファイルで到達させる) + for name in ("todo.md", "test-cases.md", "pbi-input.md", "review-external.md"): + with self.subTest(artifact=name): + with tempfile.TemporaryDirectory() as tmp: + d = tpp._make_task_dir(tmp) + _build_valid(d) + (d / name).write_text((d / name).read_text() + "x") + self.assertEqual(_run(d), 1, f"{name} 改竄が受理された") + def test_r2_expected_sha_required_at_exec(self): # exec 相当(expected_sha 指定)では偽 source_sha を BLOCK with tempfile.TemporaryDirectory() as tmp: