@tyhld/conductor 0.12.17 → 0.12.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,15 +7,19 @@
7
7
  **関門でない確認(3ボタンのカード)は出しません。**
8
8
  関門でない確認は**すべてその場で「はい」**として通し、この機の記録に「**聞かずに通した**」を1行残します。
9
9
 
10
- ★**3関門(main直変更/本番確認/DB実行)だけは、自走モードでも人に聞きます**
10
+ ★**関門(本番確認/DB実行)だけは、自走モードでも人に聞きます**
11
11
  (便 918ce835・えふさん確定 2026-09-17「本番操作は人承認へ戻す」)。
12
+ ★main 直変更と、本番 DB を直接変える道具は、関門ではなく **`deny`** です(便 fc22bc34・1-2節)。
12
13
  2026-09-06〜09-17 は関門も聞かずに通していました(便 e6c5658a の実測で、関門の判定21件が確認なしで実行)。
14
+ ★2026-09-18 から、関門は**入口の番人が管制に聞いて、答えが返るまで実行させません**。
15
+ auto モードでも同じです(便 ab6e36ca・4節)。
13
16
 
14
17
  | | 自走モード(既定) | 従来(`CC2_AUTORUN=0`) |
15
18
  | --- | --- | --- |
16
- | 3関門(main直変更/本番確認/DB実行) | ★**管制の2択(はい/いいえ)で人が判断** | 同左 |
19
+ | 関門(本番確認/DB実行) | ★**管制の2択(はい/いいえ)で人が判断** | 同左 |
17
20
  | そのほかの確認(`cd` / `npx` / 削除 など) | **聞かずに通す**+この機の記録に1行 | 管制の3ボタンで人が判断 |
18
21
  | **危険**(持ち出し・秘密・番人の設定への書き込み・`gh pr merge --admin`) | **`deny`** | 同左 |
22
+ | **抜け道**(main 直 push・API での合流・保護の変更・秘密の値の設定・リポの削除・検査の省略・本番 DB の直接変更) | **`deny`**(便 fc22bc34・1-2節) | 同左 |
19
23
  | 管制へつながらないとき | 関門は **`deny`**(フェイルクローズ)。それ以外の職人の作業は止まりません | 関門は `deny`(フェイルクローズ) |
20
24
  | PR の合流(`gh pr merge --auto` など・鉄則7) | **聞かずに通す**(関門ではない・ADR-007) | 同左 |
21
25
 
@@ -55,28 +59,53 @@ CC2_AUTORUN=0
55
59
 
56
60
  | 順番 | 何に当たるか | どうなるか |
57
61
  | --- | --- | --- |
58
- | 1 | **危険**(本番破壊・force push・データ消失・認証情報の露出/持ち出し) | `deny`(実行させない) |
59
- | 2 | **3関門**(main直変更/本番確認/DB実行) | 管制に到達できれば `ask`(人の判断へ渡す・2択)/**聞けないときは `deny`**(フェイルクローズ・ADR-003)。★自走モードでも同じ(便 918ce835) |
62
+ | 1 | **危険**(本番破壊・force push・データ消失・認証情報の露出/持ち出し)+★**抜け道**(1-2節) | `deny`(実行させない) |
63
+ | 2 | **関門**(本番確認/DB実行) | ★入口の番人が**その場で管制の2択で聞き、答えが出るまで待つ**。「はい」→ `allow`/「いいえ」・答えが来ない・聞けない → **`deny`**(便 ab6e36ca・4節)。自走モードでも・auto モードでも同じ |
60
64
  | 3 | それ以外 | `allow`(自走) |
61
65
 
62
66
  ★**新しいコマンドは必ず 3 に落ちます**。これが「将来出てくる道具が自動的に正しい側へ行く」形です。
63
67
 
64
68
  ### ★PRのマージは関門ではありません(ADR-007)
65
69
 
66
- `gh pr merge`(および `gh api …/pulls/N/merge`)は**関門から外し、職人が自走できます**。
70
+ `gh pr merge`(`--auto`・`--merge`・`--squash`・`--rebase`・`--delete-branch`)は**関門から外し、職人が自走できます**。
67
71
  マージを関門にすると、職人は毎回**最後の1手で人を待って止まる**ため、「指示を出したら終わりまで進む」が
68
72
  成立しませんでした。マージはPRという記録が残り、レビューとCIの結果が付いた上での操作です。
69
73
 
70
- **外したのはマージだけ**です。`git push origin main` のような**本番ブランチへの直接 push** は
71
- 関門のまま残ります(PRもレビューもCIも通らずに本番の元が変わるため、性質がまったく違います)。
72
- 関門1の呼び名も「マージ」から「**main直変更**」へ変わりました(種別の字面 `merge` は管制の表示・
73
- 集計が使っているのでそのままです)。
74
+ **外したのはマージだけ**です。`git push origin main` のような**本番ブランチへの直接 push** は、
75
+ 便 fc22bc34 で関門から **`deny`** へ移しました(1-2節)。★API での合流(`gh api …/pulls/N/merge` の書き込み)も `deny` です。
74
76
 
75
77
  ★**保護を迂回する合流 `gh pr merge --admin` は `deny`** です(便 918ce835・えふさん確定 2026-09-17)。
76
78
  branch protection は「CI が通っていない物を main に入れない」歯止めで、`--admin` はそれを外します。
77
79
  止めるのは動詞が `gh` で `pr merge` の後ろに `--admin` があるときだけで、PR の本文やコミットの説明に
78
80
  字面が出るだけの操作は止めません。本番の DDL を含む PR の合流も、鉄則7の自動合流のままです(本番操作の承認とは分けます)。
79
81
 
82
+ ## 1-2. ★抜け道は `deny`(便 fc22bc34・えふさん確定 2026-09-18)
83
+
84
+ 考え方:**ALLOW**=普通の開発・検索・試験・練習環境(本番を変えない)/**ASK**=外部への送信・公開など、
85
+ 正当な仕事だが実世界に影響する操作/**DENY**=職人が行う必要が無い抜け道。ここは DENY の側です。
86
+
87
+ | 抜け道 | 止める形(`policy.py`) |
88
+ | --- | --- |
89
+ | main など保護ブランチへの直接 push | `git push <remote> main`・`HEAD:master`・`--all`・`--mirror`。★書き込む先を書かない形(`git push`・`git push origin`・`git push origin HEAD`)は、**今のブランチと push 先(`@{push}`)を git に確かめ**、保護ブランチなら止める。確かめられない(切り離された HEAD・git リポでない・作業場所が変数)ときも止める(`_main_push_danger`) |
90
+ | 保護の迂回 | `gh pr merge --admin`・API での合流(`gh api` / `curl` で `pulls/N/merge` を PUT/POST・GraphQL の `mergePullRequest`) |
91
+ | 保護の変更(強める向きも) | ブランチの保護・ruleset・必須の検査・環境(`environments/<名前>`)・デプロイの保護(つなぎ先の `update_project_deployment_protection`)への書き込み |
92
+ | 秘密の値の設定・削除 | `vercel env add/rm`・`supabase secrets set/unset`・`wrangler secret put/delete`・`gh variable set/delete`・`fly secrets`・`heroku config:set`・`netlify env:set`、API の `actions/secrets`・`variables` への書き込み |
93
+ | リポの削除 | `gh repo delete`・API の `DELETE repos/<o>/<r>` |
94
+ | 検査・安全設定の省略 | `claude --dangerously-skip-permissions`・`--permission-mode bypassPermissions`・`git … --no-verify`・`git commit -n`・`git -c core.hooksPath=…`・コミットの説明や合流の説明に入った `[skip ci]` 類(GitHub が本当に CI を飛ばす) |
95
+ | 本番 DB の直接変更 | `psql`・`dropdb`・`prisma db push / migrate deploy / migrate reset`・`supabase db push / migration up`・`drizzle-kit push`・`npm/pnpm/yarn run migrate…`・REST の `rpc/exec_sql`(今の設定の deny と揃えた) |
96
+
97
+ ★**字面だけでは止めません**。どれも「実際に動く道具の語の並び」(`_tool_words`)で見ます。PR の本文やコミットの説明に
98
+ 「--admin」「skip ci」と書くだけの操作は通ります(`[skip ci]` 類は、コミットの説明に入った時点で GitHub が本当に CI を飛ばすので止めます)。
99
+ ★`bash -c "…"` などの包みの中も同じ物差しで測り直します。
100
+
101
+ ### ★本番 DB の直接変更は、あとで ASK へ移せる形
102
+
103
+ 判定は `_prod_db_change` の1か所、決定は `PROD_DB_CHANGE_DECISION` の1か所(いまは `DENY`)。
104
+ 「職人が本番の鍵を持たない+人が承認した専用の経路」が完成したら、この定数を `GATE` に差し替えるだけで、
105
+ 同じ判定が関門 db(管制の2択)として返ります(`test_bypass_deny.py` の (7) で差し替えを確かめています)。
106
+ ★差し替えるときは、ホームと各現場の設定の deny(`Bash(psql:*)` など)も人の手番で外してください。
107
+ 外さないと、番人が「はい」を受けても設定の deny で止まります(便 df4accf8 の重大発見2)。
108
+
80
109
  ## 2. ファイルの役割
81
110
 
82
111
  | ファイル | 役割 |
@@ -88,6 +117,7 @@ branch protection は「CI が通っていない物を main に入れない」
88
117
  | `hook_datalink.py` | フックと管制のやり取り(HTTP)。**製品固有の前提を持たない=切り出し可能** |
89
118
  | `summary_ja.py` | 確認カードに出す**日本語1行**の組み立て |
90
119
  | `test_policy.py` | 単体テスト(297件)。`python3 scripts/hooks/test_policy.py` |
120
+ | `test_bypass_deny.py` | 抜け道の `deny` と、止めてはいけない操作の試験(便 fc22bc34)。`python3 scripts/hooks/test_bypass_deny.py` |
91
121
  | (`../check_permission_rules.py`) | 現場の許可設定に残った「番人と食い違う行」を洗い出す検査(読むだけ・ADR-009) |
92
122
  | `test_hook_datalink.py` | 連絡線の単体テスト。`python3 scripts/hooks/test_hook_datalink.py` |
93
123
  | `settings.sample.json` | フック登録の見本(★人間が `~/.claude/settings.json` へ反映する) |
@@ -129,7 +159,73 @@ branch protection は「CI が通っていない物を main に入れない」
129
159
  同じ語彙を Bash とファイル編集ツール(Write/Edit/…)の両方に適用するので、
130
160
  **経路による強度差がありません**。
131
161
 
132
- ## 4. なぜ関門は `ask` で返すのか(便 918ce835 で `defer` から戻した)
162
+ ## 4. ★関門は入口の番人が聞いて、答えが返るまで実行させない(便 ab6e36ca・2026-09-18)
163
+
164
+ 便 918ce835 では関門を `ask` で返し、確認の番人(PermissionRequest)が管制にカードを出していました。
165
+ ところが**職人が auto モードで動くと、Claude Code は確認の番人の答えを待たずに実行を許しました**
166
+ (便 c525bb2b の実機・見本を2回とも実行。1回目は約2分後、2回目は約2.5秒後)。
167
+ =「カードを出して、あとは任せる」形では、止められるかがモードに左右されます。
168
+
169
+ 入口の番人(PreToolUse)の `deny` は**どのモードでも効きます**。そこで入口の番人が自分で管制に聞き、
170
+ **答えが返るまで返事をせず**、通す/止めるを自分で返します(`cc2_guard.ask_gate_now` → `permission_request_hook.ask_gate`)。
171
+
172
+ | 管制の状態 | 入口の番人の返事 |
173
+ | --- | --- |
174
+ | 「はい」 | `allow`(この1回だけ・恒久許可は付けない) |
175
+ | 「いいえ」 | `deny`「お客さまが「いいえ」を選んだので、実行しませんでした。」 |
176
+ | 答えが来ない(480秒) | `deny`「お客さまの確認(管制)が取れなかったので、実行しませんでした。…」+カードを閉じる |
177
+ | 管制が未設定・届かない・登録できない | `deny`「お客さまへの確認(管制)に今つながらないため…」 |
178
+ | 570秒に達した(管制が固まった等) | `deny`「…時間内に取れなかったので、実行しませんでした。…」(最後の歯止め) |
179
+
180
+ ★**`ask` は返しません**。以前の無効化フラグ `CC2_GATE_FAILCLOSE=0`(聞けなくても `ask`)も外しました。
181
+ `ask` は auto モードでは人の答えを待たずに通りうるため、止めが消える回避路になります。
182
+
183
+ ### ★待ち時間の上限(実物で確かめた値・Claude Code 2.1.275)
184
+
185
+ | 実測 | 結果 |
186
+ | --- | --- |
187
+ | PreToolUse に `timeout` を書かない登録で 75 秒待ってから `deny` | **止まった**(既定の上限は 75 秒より長い) |
188
+ | `timeout: 30` の登録で 45 秒待たせた | **30 秒で打ち切られ、番人の `deny` は捨てられて実行された** |
189
+ | `timeout` を書かない登録で 615 秒待たせた | **約 599 秒で打ち切られ、番人の `deny` は捨てられて実行された**(既定の上限は 600 秒) |
190
+
191
+ =**上限を超えると「止める」が消えます**。だから番人は上限の内側で必ず返します。
192
+
193
+ - `HOOK_TIMEOUT_SEC = 600` … Claude Code のフックの既定の上限(上の実測)。
194
+ ★この機のホーム設定の入口の番人には `timeout` が書かれていないので、既定の 600 秒が効きます。
195
+ 見本(`settings.sample.json`)には `"timeout": 600` を明記しました(同じ値)
196
+ - `GATE_HARD_LIMIT_SEC = 570` … ここに達したら何をしていても `deny` を書いて終わる(`SIGALRM`)
197
+ - `GATE_WAIT_SEC = 480` … 人の答えを待つ上限。**カードの期限(`waitMs`)も 480 秒にそろえます**
198
+ (登録 35秒+最後の問い合わせのはみ出し 38秒+カードを閉じる 10秒を足しても 563秒<570秒)
199
+
200
+ 環境変数 `CC2_GATE_WAIT_SEC` / `CC2_GATE_HARD_LIMIT_SEC` は**短くする向きにだけ**効きます(試験用)。
201
+
202
+ ### ★同じ操作の答えは覚えません
203
+
204
+ 1回の答えは、その1回の実行にだけ使います。関門は本番への操作なので、同じ文字列でも2回目は別の本番操作です。
205
+ 入口の番人は1回の実行につき1回しか呼ばれず、`allow` を返せば確認の番人も呼ばれないので、1回の実行の中で聞き直すことは起きません。
206
+ 「今後は聞かない」は職人側に作りません。
207
+
208
+ ★管制は、`requestKey` を省くと「会話+中身」が同じ確認を1行にまとめ、**前に出た答えをそのまま返します**
209
+ (ai-kana `src/lib/hook-request.ts` の `buildRequestKey` / `upsertHookRequest`)。それだと同じ会話で同じ本番操作の
210
+ 2回目が、前の「はい」で聞かれずに通ります。入口の番人は関門を聞くたびに**新しい `requestKey`** を付け
211
+ (`hook:<会話>:gate:<乱数>`)、登録の時点で付いてくる答え(`status='answered'`)も使いません。
212
+
213
+ ### auto モードの実機(便 ab6e36ca・Claude Code 2.1.275)
214
+
215
+ `--permission-mode auto`、`allow: ["Bash(python3:*)"]`、入口と確認の番人を登録し、答えない偽の管制へつないで
216
+ 見本 `python3 -c "print('gatecheck-' + 'grant ok')"` を1回実行させました(`scripts/hooks/test_gate_wait.py` は同じことを番人単体で CI でも確かめます)。
217
+
218
+ | 起動の仕方・番人 | 結果 |
219
+ | --- | --- |
220
+ | `claude -p`・この版(入口で聞いて待つ・待つ上限 20秒) | **実行されなかった**(カード1枚→20秒待って答え無し→`deny`→カードを閉じる) |
221
+ | 対話(tmux)・この版(待つ上限 480秒=既定) | **実行されなかった**(「PreToolUse の番人を実行中」のまま 480秒待ち→`deny`→カードを閉じる) |
222
+ | `claude -p`・前の版(入口は `ask`) | 実行されなかった(`-p` は `ask` に答えられないので、その場で拒否。管制のカードも出ない) |
223
+ | 対話(tmux)・前の版 | 10分間は実行されなかった(端末の確認画面のまま。確認の番人は 600秒で打ち切られ、カードは閉じられなかった) |
224
+
225
+ ★前の版でも、今回の実測では「答えを待たずに実行」(便 c525bb2b)は再現しませんでした。c525bb2b で何が実行を許したかは未確定です。
226
+ この版は、何が許しても入口の `deny` が先に効くので、その答えに左右されません。
227
+
228
+ ## 4-2. なぜ関門は `defer` をやめたのか(便 918ce835)
133
229
 
134
230
  以前は `defer`(何も出力しない)で返していました。`ask` だと画面が2択、`defer` だと3択になるからです
135
231
  (検証 26c878b7)。
@@ -162,27 +258,19 @@ branch protection は「CI が通っていない物を main に入れない」
162
258
  =フックの `ask` は `allow` 行より強い。★確認の番人(PermissionRequest)は入れずに試しました(管制に本物のカードを出さないため)。
163
259
  カードが出ることの確認は、実機での人の手番です。
164
260
 
165
- ### ★自走モードでも関門は人に聞く
166
-
167
- `permission_request_hook.decide_autorun` は、関門のときだけ従来の聞く経路(`decide_by_asking`)へ回します。
168
- 管制に2択のカードを出し、人の答えを返します。関門でない確認は今までどおりその場で `allow` です。
169
- 自走の印(`CC2_AUTORUN`)の意味は変えていません。
261
+ ★この `ask` の作りは、便 ab6e36ca で「入口の番人が聞いて待つ」に置き換えました(4節)。
262
+ `ask` が `allow` 行に勝つことは今も正しいですが、auto モードでは人の答えを待たずに通りうるためです。
170
263
 
171
- ### ★聞けないときは止める(フェイルクローズ・ADR-003)
264
+ ### 確認の番人(PermissionRequest)に残る関門の聞き方
172
265
 
173
- `ask` の先で人に聞くには、**管制に到達できること**が前提です。そこで関門に限り、自走モードでも:
174
-
175
- - 管制へ到達でき認証も通る(`hook_datalink.ping` が真)→ `ask`(2択カード)
176
- - 管制が未設定/到達不能/認証不可/答えが取れない → **`deny`**(安全のため止める)
177
-
178
- `deny` は PreToolUse(`cc2_guard`)で返すので、**確認を出さない実行モードでも確実に止まります**。
179
- 関門3種以外は従来どおり(全部は止めません)。既定で有効。`CC2_GATE_FAILCLOSE=0` で無効化できます
180
- (管制が不通の間だけ現場を止めたくない、という人間の判断用。無効にしても関門は `ask` のまま)。`ping` はカードを作らない GET で
181
- 到達だけを確かめます(副作用ゼロ・どんな例外でも False=安全側へ倒れる)。
266
+ `permission_request_hook.decide_autorun` は、関門のときだけ従来の聞く経路(`decide_by_asking`)へ回します。
267
+ Claude Code では入口の番人が先に `allow` / `deny` を返すので、ここへは来ません。
268
+ ここで関門を聞くのは Codex(下)と、経路が増えたときの二重化だけです。聞けないときは `deny` です。
182
269
 
183
- ★Codex から呼ばれたとき(`--codex`)は、PreToolUse の関門は従来どおり委譲します。
184
- Codex が PreToolUse の `ask` を受け付けるかは確かめていないためです(ADR-022 で確かめたのは `deny` と「`allow` は受け付けない」だけ)。
185
- 委譲の先は Codex 自身の承認の流れ(`approval_policy = "on-request"`)→ PermissionRequest の番人で、ここで関門は2択で人に聞きます。
270
+ ★Codex から呼ばれたとき(`--codex`)は、PreToolUse の関門は従来どおり委譲します
271
+ (Codex は PreToolUse の `allow` を受け付けないので、入口で「はい」を返せません・ADR-022)。
272
+ 管制に届かなければ入口で `deny`、届くなら Codex 自身の承認の流れ(`approval_policy = "on-request"`)→
273
+ PermissionRequest の番人で、関門は2択で人に聞きます。
186
274
 
187
275
  ### 「今後は聞かない」の扱い
188
276
 
@@ -208,7 +296,7 @@ python3 scripts/check_permission_rules.py
208
296
 
209
297
  ### 将来の論点(記載のみ)
210
298
 
211
- 関門を `ask` にしたので、ホーム設定を管理できない環境(他社へ提供した先など)でも、`allow` 行で関門は消えません。
299
+ 関門は入口の番人が `allow` / `deny` を自分で返すので、ホーム設定を管理できない環境(他社へ提供した先など)でも、`allow` 行やモードで関門は消えません。
212
300
  ただし番人は名前で判定するので、関門に当たらない書き方(台本の中から本番を呼ぶ等)は別に塞ぐ必要があります
213
301
  (便 d1fb33ed の分類表・次の便で関門の種類を広げる)。
214
302
 
@@ -9,36 +9,44 @@
9
9
 
10
10
  【判定と返し方】
11
11
  policy の 'deny' → permissionDecision="deny" … 実行させない
12
- policy の 'gate' → permissionDecision="ask" … 確認を必ず出す=人が判断する(便 918ce835)。
13
- ★ただし「聞けない」ときは deny(フェイルクローズ・下記)。
12
+ policy の 'gate' → ★その場で管制の2択カードで人に聞き、答えが出るまで返事をしない(便 ab6e36ca)。
13
+ 「はい」→ "allow"/「いいえ」・答えが来ない・管制に届かない・時間切れ → "deny"。
14
14
  ★Codex から呼ばれたときだけは従来どおり委譲(下の build_output の説明)。
15
15
  policy の 'allow' → permissionDecision="allow" … 自走
16
16
 
17
- 【★聞けないときは止める(フェイルクローズ・報告 09c70615 の根治)】
18
- 関門(gate)は「ask→ハーネスが確認→管制カード」で人へ渡す設計だが、これは
19
- 【管制に到達できること】が前提。到達できないと人に届かない。よって関門に限り:
20
- ・管制へ到達でき認証も通る(ping OK)→ ask(2択カードを出す)
21
- ・管制が未設定 / 到達不能 / 認証不可(ping NG)→ deny(安全のため止める)
22
- ★deny は PreToolUse で返すため、たとえ確認を出さない実行モードでも確実に止まる。
23
- 関門3種以外は従来どおり(全部は止めない)。★自走モードでも同じ(便 918ce835)。
24
- ★既定は有効。環境変数 CC2_GATE_FAILCLOSE=0 で無効化できる(管制が不通の間だけ現場を
25
- 止めたくない、という人間の判断用。既定 '1')。
26
-
27
- 【★なぜ関門は "ask" で返すのか(便 918ce835 で defer から戻した)】
28
- 以前は defer(何も出力しない)で返していた(実機検証 26c878b7)。理由は、ask だと確認が
29
- 2択[Yes/No]になり、defer なら3択[Yes/今後は聞かない/No]になるから。
30
- ところが defer には前提条件があった:
31
- ・関門コマンドに一致する allow 行が、どのスコープ(user / project / local)にも無いこと。
32
- 一致する allow 行が1つでもあると、確認ダイアログも PermissionRequest も出ずに実行される。
33
- 実際の利用者設定には Bash(python3:*) などの広い allow 行があり、関門の判定が出たのに
34
- 確認なしで実行された行が21件あった(便 e6c5658a の実測・中身はすべて安全だった)。
35
- 一方、関門の確認は【もともと2択】(permission_request_hook.build_choices・関門に
36
- 「今後は聞かない」は出さない方針)なので、ask の2択で失うものは無い。
37
- → 関門は ask で返す。ask は allow 行より強い(便 918ce835 で実機を確かめた結果は README)。
38
- ★「今後は聞かない」を管制の3ボタンで受ける話は、関門でない確認にだけ残る。
17
+ 【★なぜ入口の番人の中で待つのか(便 ab6e36ca・えふさん確定 2026-09-17)】
18
+ 便 918ce835 では関門を "ask" で返し、確認の番人(PermissionRequest)が管制に聞いていた。
19
+ ところが職人が auto モードで動くと、Claude Code は確認の番人の答えを待たずに実行を許した
20
+ (便 c525bb2b の実機・見本を2回とも実行。1回目は約2分後、2回目は約2.5秒後)。
21
+ =「カードを出して、あとは任せる」形では、実行を止められるかがモードに左右される。
22
+ 入口の番人(PreToolUse)の deny はどのモードでも効く。だから入口の番人が自分で聞いて待ち、
23
+ 通す/止めるを自分で返す(モードに左右されない根治)。
24
+ ★ask は返さない。ask は auto モードでは人の答えを待たずに通りうる(上の実機)。
25
+
26
+ 【★待ち時間の上限(実物で確かめた値・便 ab6e36ca)】
27
+ Claude Code 2.1.275 の実測:
28
+ ・PreToolUse に timeout を書かない登録で 75 秒待った → deny が効いた(既定の上限は 75 秒より長い)
29
+ ・timeout 30 の登録で 45 秒待たせた → 30 秒で打ち切られ、番人の deny は捨てられて【実行された】
30
+ ・timeout を書かない登録で 615 秒待たせた → 約 599 秒で打ち切られ、【実行された】
31
+ =上限を超えると「止める」が消える(フェイルオープン)。よって番人は上限の【内側】で必ず返す:
32
+ ・HOOK_TIMEOUT_SEC = 600 … Claude Code のフックの既定の上限(上の実測)
33
+ ・GATE_HARD_LIMIT_SEC = 570 … ここに達したら何をしていても deny を書いて終わる(最後の歯止め)
34
+ ・GATE_WAIT_SEC = 480 … 人の答えを待つ上限。カードの期限(waitMs)も同じ値にそろえる。
35
+ 登録(最大35秒)・最後の問い合わせのはみ出し(最大38秒)・カードを閉じる(最大10秒)を足しても
36
+ 570 秒の内側に収まる(480+35+38+10=563)。
37
+ ★環境変数 CC2_GATE_WAIT_SEC / CC2_GATE_HARD_LIMIT_SEC は【短くする向きにだけ】効く
38
+ (試験用。上の値より長くはできない=上限を越えて「止める」が消える形は作れない)。
39
+
40
+ 【★同じ操作の答えを覚えない(便 ab6e36ca の決めごと)】
41
+ 1回の答えは、その1回の実行にだけ使う。覚えて次に使い回さない。
42
+ ・関門は本番への操作なので、同じ文字列でも2回目は別の本番操作(2回目の反映・2回目の DB 実行)。
43
+ ・入口の番人は1回の実行につき1回しか呼ばれない(allow を返せば確認の番人も呼ばれない)ので、
44
+ 1回の実行の中で聞き直すことはもともと起きない。
45
+ ・「いいえ」の後に職人が同じ操作を試し直したときは、もう一度カードが出る(人が止め続けられる)。
39
46
 
40
47
  【フェイルセーフ】
41
48
  例外時は何も出力せず exit 0(=委譲)。誤って allow を出さない。
49
+ ★関門で待っている最中の例外・上限到達は deny(委譲にすると auto モードで通りうるため)。
42
50
 
43
51
  【入出力(公式 hooks 仕様 v2.1.x)】
44
52
  入力(stdin JSON): {"tool_name":"Bash","tool_input":{...},"cwd":"..."}
@@ -48,26 +56,51 @@
48
56
  """
49
57
  import json
50
58
  import os
59
+ import signal
51
60
  import sys
52
61
 
53
62
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
54
63
 
55
- import hook_datalink # noqa: E402 … 起動記録 と 管制への到達確認(ping)に使う
64
+ import hook_datalink # noqa: E402 … 起動記録 と 管制への問い合わせに使う
65
+ import permission_request_hook # noqa: E402 … 関門の2択カード(ask_gate)
56
66
  import policy # noqa: E402
57
67
 
68
+ # 待ち時間の上限(秒)。根拠は冒頭の【待ち時間の上限】。
69
+ HOOK_TIMEOUT_SEC = 600
70
+ GATE_HARD_LIMIT_SEC = 570
71
+ GATE_WAIT_SEC = 480
72
+
58
73
  # policy の判定 → フックが返す permissionDecision。
59
- # ★GATE は ask(allow 行に負けない・上の説明)。「聞けない」ときの deny は build_output で先に判定する。
74
+ # ★GATE は 'gate'(その場で聞いて allow / deny を返す)。ask は返さない(冒頭の説明)。
60
75
  _DECISION_MAP = {
61
76
  policy.DENY: 'deny',
62
77
  policy.ALLOW: 'allow',
63
- policy.GATE: 'ask',
78
+ policy.GATE: 'gate',
64
79
  }
65
80
 
81
+ # ★上限に達したとき・待っている最中に壊れたときの文言(止めたことを人の言葉で伝える)。
82
+ _GATE_TIMEOUT_MESSAGE = (
83
+ 'お客さまの確認(管制)が時間内に取れなかったので、実行しませんでした。'
84
+ 'マージ・本番反映・データベースの変更は、確認が取れたときだけ実行します。'
85
+ )
86
+
87
+
88
+ def _shorter_only(env, name, default):
89
+ """環境変数で【短くする向きにだけ】上書きする(長くはできない=上限を越えない)。"""
90
+ try:
91
+ v = float((env.get(name) or '').strip())
92
+ except ValueError:
93
+ return default
94
+ return min(default, v) if v > 0 else default
95
+
96
+
97
+ def gate_wait_sec(env=None):
98
+ return _shorter_only(os.environ if env is None else env, 'CC2_GATE_WAIT_SEC', GATE_WAIT_SEC)
66
99
 
67
- def _failclose_enabled(env=None):
68
- """関門のフェイルクローズが有効か。既定は有効('1')。'0' で無効。"""
69
- env = os.environ if env is None else env
70
- return (env.get('CC2_GATE_FAILCLOSE') or '1').strip() != '0'
100
+
101
+ def gate_hard_limit_sec(env=None):
102
+ return _shorter_only(os.environ if env is None else env, 'CC2_GATE_HARD_LIMIT_SEC',
103
+ GATE_HARD_LIMIT_SEC)
71
104
 
72
105
 
73
106
  def _deny_output(reason):
@@ -80,6 +113,35 @@ def _deny_output(reason):
80
113
  }
81
114
 
82
115
 
116
+ def _allow_output(reason):
117
+ return {
118
+ 'hookSpecificOutput': {
119
+ 'hookEventName': 'PreToolUse',
120
+ 'permissionDecision': 'allow',
121
+ 'permissionDecisionReason': reason,
122
+ }
123
+ }
124
+
125
+
126
+ def _load_config(datalink):
127
+ try:
128
+ return datalink.load_config()
129
+ except Exception: # noqa: BLE001
130
+ return None
131
+
132
+
133
+ def ask_gate_now(data, gate, datalink=hook_datalink):
134
+ """関門をその場で管制に聞き、答えが出るまで待って allow / deny を返す(便 ab6e36ca)。
135
+ ★答えが取れなければ必ず deny。"""
136
+ cfg = _load_config(datalink)
137
+ if cfg is None:
138
+ return _deny_output(permission_request_hook._GATE_FAILCLOSE_MESSAGE)
139
+ ok, message = permission_request_hook.ask_gate(data, gate, cfg, datalink, gate_wait_sec())
140
+ if ok:
141
+ return _allow_output('お客さまが管制の確認で「はい」を選びました(この1回だけ実行します)。')
142
+ return _deny_output(message)
143
+
144
+
83
145
  def build_output(data, datalink=hook_datalink, codex=False):
84
146
  """入力1件から出力(dict) を作る。返り値 None は「何も出力しない(委譲=defer)」。
85
147
 
@@ -106,30 +168,18 @@ def build_output(data, datalink=hook_datalink, codex=False):
106
168
  if codex and decision == 'allow':
107
169
  return None # ★Codex は allow を受け付けない(上の説明)。委譲は allow と同じ意味。
108
170
 
109
- if decision == 'ask':
110
- # ここに来るのは関門(gate)だけ。「聞けない」状況(管制不通・未設定)では
111
- # 素通りさせず deny する=フェイルクローズ。
112
- # ★自走モードでもフェイルクローズを通す(便 918ce835・えふさん確定 2026-09-17
113
- # 「本番操作は人承認へ戻す」)。以前は自走なら人に聞かないので止める理由が無い、
114
- # としてここを飛ばしていた。いまは関門だけは自走でも人に聞く
115
- # (permission_request_hook.decide_autorun)ので、聞けないなら止める。
116
- if _failclose_enabled():
117
- try:
118
- cfg = datalink.load_config()
119
- except Exception: # noqa: BLE001
120
- cfg = None
121
- reachable = datalink.ping(cfg) if cfg is not None else False
122
- if not reachable:
123
- return _deny_output(
124
- 'お客さまへの確認(管制)に今つながらないため、安全のためこの操作を止めました。'
125
- 'マージ・本番反映・データベースの変更は、確認できないときは実行しません。'
126
- '連絡線が回復してから、もう一度お試しください。'
127
- )
128
- if codex:
129
- # ★Codex が PreToolUse の ask を受け付けるかは未確認(ADR-022 で確かめたのは deny と
130
- # 「allow は受け付けない」だけ)。従来どおり委譲し、Codex 自身の承認の流れ
131
- # (approval_policy=on-request)→ PermissionRequest の番人(関門は人に聞く)へ渡す。
132
- return None
171
+ if decision == 'gate':
172
+ if not codex:
173
+ # ★その場で聞いて待ち、通す/止めるをここで返す(便 ab6e36ca)。
174
+ # 聞けない(未設定・不通・登録できない)・答えが来ない・時間切れは deny。
175
+ return ask_gate_now(data, result.get('gate'), datalink)
176
+ # ★Codex が PreToolUse の allow を受け付けないため、入口で「はい」を返せない
177
+ # (ADR-022)。従来どおり、管制に届かなければここで止め、届くなら委譲して
178
+ # Codex 自身の承認の流れ → PermissionRequest の番人(関門は人に聞く)へ渡す。
179
+ cfg = _load_config(datalink)
180
+ if cfg is None or not datalink.ping(cfg):
181
+ return _deny_output(permission_request_hook._GATE_FAILCLOSE_MESSAGE)
182
+ return None
133
183
 
134
184
  return {
135
185
  'hookSpecificOutput': {
@@ -149,13 +199,38 @@ def main() -> int:
149
199
  data = json.loads(raw) if raw.strip() else {}
150
200
  except Exception: # noqa: BLE001
151
201
  return 0 # 入力不正 → 委譲(フェイルセーフ)
202
+ # ★最後の歯止め: Claude Code の上限(HOOK_TIMEOUT_SEC)に打ち切られる前に、何をしていても
203
+ # deny を書いて終わる。打ち切られると番人の答えは捨てられ、実行されてしまう(冒頭の実測)。
204
+ # ふつうの操作は一瞬で終わるので、ここに達するのは関門で待っているときだけ。
205
+ _arm_hard_limit()
152
206
  try:
153
207
  out = build_output(data, codex=codex)
154
- if out is not None:
155
- sys.stdout.write(json.dumps(out, ensure_ascii=False))
156
- return 0
157
208
  except Exception: # noqa: BLE001
158
- return 0 # 想定外でも allow を出さない=委譲
209
+ out = None
210
+ if not codex and _is_gate(data):
211
+ out = _deny_output(_GATE_TIMEOUT_MESSAGE) # 待っている最中に壊れた関門は止める
212
+ signal.alarm(0)
213
+ if out is not None:
214
+ sys.stdout.write(json.dumps(out, ensure_ascii=False))
215
+ return 0 # それ以外の想定外は allow を出さない=委譲
216
+
217
+
218
+ def _is_gate(data):
219
+ try:
220
+ return policy.decide_event(data).get('decision') == policy.GATE
221
+ except Exception: # noqa: BLE001
222
+ return False
223
+
224
+
225
+ def _on_hard_limit(signum, frame): # noqa: ARG001
226
+ sys.stdout.write(json.dumps(_deny_output(_GATE_TIMEOUT_MESSAGE), ensure_ascii=False))
227
+ sys.stdout.flush()
228
+ os._exit(0)
229
+
230
+
231
+ def _arm_hard_limit():
232
+ signal.signal(signal.SIGALRM, _on_hard_limit)
233
+ signal.alarm(max(1, int(gate_hard_limit_sec())))
159
234
 
160
235
 
161
236
  if __name__ == '__main__':
@@ -3,7 +3,11 @@
3
3
 
4
4
  【いつ走るか】
5
5
  Claude Code が「人に聞く必要がある」と判定して確認ダイアログを出す瞬間。
6
- 番人(cc2_guard.py)が関門を "ask" で返したときが主な入口。
6
+ ★関門(Claude Code)は、入口の番人(cc2_guard.py)が自分で管制に聞いて待ち、
7
+ allow / deny を返す(便 ab6e36ca・下の ask_gate)。ここへは来ない。
8
+ ここで関門を聞くのは Codex(入口で委譲する)と、経路が増えたときの二重化だけ。
9
+ ★なぜ入口で待つのか: 確認の番人は、auto モードの職人では答えを待たずに実行を許された
10
+ (便 c525bb2b の実機)。入口の番人の deny はどのモードでも効く。
7
11
 
8
12
  【何をするか】
9
13
  1. 管制へ確認を1件登録する(POST /api/conductor/hook-requests)
@@ -39,6 +43,7 @@
39
43
  import json
40
44
  import os
41
45
  import sys
46
+ import uuid
42
47
 
43
48
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
49
 
@@ -68,8 +73,9 @@ def build_choices(gate=None):
68
73
  return [yes, {'value': 'allow_always', 'label': '今後は聞かない'}, no]
69
74
 
70
75
 
71
- def build_payload(data, gate=None):
72
- """管制へ送る値を組み立てる。★人が読む文言は日本語1行(summary)。"""
76
+ def build_payload(data, gate=None, wait_sec=None):
77
+ """管制へ送る値を組み立てる。★人が読む文言は日本語1行(summary)。
78
+ ★wait_sec … カードの期限(秒)。待つ側の上限と同じ値を渡し、カードとフックの期限をそろえる。"""
73
79
  tool = data.get('tool_name')
74
80
  tin = data.get('tool_input') if isinstance(data.get('tool_input'), dict) else {}
75
81
  cwd = data.get('cwd') or ''
@@ -85,7 +91,7 @@ def build_payload(data, gate=None):
85
91
  'toolName': tool or 'unknown',
86
92
  'toolInput': tin,
87
93
  'summary': summary_ja.summarize_tool(tool, tin, gate),
88
- 'waitMs': hook_datalink.MAX_WAIT_SEC * 1000,
94
+ 'waitMs': int((wait_sec or hook_datalink.MAX_WAIT_SEC) * 1000),
89
95
  }
90
96
  # 選択肢を添える(取得できたときだけ。空なら送らない=管制のフォールバックに任せる)。
91
97
  choices = build_choices(gate)
@@ -142,6 +148,53 @@ def _gate_deny_for_failure(kind):
142
148
  return _deny(_GATE_FAILCLOSE_MESSAGE)
143
149
 
144
150
 
151
+ # ★答えが取れなかった(時間切れ・答えの途中で不通)ときの文言(便 ab6e36ca)。
152
+ _GATE_NO_ANSWER_MESSAGE = (
153
+ 'お客さまの確認(管制)が取れなかったので、実行しませんでした。'
154
+ 'マージ・本番反映・データベースの変更は、確認が取れたときだけ実行します。'
155
+ )
156
+ _GATE_REFUSED_MESSAGE = 'お客さまが「いいえ」を選んだので、実行しませんでした。'
157
+
158
+
159
+ def ask_gate(data, gate, cfg, datalink=hook_datalink, wait_sec=None):
160
+ """関門を管制の2択で聞き、答えが出るまで待つ(便 ab6e36ca・入口の番人から呼ぶ)。
161
+
162
+ 返り: (True, None) … 人が「はい」を選んだ
163
+ (False, 人に見せる理由) … 「いいえ」・登録できない・答えが来ない・時間切れ
164
+ ★答えないことを許可に読み替えない。答えが取れなければ必ず (False, …)。
165
+ ★恒久許可(今後は聞かない)は関門には出さない・受けても通すのはこの1回だけ。
166
+ ★1回の実行ごとに【新しいカード】を立てる(requestKey を毎回新しくする)。
167
+ 管制は requestKey を省くと「会話+中身」で1行にまとめ、前に出た答えをそのまま返す
168
+ (ai-kana src/lib/hook-request.ts の buildRequestKey / upsertHookRequest)。
169
+ それだと同じ会話で同じ本番操作の2回目が、前の「はい」で聞かれずに通る。
170
+ 同じ理由で、登録の時点で付いてくる答え(status='answered')も使わない。
171
+ """
172
+ wait_sec = wait_sec or hook_datalink.MAX_WAIT_SEC
173
+ payload = build_payload(data, gate, wait_sec)
174
+ payload['requestKey'] = f"hook:{payload['sessionId']}:gate:{uuid.uuid4().hex}"
175
+ reg = datalink.register(cfg, payload)
176
+ if not reg or not reg.get('id'):
177
+ kind = _register_failure_kind(datalink)
178
+ return False, (_GATE_DEFECT_MESSAGE if kind == hook_datalink.FAIL_CLIENT
179
+ else _GATE_FAILCLOSE_MESSAGE)
180
+ request_id = reg['id']
181
+ answer = None
182
+ try:
183
+ answer = datalink.wait_for_decision(cfg, request_id, max_wait_sec=wait_sec)
184
+ finally:
185
+ # ★答えが無いまま去る(時間切れ・上限で打ち切り)ときはカードを閉じる。
186
+ if answer is None:
187
+ try:
188
+ datalink.abandon(cfg, request_id)
189
+ except Exception: # noqa: BLE001 閉じられなくても止める判断は変えない
190
+ pass
191
+ if answer is None:
192
+ return False, _GATE_NO_ANSWER_MESSAGE
193
+ if answer in ('allow', 'allow_always'):
194
+ return True, None
195
+ return False, _GATE_REFUSED_MESSAGE
196
+
197
+
145
198
  def _deny(message):
146
199
  return {'hookSpecificOutput': {
147
200
  'hookEventName': 'PermissionRequest',