@tyhld/conductor 0.12.16 → 0.12.18

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.16",
3
+ "version": "0.12.18",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,16 +4,22 @@
4
4
 
5
5
  ## 0. ★いまは「自走モード」が既定です(えふさん決定 2026-09-06)
6
6
 
7
- **お客さまへの確認(3ボタンのカード)は出しません。**
8
- これまで確認にしていたものは**すべてその場で「はい」**として通し、
9
- 代わりに「**聞かずに通した**」を管制へ**履歴として**残します(画面のカードにはなりません)。
7
+ **関門でない確認(3ボタンのカード)は出しません。**
8
+ 関門でない確認は**すべてその場で「はい」**として通し、この機の記録に「**聞かずに通した**」を1行残します。
9
+
10
+ ★**3関門(main直変更/本番確認/DB実行)だけは、自走モードでも人に聞きます**
11
+ (便 918ce835・えふさん確定 2026-09-17「本番操作は人承認へ戻す」)。
12
+ 2026-09-06〜09-17 は関門も聞かずに通していました(便 e6c5658a の実測で、関門の判定21件が確認なしで実行)。
13
+ ★2026-09-18 から、関門は**入口の番人が管制に聞いて、答えが返るまで実行させません**。
14
+ auto モードでも同じです(便 ab6e36ca・4節)。
10
15
 
11
16
  | | 自走モード(既定) | 従来(`CC2_AUTORUN=0`) |
12
17
  | --- | --- | --- |
13
- | 3関門(main直変更/本番確認/DB実行) | **聞かずに通す**+履歴を残す | 管制の3ボタンで人が判断 |
14
- | そのほかの確認(`cd` / `npx` / 削除 など) | **聞かずに通す**+履歴を残す | 端末の確認画面 |
15
- | **危険**(持ち出し・秘密・番人の設定への書き込み) | **今までどおり `deny`(1ミリも変えていません)** | 同左 |
16
- | 管制へつながらないとき | **職人は止まりません**(履歴だけ残せず、その旨をこの機の記録に1行) | 関門は `deny`(フェイルクローズ) |
18
+ | 3関門(main直変更/本番確認/DB実行) | ★**管制の2択(はい/いいえ)で人が判断** | 同左 |
19
+ | そのほかの確認(`cd` / `npx` / 削除 など) | **聞かずに通す**+この機の記録に1行 | 管制の3ボタンで人が判断 |
20
+ | **危険**(持ち出し・秘密・番人の設定への書き込み・`gh pr merge --admin`) | **`deny`** | 同左 |
21
+ | 管制へつながらないとき | 関門は **`deny`**(フェイルクローズ)。それ以外の職人の作業は止まりません | 関門は `deny`(フェイルクローズ) |
22
+ | PR の合流(`gh pr merge --auto` など・鉄則7) | **聞かずに通す**(関門ではない・ADR-007) | 同左 |
17
23
 
18
24
  **切り替えは1か所だけ**です。`$HOME/.conductor.env`(接続先・鍵と同じファイル)に
19
25
 
@@ -52,7 +58,7 @@ CC2_AUTORUN=0
52
58
  | 順番 | 何に当たるか | どうなるか |
53
59
  | --- | --- | --- |
54
60
  | 1 | **危険**(本番破壊・force push・データ消失・認証情報の露出/持ち出し) | `deny`(実行させない) |
55
- | 2 | **3関門**(main直変更/本番確認/DB実行) | ★自走モード(既定)では**聞かずに通す**(上の 0 節)。`CC2_AUTORUN=0` のときだけ、管制に到達できれば `defer`(人の判断へ渡す)/**聞けないときは `deny`**(フェイルクローズ・ADR-003) |
61
+ | 2 | **3関門**(main直変更/本番確認/DB実行) | ★入口の番人が**その場で管制の2択で聞き、答えが出るまで待つ**。「はい」→ `allow`/「いいえ」・答えが来ない・聞けない → **`deny`**(便 ab6e36ca・4節)。自走モードでも・auto モードでも同じ |
56
62
  | 3 | それ以外 | `allow`(自走) |
57
63
 
58
64
  ★**新しいコマンドは必ず 3 に落ちます**。これが「将来出てくる道具が自動的に正しい側へ行く」形です。
@@ -68,6 +74,11 @@ CC2_AUTORUN=0
68
74
  関門1の呼び名も「マージ」から「**main直変更**」へ変わりました(種別の字面 `merge` は管制の表示・
69
75
  集計が使っているのでそのままです)。
70
76
 
77
+ ★**保護を迂回する合流 `gh pr merge --admin` は `deny`** です(便 918ce835・えふさん確定 2026-09-17)。
78
+ branch protection は「CI が通っていない物を main に入れない」歯止めで、`--admin` はそれを外します。
79
+ 止めるのは動詞が `gh` で `pr merge` の後ろに `--admin` があるときだけで、PR の本文やコミットの説明に
80
+ 字面が出るだけの操作は止めません。本番の DDL を含む PR の合流も、鉄則7の自動合流のままです(本番操作の承認とは分けます)。
81
+
71
82
  ## 2. ファイルの役割
72
83
 
73
84
  | ファイル | 役割 |
@@ -120,49 +131,127 @@ CC2_AUTORUN=0
120
131
  同じ語彙を Bash とファイル編集ツール(Write/Edit/…)の両方に適用するので、
121
132
  **経路による強度差がありません**。
122
133
 
123
- ## 4. なぜ関門は `defer` で返すのか(★前提条件つき)
134
+ ## 4. ★関門は入口の番人が聞いて、答えが返るまで実行させない(便 ab6e36ca・2026-09-18)
135
+
136
+ 便 918ce835 では関門を `ask` で返し、確認の番人(PermissionRequest)が管制にカードを出していました。
137
+ ところが**職人が auto モードで動くと、Claude Code は確認の番人の答えを待たずに実行を許しました**
138
+ (便 c525bb2b の実機・見本を2回とも実行。1回目は約2分後、2回目は約2.5秒後)。
139
+ =「カードを出して、あとは任せる」形では、止められるかがモードに左右されます。
140
+
141
+ 入口の番人(PreToolUse)の `deny` は**どのモードでも効きます**。そこで入口の番人が自分で管制に聞き、
142
+ **答えが返るまで返事をせず**、通す/止めるを自分で返します(`cc2_guard.ask_gate_now` → `permission_request_hook.ask_gate`)。
143
+
144
+ | 管制の状態 | 入口の番人の返事 |
145
+ | --- | --- |
146
+ | 「はい」 | `allow`(この1回だけ・恒久許可は付けない) |
147
+ | 「いいえ」 | `deny`「お客さまが「いいえ」を選んだので、実行しませんでした。」 |
148
+ | 答えが来ない(480秒) | `deny`「お客さまの確認(管制)が取れなかったので、実行しませんでした。…」+カードを閉じる |
149
+ | 管制が未設定・届かない・登録できない | `deny`「お客さまへの確認(管制)に今つながらないため…」 |
150
+ | 570秒に達した(管制が固まった等) | `deny`「…時間内に取れなかったので、実行しませんでした。…」(最後の歯止め) |
151
+
152
+ ★**`ask` は返しません**。以前の無効化フラグ `CC2_GATE_FAILCLOSE=0`(聞けなくても `ask`)も外しました。
153
+ `ask` は auto モードでは人の答えを待たずに通りうるため、止めが消える回避路になります。
154
+
155
+ ### ★待ち時間の上限(実物で確かめた値・Claude Code 2.1.275)
156
+
157
+ | 実測 | 結果 |
158
+ | --- | --- |
159
+ | PreToolUse に `timeout` を書かない登録で 75 秒待ってから `deny` | **止まった**(既定の上限は 75 秒より長い) |
160
+ | `timeout: 30` の登録で 45 秒待たせた | **30 秒で打ち切られ、番人の `deny` は捨てられて実行された** |
161
+ | `timeout` を書かない登録で 615 秒待たせた | **約 599 秒で打ち切られ、番人の `deny` は捨てられて実行された**(既定の上限は 600 秒) |
162
+
163
+ =**上限を超えると「止める」が消えます**。だから番人は上限の内側で必ず返します。
164
+
165
+ - `HOOK_TIMEOUT_SEC = 600` … Claude Code のフックの既定の上限(上の実測)。
166
+ ★この機のホーム設定の入口の番人には `timeout` が書かれていないので、既定の 600 秒が効きます。
167
+ 見本(`settings.sample.json`)には `"timeout": 600` を明記しました(同じ値)
168
+ - `GATE_HARD_LIMIT_SEC = 570` … ここに達したら何をしていても `deny` を書いて終わる(`SIGALRM`)
169
+ - `GATE_WAIT_SEC = 480` … 人の答えを待つ上限。**カードの期限(`waitMs`)も 480 秒にそろえます**
170
+ (登録 35秒+最後の問い合わせのはみ出し 38秒+カードを閉じる 10秒を足しても 563秒<570秒)
171
+
172
+ 環境変数 `CC2_GATE_WAIT_SEC` / `CC2_GATE_HARD_LIMIT_SEC` は**短くする向きにだけ**効きます(試験用)。
124
173
 
125
- 当初は `ask` にしていました。ところが実機で確かめたところ(検証 26c878b7)、
174
+ ### ★同じ操作の答えは覚えません
175
+
176
+ 1回の答えは、その1回の実行にだけ使います。関門は本番への操作なので、同じ文字列でも2回目は別の本番操作です。
177
+ 入口の番人は1回の実行につき1回しか呼ばれず、`allow` を返せば確認の番人も呼ばれないので、1回の実行の中で聞き直すことは起きません。
178
+ 「今後は聞かない」は職人側に作りません。
179
+
180
+ ★管制は、`requestKey` を省くと「会話+中身」が同じ確認を1行にまとめ、**前に出た答えをそのまま返します**
181
+ (ai-kana `src/lib/hook-request.ts` の `buildRequestKey` / `upsertHookRequest`)。それだと同じ会話で同じ本番操作の
182
+ 2回目が、前の「はい」で聞かれずに通ります。入口の番人は関門を聞くたびに**新しい `requestKey`** を付け
183
+ (`hook:<会話>:gate:<乱数>`)、登録の時点で付いてくる答え(`status='answered'`)も使いません。
184
+
185
+ ### auto モードの実機(便 ab6e36ca・Claude Code 2.1.275)
186
+
187
+ `--permission-mode auto`、`allow: ["Bash(python3:*)"]`、入口と確認の番人を登録し、答えない偽の管制へつないで
188
+ 見本 `python3 -c "print('gatecheck-' + 'grant ok')"` を1回実行させました(`scripts/hooks/test_gate_wait.py` は同じことを番人単体で CI でも確かめます)。
189
+
190
+ | 起動の仕方・番人 | 結果 |
191
+ | --- | --- |
192
+ | `claude -p`・この版(入口で聞いて待つ・待つ上限 20秒) | **実行されなかった**(カード1枚→20秒待って答え無し→`deny`→カードを閉じる) |
193
+ | 対話(tmux)・この版(待つ上限 480秒=既定) | **実行されなかった**(「PreToolUse の番人を実行中」のまま 480秒待ち→`deny`→カードを閉じる) |
194
+ | `claude -p`・前の版(入口は `ask`) | 実行されなかった(`-p` は `ask` に答えられないので、その場で拒否。管制のカードも出ない) |
195
+ | 対話(tmux)・前の版 | 10分間は実行されなかった(端末の確認画面のまま。確認の番人は 600秒で打ち切られ、カードは閉じられなかった) |
196
+
197
+ ★前の版でも、今回の実測では「答えを待たずに実行」(便 c525bb2b)は再現しませんでした。c525bb2b で何が実行を許したかは未確定です。
198
+ この版は、何が許しても入口の `deny` が先に効くので、その答えに左右されません。
199
+
200
+ ## 4-2. なぜ関門は `defer` をやめたのか(便 918ce835)
201
+
202
+ 以前は `defer`(何も出力しない)で返していました。`ask` だと画面が2択、`defer` だと3択になるからです
203
+ (検証 26c878b7)。
126
204
 
127
205
  | フックの返し方 | `permission_suggestions` | 画面の選択肢 |
128
206
  | --- | --- | --- |
129
207
  | `ask` | **null** | `[Yes]` `[No]` の**2択** |
130
208
  | `defer` | **入る** | `[Yes]` `[Yes, and don't ask again for: …]` `[No]` の**3択** |
131
209
 
132
- 3択(はい/今後は聞かない/いいえ)を残す方針のため、**関門は(到達できるとき)`defer`** で返します。
210
+ ところが `defer` には**前提条件**がありました。関門コマンドに一致する `allow` 行が**どのスコープにも無いこと**。
211
+ 一致する `allow` 行が1つでもあると、**確認ダイアログも PermissionRequest も出ずに実行されます**。
212
+ 実際の利用者設定には `Bash(python3:*)` などの広い `allow` 行があり、関門の判定が出たのに確認なしで実行された行が
213
+ 21件ありました(便 e6c5658a の実測。中身はすべて安全でした)。
133
214
 
134
- ### ★聞けないときは止める(フェイルクローズ・ADR-003)※自走モードでは通りません
215
+ 一方、**関門の確認はもともと2択**です(`permission_request_hook.build_choices`・関門に「今後は聞かない」は出さない)。
216
+ `ask` にしても失うものが無いので、**関門は `ask`** で返します。
135
217
 
136
- ★以下は `CC2_AUTORUN=0`(従来どおり聞く)のときの話です。自走モードでは**そもそも人に聞かない**ので、
137
- 「聞けないから止める」理由がありません(残すと管制が落ちている間だけ職人が止まり、決定と食い違います)。
138
- **危険の `deny` は自走でも今までどおり止まります**(順番1・別の仕組み)。
218
+ ### ★`ask` は `allow` 行に勝つ(実機で確認・便 918ce835・2026-09-17・Claude Code 2.1.274)
219
+
220
+ この機で、`allow: ["Bash(python3:*)"]` と PreToolUse の番人だけを持つ設定で `claude -p` を起動し、
221
+ 関門 db に当たる見本(`python3 -c` で文字列を出すだけ・危険語なし)を1回実行させました。
222
+ `-p` は確認に答えられないので、「確認が出た」=「実行されずに拒否」として見えます。
223
+
224
+ | 番人 | 結果 |
225
+ | --- | --- |
226
+ | main の番人(関門は `defer`) | **確認なしで実行された**(`permission_denials` 0件=今の穴の再現) |
227
+ | この版の番人(関門は `ask`) | **実行されなかった**(`permission_denials` 1件) |
228
+ | この版の番人+`CC2_GATE_FAILCLOSE=0`(到達確認を省き、必ず `ask`) | **実行されなかった**(`permission_denials` 1件) |
139
229
 
230
+ =フックの `ask` は `allow` 行より強い。★確認の番人(PermissionRequest)は入れずに試しました(管制に本物のカードを出さないため)。
231
+ カードが出ることの確認は、実機での人の手番です。
140
232
 
141
- `defer` は **管制に到達できること** が前提で、到達できないと**黙って素通り**します(実際に3関門が
142
- 素通りしていました・調査 `09c70615`)。そこで関門に限り:
233
+ ★この `ask` の作りは、便 ab6e36ca で「入口の番人が聞いて待つ」に置き換えました(4節)。
234
+ `ask` が `allow` 行に勝つことは今も正しいですが、auto モードでは人の答えを待たずに通りうるためです。
143
235
 
144
- - 管制へ到達でき認証も通る(`hook_datalink.ping` が真)→ 従来どおり `defer`(3択カード)
145
- - 管制が未設定/到達不能/認証不可/答えが取れない → **`deny`**(安全のため止める)
236
+ ### 確認の番人(PermissionRequest)に残る関門の聞き方
146
237
 
147
- `deny` は PreToolUse(`cc2_guard`)で返すので、**確認を出さない実行モードでも確実に止まります**。
148
- 関門3種以外は従来どおり(全部は止めません)。既定で有効。`CC2_GATE_FAILCLOSE=0` で無効化できます
149
- (管制が不通の間だけ現場を止めたくない、という人間の判断用)。`ping` はカードを作らない GET で
150
- 到達だけを確かめます(副作用ゼロ・どんな例外でも False=安全側へ倒れる)。
238
+ `permission_request_hook.decide_autorun` は、関門のときだけ従来の聞く経路(`decide_by_asking`)へ回します。
239
+ Claude Code では入口の番人が先に `allow` / `deny` を返すので、ここへは来ません。
240
+ ここで関門を聞くのは Codex(下)と、経路が増えたときの二重化だけです。聞けないときは `deny` です。
151
241
 
152
- ### ★成立の前提条件(満たさないと関門は素通りします)
242
+ ★Codex から呼ばれたとき(`--codex`)は、PreToolUse の関門は従来どおり委譲します
243
+ (Codex は PreToolUse の `allow` を受け付けないので、入口で「はい」を返せません・ADR-022)。
244
+ 管制に届かなければ入口で `deny`、届くなら Codex 自身の承認の流れ(`approval_policy = "on-request"`)→
245
+ PermissionRequest の番人で、関門は2択で人に聞きます。
153
246
 
154
- 1. **関門コマンドに一致する `allow` 行が、どのスコープにも無いこと。**
155
- 規則の評価順は **deny → ask → allow** で、スコープ(user / project / local)は順序に影響しません。
156
- 一致する `allow` 行が1つでもあると、**確認ダイアログも PermissionRequest も出ずに実行されます**
157
- (一時環境で再現確認済み)。消すべき行の一覧と手順は
158
- [`docs/guard-cleanup-手順書.md`](../../docs/guard-cleanup-手順書.md) にあります。
247
+ ### 「今後は聞かない」の扱い
159
248
 
160
- 2. **「今後は聞かない」は必ず管制画面の3ボタンで受けること。**
161
- 端末のダイアログで `2. Yes, and don't ask again for: …` を押すと、
162
- **`localSettings` に `allow` 行が書き込まれ、その関門が恒久的に消えます**
163
- (`permission_suggestions` の `destination` が `localSettings` であることが根拠)。
164
- 管制経由なら PermissionRequest フックが `destination: 'session'` で処理するので、
165
- 設定ファイルは汚れず、**セッションが変われば関門は復活**します。
249
+ 関門には出しません(毎回人が判断します)。関門でない確認(`CC2_AUTORUN=0` のとき)で「今後は聞かない」を
250
+ 使うときは、**必ず管制画面の3ボタンで受けてください。**
251
+ 端末のダイアログで `2. Yes, and don't ask again for: …` を押すと、
252
+ **`localSettings` に `allow` 行が書き込まれます**(`permission_suggestions` の `destination` が `localSettings` であることが根拠)。
253
+ 管制経由なら PermissionRequest フックが `destination: 'session'` で処理するので、設定ファイルは汚れません。
254
+ 広い `allow` 行の掃除の手順は [`docs/guard-cleanup-手順書.md`](../../docs/guard-cleanup-手順書.md) にあります。
166
255
 
167
256
  ### ★逆向きの残骸=`ask` 行(関門が増えるのではなく、職人が止まります・ADR-009)
168
257
 
@@ -179,9 +268,9 @@ python3 scripts/check_permission_rules.py
179
268
 
180
269
  ### 将来の論点(記載のみ)
181
270
 
182
- ホーム設定を管理できない環境(他社へ提供した先など)では、前提条件1を保証できないため
183
- `defer` では関門を守れません。その場合は `ask`(2択)に倒す必要があります。
184
- **3択と「どの環境でも関門が消えない」は、現在の Claude Code の仕様では同時に満たせません。**
271
+ 関門は入口の番人が `allow` / `deny` を自分で返すので、ホーム設定を管理できない環境(他社へ提供した先など)でも、`allow` 行やモードで関門は消えません。
272
+ ただし番人は名前で判定するので、関門に当たらない書き方(台本の中から本番を呼ぶ等)は別に塞ぐ必要があります
273
+ (便 d1fb33ed の分類表・次の便で関門の種類を広げる)。
185
274
 
186
275
  ## 5. 管制との連絡線(段1の受け口へつなぐ)
187
276
 
@@ -9,42 +9,44 @@
9
9
 
10
10
  【判定と返し方】
11
11
  policy の 'deny' → permissionDecision="deny" … 実行させない
12
- policy の 'gate' → 原則 defer(何も出力しない)… 通常の許可フローへ渡す=人が判断する。
13
- ★ただし「聞けない」ときは deny(フェイルクローズ・下記)。
12
+ policy の 'gate' → ★その場で管制の2択カードで人に聞き、答えが出るまで返事をしない(便 ab6e36ca)。
13
+ 「はい」→ "allow"/「いいえ」・答えが来ない・管制に届かない・時間切れ → "deny"。
14
+ ★Codex から呼ばれたときだけは従来どおり委譲(下の build_output の説明)。
14
15
  policy の 'allow' → permissionDecision="allow" … 自走
15
16
 
16
- 【★聞けないときは止める(フェイルクローズ・報告 09c70615 の根治)】
17
- 関門(gate)は「defer→ハーネスが確認→管制カード」で人へ渡す設計だが、これは
18
- 【管制に到達できること】が前提。到達できないとき defer は【黙って素通り】する
19
- (実際にマージ・DB・認証の3関門が素通りしていた)。よって関門に限り:
20
- ・管制へ到達でき認証も通る(ping OK)→ 従来どおり defer(3択カードを出す)
21
- ・管制が未設定 / 到達不能 / 認証不可(ping NG)→ deny(安全のため止める)
22
- ★deny は PreToolUse で返すため、たとえ確認を出さない実行モードでも確実に止まる
23
- (defer は素通りしうるが deny は素通りしない)。関門3種以外は従来どおり(全部は止めない)。
24
- ★既定は有効。環境変数 CC2_GATE_FAILCLOSE=0 で無効化できる(管制が不通の間だけ現場を
25
- 止めたくない、という人間の判断用。既定 '1')。
26
-
27
- 【★なぜ関門は "defer" で返すのか(実機検証 26c878b7 に基づく決定)】
28
- 当初は "ask" にしていた。理由は「defer だとユーザー設定の allow 規則に拾われて素通りする」。
29
- それは事実(下記の前提条件を参照)だが、"ask" には確認カードが【2択になる】という
30
- 副作用があった。実機で確かめた結果:
31
- ・"ask" … permission_suggestions が null。画面は [Yes] [No] の2択。
32
- ・"defer" … permission_suggestions が入る。画面は
33
- [Yes] [Yes, and don't ask again for: …] [No] の3択。
34
- 3択(はい/今後は聞かない/いいえ)を残す方針のため、関門は defer で返す。
35
-
36
- ★defer が成立するための前提条件(満たさないと関門は素通りする・実機で再現済み)
37
- 1. 関門コマンドに一致する allow 行が、どのスコープ(user / project / local)にも無いこと。
38
- 規則の評価順は deny → ask → allow で、スコープは順序に影響しない。
39
- 一致する allow 行が1つでもあると、確認ダイアログも PermissionRequest も出ずに実行される。
40
- 2. 「今後は聞かない」は【管制画面の3ボタン】で受けること。
41
- 端末のダイアログで 2番を押すと localSettings に allow 行が書かれ、その関門が恒久的に消える。
42
- 管制経由なら PermissionRequest フックが destination='session' で処理するため、
43
- 設定ファイルは汚れず、セッションが変われば関門は復活する。
44
- 詳しい手順は docs/guard-cleanup-手順書.md(人間がやる掃除)を参照。
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
+ ・「いいえ」の後に職人が同じ操作を試し直したときは、もう一度カードが出る(人が止め続けられる)。
45
46
 
46
47
  【フェイルセーフ】
47
48
  例外時は何も出力せず exit 0(=委譲)。誤って allow を出さない。
49
+ ★関門で待っている最中の例外・上限到達は deny(委譲にすると auto モードで通りうるため)。
48
50
 
49
51
  【入出力(公式 hooks 仕様 v2.1.x)】
50
52
  入力(stdin JSON): {"tool_name":"Bash","tool_input":{...},"cwd":"..."}
@@ -54,40 +56,51 @@
54
56
  """
55
57
  import json
56
58
  import os
59
+ import signal
57
60
  import sys
58
61
 
59
62
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
60
63
 
61
- import hook_datalink # noqa: E402 … 起動記録 と 管制への到達確認(ping)に使う
64
+ import hook_datalink # noqa: E402 … 起動記録 と 管制への問い合わせに使う
65
+ import permission_request_hook # noqa: E402 … 関門の2択カード(ask_gate)
62
66
  import policy # noqa: E402
63
67
 
68
+ # 待ち時間の上限(秒)。根拠は冒頭の【待ち時間の上限】。
69
+ HOOK_TIMEOUT_SEC = 600
70
+ GATE_HARD_LIMIT_SEC = 570
71
+ GATE_WAIT_SEC = 480
72
+
64
73
  # policy の判定 → フックが返す permissionDecision。
65
- # ★GATE は載せない(=何も出力しない=defer)。3択を残すための決定(上の説明を参照)。
74
+ # ★GATE は 'gate'(その場で聞いて allow / deny を返す)。ask は返さない(冒頭の説明)。
66
75
  _DECISION_MAP = {
67
76
  policy.DENY: 'deny',
68
77
  policy.ALLOW: 'allow',
78
+ policy.GATE: 'gate',
69
79
  }
70
80
 
81
+ # ★上限に達したとき・待っている最中に壊れたときの文言(止めたことを人の言葉で伝える)。
82
+ _GATE_TIMEOUT_MESSAGE = (
83
+ 'お客さまの確認(管制)が時間内に取れなかったので、実行しませんでした。'
84
+ 'マージ・本番反映・データベースの変更は、確認が取れたときだけ実行します。'
85
+ )
71
86
 
72
- def _autorun(datalink):
73
- """自走モードか。★古い連絡線(この印を知らない版)が混ざっていても落ちない。
74
87
 
75
- 印を持たない=自走の仕組みがまだ入っていない版なので、従来どおり(False)に倒す。
76
- ★テスト用の偽 datalink にも無いことがあるため、既存の作法どおり getattr で守る。
77
- """
78
- fn = getattr(datalink, 'autorun_enabled', None)
79
- if not callable(fn):
80
- return False
88
+ def _shorter_only(env, name, default):
89
+ """環境変数で【短くする向きにだけ】上書きする(長くはできない=上限を越えない)。"""
81
90
  try:
82
- return bool(fn())
83
- except Exception: # noqa: BLE001
84
- return False
91
+ v = float((env.get(name) or '').strip())
92
+ except ValueError:
93
+ return default
94
+ return min(default, v) if v > 0 else default
85
95
 
86
96
 
87
- def _failclose_enabled(env=None):
88
- """関門のフェイルクローズが有効か。既定は有効('1')。'0' で無効。"""
89
- env = os.environ if env is None else env
90
- return (env.get('CC2_GATE_FAILCLOSE') or '1').strip() != '0'
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)
99
+
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)
91
104
 
92
105
 
93
106
  def _deny_output(reason):
@@ -100,6 +113,35 @@ def _deny_output(reason):
100
113
  }
101
114
 
102
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
+
103
145
  def build_output(data, datalink=hook_datalink, codex=False):
104
146
  """入力1件から出力(dict) を作る。返り値 None は「何も出力しない(委譲=defer)」。
105
147
 
@@ -121,40 +163,31 @@ def build_output(data, datalink=hook_datalink, codex=False):
121
163
  pass
122
164
 
123
165
  decision = _DECISION_MAP.get(result['decision'])
166
+ if decision is None:
167
+ return None # 想定外の判定 → 委譲(allow を勝手に出さない)
124
168
  if codex and decision == 'allow':
125
169
  return None # ★Codex は allow を受け付けない(上の説明)。委譲は allow と同じ意味。
126
- if decision is not None:
127
- return {
128
- 'hookSpecificOutput': {
129
- 'hookEventName': 'PreToolUse',
130
- 'permissionDecision': decision,
131
- 'permissionDecisionReason': result['reason'],
132
- }
133
- }
134
170
 
135
- # ここに来るのは関門(gate)だけ。既定は defer(3択カードを出す)だが、
136
- # 「聞けない」状況(管制不通・未設定)では素通りさせず deny する=フェイルクローズ。
137
- #
138
- # ★自走モードでは、このフェイルクローズは通さない(えふさん決定 2026-09-06)。
139
- # フェイルクローズの理由は【人に聞けないから止める】だった。自走モードでは
140
- # そもそも人に聞かない(PermissionRequest の番人がその場で allow を返す)ので、
141
- # 止める理由が無い。ここを残すと「管制が落ちている間だけ職人が止まる」ことになり、
142
- # 決定(すべて自走)と食い違う。★deny の判定そのもの(危険・持ち出し)は上の
143
- # _DECISION_MAP で既に返しており、ここには来ない=安全側は1ミリも動いていない。
144
- if _failclose_enabled() and not _autorun(datalink):
145
- try:
146
- cfg = datalink.load_config()
147
- except Exception: # noqa: BLE001
148
- cfg = None
149
- reachable = datalink.ping(cfg) if cfg is not None else False
150
- if not reachable:
151
- return _deny_output(
152
- 'お客さまへの確認(管制)に今つながらないため、安全のためこの操作を止めました。'
153
- 'マージ・本番反映・データベースの変更は、確認できないときは実行しません。'
154
- '連絡線が回復してから、もう一度お試しください。'
155
- )
156
- # 管制に到達できる(または無効化された)→ 従来どおり defer(人が3択で判断)。
157
- 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
183
+
184
+ return {
185
+ 'hookSpecificOutput': {
186
+ 'hookEventName': 'PreToolUse',
187
+ 'permissionDecision': decision,
188
+ 'permissionDecisionReason': result['reason'],
189
+ }
190
+ }
158
191
 
159
192
 
160
193
  def main() -> int:
@@ -166,13 +199,38 @@ def main() -> int:
166
199
  data = json.loads(raw) if raw.strip() else {}
167
200
  except Exception: # noqa: BLE001
168
201
  return 0 # 入力不正 → 委譲(フェイルセーフ)
202
+ # ★最後の歯止め: Claude Code の上限(HOOK_TIMEOUT_SEC)に打ち切られる前に、何をしていても
203
+ # deny を書いて終わる。打ち切られると番人の答えは捨てられ、実行されてしまう(冒頭の実測)。
204
+ # ふつうの操作は一瞬で終わるので、ここに達するのは関門で待っているときだけ。
205
+ _arm_hard_limit()
169
206
  try:
170
207
  out = build_output(data, codex=codex)
171
- if out is not None:
172
- sys.stdout.write(json.dumps(out, ensure_ascii=False))
173
- return 0
174
208
  except Exception: # noqa: BLE001
175
- 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())))
176
234
 
177
235
 
178
236
  if __name__ == '__main__':