@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 +1 -1
- package/scripts/hooks/README.md +127 -38
- package/scripts/hooks/cc2_guard.py +141 -83
- package/scripts/hooks/permission_request_hook.py +75 -10
- package/scripts/hooks/policy.py +25 -2
- package/scripts/hooks/settings.sample.json +1 -0
- package/scripts/hooks/test_autorun.py +249 -21
- package/scripts/hooks/test_gate_wait.py +216 -0
- package/scripts/hooks/test_policy.py +59 -10
package/package.json
CHANGED
package/scripts/hooks/README.md
CHANGED
|
@@ -4,16 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
## 0. ★いまは「自走モード」が既定です(えふさん決定 2026-09-06)
|
|
6
6
|
|
|
7
|
-
|
|
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実行) |
|
|
14
|
-
| そのほかの確認(`cd` / `npx` / 削除 など) |
|
|
15
|
-
|
|
|
16
|
-
| 管制へつながらないとき |
|
|
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実行) |
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
ところが `defer` には**前提条件**がありました。関門コマンドに一致する `allow` 行が**どのスコープにも無いこと**。
|
|
211
|
+
一致する `allow` 行が1つでもあると、**確認ダイアログも PermissionRequest も出ずに実行されます**。
|
|
212
|
+
実際の利用者設定には `Bash(python3:*)` などの広い `allow` 行があり、関門の判定が出たのに確認なしで実行された行が
|
|
213
|
+
21件ありました(便 e6c5658a の実測。中身はすべて安全でした)。
|
|
133
214
|
|
|
134
|
-
|
|
215
|
+
一方、**関門の確認はもともと2択**です(`permission_request_hook.build_choices`・関門に「今後は聞かない」は出さない)。
|
|
216
|
+
`ask` にしても失うものが無いので、**関門は `ask`** で返します。
|
|
135
217
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
`
|
|
142
|
-
|
|
233
|
+
★この `ask` の作りは、便 ab6e36ca で「入口の番人が聞いて待つ」に置き換えました(4節)。
|
|
234
|
+
`ask` が `allow` 行に勝つことは今も正しいですが、auto モードでは人の答えを待たずに通りうるためです。
|
|
143
235
|
|
|
144
|
-
|
|
145
|
-
- 管制が未設定/到達不能/認証不可/答えが取れない → **`deny`**(安全のため止める)
|
|
236
|
+
### 確認の番人(PermissionRequest)に残る関門の聞き方
|
|
146
237
|
|
|
147
|
-
`
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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' →
|
|
13
|
-
|
|
12
|
+
policy の 'gate' → ★その場で管制の2択カードで人に聞き、答えが出るまで返事をしない(便 ab6e36ca)。
|
|
13
|
+
「はい」→ "allow"/「いいえ」・答えが来ない・管制に届かない・時間切れ → "deny"。
|
|
14
|
+
★Codex から呼ばれたときだけは従来どおり委譲(下の build_output の説明)。
|
|
14
15
|
policy の 'allow' → permissionDecision="allow" … 自走
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
・
|
|
32
|
-
・
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 … 起動記録 と
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
except
|
|
84
|
-
return
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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__':
|