@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.
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """関門は入口の番人が管制に聞いて【答えが返るまで実行させない】(便 ab6e36ca)の実物の試験。
3
+
4
+ 実行: python3 scripts/hooks/test_gate_wait.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【なぜ要るか】
8
+ 便 c525bb2b の実機: 職人が auto モードで動くと、関門を ask で返しても Claude Code は
9
+ 確認の番人(管制の答え待ち)を待たずに実行を許した。入口の番人の deny はどのモードでも効くので、
10
+ 入口の番人が自分で聞いて待ち、答えが無ければ deny を返す形にした。
11
+ ここでは【実物の cc2_guard.py を別の手続きとして起動】し、手元に立てた偽の管制と
12
+ 本物の通信(HTTP)でやり取りさせて確かめる(関数の差し替えではない)。
13
+
14
+ 【何を守っているか】
15
+ ① 答えが来ない関門は deny(=どのモードでも実行されない)。カードを閉じる(DELETE)
16
+ ② 「いいえ」は deny/「はい」は allow(この1回だけ)
17
+ ③ 管制に届かない関門は deny
18
+ ④ 管制が応答しないまま固まっても、最後の歯止めで上限の内側に deny を返す
19
+ ⑤ ふつうの操作は管制に1回も聞かない
20
+ ⑥ カードは2択・期限(waitMs)は待つ上限と同じ
21
+ ⑦ 同じ操作の答えを使い回さない(1回の実行ごとに新しい鍵・登録の時点の答えは使わない)
22
+
23
+ ★空振り確認: 待つ処理(ask_gate_now)を外して ask を返す形に戻すと ①②④ が落ちる。
24
+ """
25
+ import json
26
+ import os
27
+ import socket
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import threading
32
+ import time
33
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
+ from urllib.parse import parse_qs, urlparse
35
+
36
+ HERE = os.path.dirname(os.path.abspath(__file__))
37
+ GUARD = os.path.join(HERE, 'cc2_guard.py')
38
+
39
+ failures = []
40
+
41
+
42
+ def check(label, got, want):
43
+ ok = got == want
44
+ if not ok:
45
+ failures.append(f'{label}: got={got!r} want={want!r}')
46
+ print(f'{"OK " if ok else "NG "} {label:62} {str(got)[:50]}')
47
+
48
+
49
+ class FakeConsole:
50
+ """偽の管制。mode で答え方を決める: allow / deny / none(答えない)/ hang(固まる)。"""
51
+
52
+ def __init__(self, mode):
53
+ self.mode = mode
54
+ self.posts, self.gets, self.deletes = [], 0, 0
55
+ console = self
56
+
57
+ class Handler(BaseHTTPRequestHandler):
58
+ def log_message(self, *a): # 試験の出力を汚さない
59
+ pass
60
+
61
+ def _send(self, obj):
62
+ body = json.dumps(obj).encode()
63
+ self.send_response(200)
64
+ self.send_header('Content-Type', 'application/json')
65
+ self.send_header('Content-Length', str(len(body)))
66
+ self.end_headers()
67
+ self.wfile.write(body)
68
+
69
+ def do_POST(self):
70
+ n = int(self.headers.get('Content-Length') or 0)
71
+ console.posts.append(json.loads(self.rfile.read(n) or b'{}'))
72
+ if console.mode == 'preanswered':
73
+ # 管制が同じ鍵の行をまとめ、前に出た答えを返してきた形
74
+ self._send({'id': 'req-1', 'status': 'answered', 'decision': 'allow'})
75
+ return
76
+ self._send({'id': 'req-1', 'status': 'waiting'})
77
+
78
+ def do_GET(self):
79
+ console.gets += 1
80
+ if console.mode == 'hang':
81
+ time.sleep(30)
82
+ return
83
+ if console.mode in ('allow', 'deny'):
84
+ self._send({'status': 'answered', 'outcome': 'decided', 'decision': console.mode})
85
+ return
86
+ wait_ms = int((parse_qs(urlparse(self.path).query).get('waitMs') or ['0'])[0])
87
+ time.sleep(wait_ms / 1000)
88
+ self._send({'status': 'waiting', 'outcome': 'pending'})
89
+
90
+ def do_DELETE(self):
91
+ console.deletes += 1
92
+ self._send({})
93
+
94
+ self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
95
+ self.server.daemon_threads = True
96
+ self.url = f'http://127.0.0.1:{self.server.server_address[1]}'
97
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
98
+
99
+ def close(self):
100
+ self.server.shutdown()
101
+
102
+
103
+ def free_port():
104
+ s = socket.socket()
105
+ s.bind(('127.0.0.1', 0))
106
+ port = s.getsockname()[1]
107
+ s.close()
108
+ return port
109
+
110
+
111
+ def run_guard(command, url, extra_env=None, home=None):
112
+ """実物の番人を起動し、(permissionDecision, 理由, かかった秒) を返す。"""
113
+ env = {
114
+ 'PATH': os.environ.get('PATH', ''),
115
+ 'HOME': home,
116
+ 'CONDUCTOR_URL': url,
117
+ 'CONDUCTOR_TOKEN': 'test-token-not-secret',
118
+ 'CONDUCTOR_GUARD_LOG': os.path.join(home, 'guard.log'),
119
+ 'CONDUCTOR_HOOK_LOG': os.path.join(home, 'hook.log'),
120
+ 'CC2_GATE_WAIT_SEC': '2',
121
+ }
122
+ env.update(extra_env or {})
123
+ event = {'tool_name': 'Bash', 'tool_input': {'command': command, 'description': '見本'},
124
+ 'cwd': '/home/x/projects/demo', 'session_id': 'gate-wait-test'}
125
+ t0 = time.time()
126
+ p = subprocess.run([sys.executable, GUARD], input=json.dumps(event), capture_output=True,
127
+ text=True, env=env, timeout=60)
128
+ took = time.time() - t0
129
+ if not p.stdout.strip():
130
+ return None, None, took
131
+ out = json.loads(p.stdout)['hookSpecificOutput']
132
+ return out.get('permissionDecision'), out.get('permissionDecisionReason'), took
133
+
134
+
135
+ # 関門 db の見本(危険語を含まない・実行はしない)。★psql は便 fc22bc34 で deny へ移したので、
136
+ # 今も関門のまま残る「台本に SQL の字面を渡す形」を使う。
137
+ GATE_CMD = 'python3 apply.py --mode grant'
138
+ ROUTINE_CMD = 'npm test'
139
+
140
+ with tempfile.TemporaryDirectory() as home:
141
+ print('── ① 答えが来ない関門は deny(どのモードでも実行されない) ' + '─' * 10)
142
+ c = FakeConsole('none')
143
+ d, reason, took = run_guard(GATE_CMD, c.url, home=home)
144
+ check('① 答えが来ない → deny', d, 'deny')
145
+ check('① 理由は人の言葉(確認が取れなかった)', '確認' in (reason or '') and '実行しません' in reason, True)
146
+ check('① ★ask を返さない(auto モードで通りうる返事をしない)', d != 'ask', True)
147
+ check('① 管制へ1回だけ聞いた(カード1枚)', len(c.posts), 1)
148
+ check('① 答えを待った(問い合わせ1回以上)', c.gets >= 1, True)
149
+ check('① 答えが来ないのでカードを閉じた', c.deletes, 1)
150
+ check('① 待つ上限(2秒)で返した', 1.5 <= took < 10, True)
151
+ c.close()
152
+
153
+ print('── ② 「いいえ」は deny/「はい」は allow ' + '─' * 26)
154
+ c = FakeConsole('deny')
155
+ d, reason, _ = run_guard(GATE_CMD, c.url, home=home)
156
+ check('② 「いいえ」→ deny', d, 'deny')
157
+ check('② 理由に「いいえ」', '「いいえ」' in (reason or ''), True)
158
+ c.close()
159
+ c = FakeConsole('allow')
160
+ d, reason, _ = run_guard(GATE_CMD, c.url, home=home)
161
+ check('② 「はい」→ allow', d, 'allow')
162
+ check('② 「はい」のときカードは閉じない(答えがある)', c.deletes, 0)
163
+ c.close()
164
+
165
+ print('── ③ 管制に届かない関門は deny ' + '─' * 36)
166
+ d, reason, took = run_guard(GATE_CMD, f'http://127.0.0.1:{free_port()}', home=home)
167
+ check('③ 届かない → deny', d, 'deny')
168
+ check('③ 理由は人の言葉(つながらない)', 'つながらない' in (reason or ''), True)
169
+ check('③ すぐ返る', took < 10, True)
170
+
171
+ print('── ④ 管制が固まっても、最後の歯止めで上限の内側に deny ' + '─' * 12)
172
+ c = FakeConsole('hang')
173
+ d, reason, took = run_guard(GATE_CMD, c.url, home=home,
174
+ extra_env={'CC2_GATE_WAIT_SEC': '20', 'CC2_GATE_HARD_LIMIT_SEC': '3'})
175
+ check('④ 固まった管制 → deny', d, 'deny')
176
+ check('④ 歯止め(3秒)で返した', 2.5 <= took < 8, True)
177
+ check('④ 理由は人の言葉(時間内に取れなかった)', '時間内' in (reason or ''), True)
178
+ c.close()
179
+
180
+ print('── ⑤ ふつうの操作は管制に1回も聞かない ' + '─' * 28)
181
+ c = FakeConsole('none')
182
+ d, _, took = run_guard(ROUTINE_CMD, c.url, home=home)
183
+ check('⑤ npm test → allow', d, 'allow')
184
+ check('⑤ 管制へ0回', (len(c.posts), c.gets), (0, 0))
185
+ check('⑤ すぐ返る', took < 5, True)
186
+ c.close()
187
+
188
+ print('── ⑥ カードは2択・期限は待つ上限と同じ ' + '─' * 28)
189
+ c = FakeConsole('allow')
190
+ run_guard(GATE_CMD, c.url, home=home)
191
+ p = c.posts[0] if c.posts else {}
192
+ check('⑥ 2択(今後は聞かないを出さない)', [x.get('value') for x in p.get('choices', [])],
193
+ ['allow', 'deny'])
194
+ check('⑥ カードの期限 = 待つ上限(2秒)', p.get('waitMs'), 2000)
195
+ check('⑥ 題はデータベースの変更', (p.get('summary') or '').startswith('【お客さまの確認:データベース'),
196
+ True)
197
+ c.close()
198
+
199
+ print('── ⑦ 同じ操作の答えを使い回さない ' + '─' * 34)
200
+ c = FakeConsole('allow')
201
+ run_guard(GATE_CMD, c.url, home=home)
202
+ run_guard(GATE_CMD, c.url, home=home)
203
+ keys = [x.get('requestKey') for x in c.posts]
204
+ check('⑦ 同じ操作を2回 → カードも2枚・鍵は毎回ちがう', len(set(k for k in keys if k)), 2)
205
+ c.close()
206
+ c = FakeConsole('preanswered')
207
+ d, _, _ = run_guard(GATE_CMD, c.url, home=home)
208
+ check('⑦ ★登録の時点で付いてきた「はい」は使わず、答えを待つ(来なければ deny)', d, 'deny')
209
+ c.close()
210
+
211
+ print('\n' + '=' * 72)
212
+ if failures:
213
+ print(f'NG {len(failures)} 件:')
214
+ for f in failures:
215
+ print(' -', f)
216
+ sys.exit(1)
217
+ print('すべて期待どおり')
218
+ sys.exit(0)
@@ -40,11 +40,8 @@ def check(label, got, want):
40
40
  # (a) 3関門は必ず gate(=人へ)
41
41
  # ─────────────────────────────────────────────────────────────────────────────
42
42
  GATE_CASES = [
43
- # 関門1 本番ブランチの直接変更(★PRのマージは関門ではない → (a4) で確かめる)
44
- ('git push origin main', policy.GATE_MERGE),
45
- ('git push -u origin main', policy.GATE_MERGE),
46
- ('git push origin HEAD:master', policy.GATE_MERGE),
47
- ('git push origin production', policy.GATE_MERGE),
43
+ # ★旧 関門1(main など保護ブランチへの直接 push)は便 fc22bc34 で deny へ移した
44
+ # → test_bypass_deny.py で確かめる。PRのマージは関門ではない → (a4)。
48
45
  # 関門2 本番確認
49
46
  ('vercel --prod', policy.GATE_RELEASE),
50
47
  ('vercel deploy --prod', policy.GATE_RELEASE),
@@ -56,14 +53,10 @@ GATE_CASES = [
56
53
  ('supabase functions deploy hello', policy.GATE_RELEASE),
57
54
  ('npm run deploy -- --production', policy.GATE_RELEASE),
58
55
  # 関門3 DB実行
59
- ('psql -c "select 1"', policy.GATE_DB),
60
- ('npx prisma migrate deploy', policy.GATE_DB),
61
- ('npx prisma db push', policy.GATE_DB),
62
- ('supabase db push', policy.GATE_DB),
63
- ('supabase migration up', policy.GATE_DB),
64
- ('npx drizzle-kit push', policy.GATE_DB),
56
+ # ★本番 DB を直接変える道具(psql・prisma db push / migrate deploy・supabase db push /
57
+ # migration up・drizzle-kit push 等)は便 fc22bc34 で deny へ移した(設定の deny と揃える)
58
+ # → test_bypass_deny.py。ここに残るのは、それ以外の DB 実行(見分けられないので人に聞く)。
65
59
  ('createdb newdb', policy.GATE_DB),
66
- ('echo "CREATE TABLE x (id uuid)" | psql', policy.GATE_DB),
67
60
  ('supabase db query "GRANT SELECT ON x TO authenticated"', policy.GATE_DB),
68
61
  ('run-sql "ALTER TABLE x ENABLE ROW LEVEL SECURITY"', policy.GATE_DB),
69
62
  ('apply "CREATE POLICY p ON x"', policy.GATE_DB),
@@ -81,10 +74,12 @@ for cmd, want_gate in GATE_CASES:
81
74
  class GuardDatalink:
82
75
  """cc2_guard 用の偽 datalink(到達可否を固定し、ネットワークもディスクも触らない)。"""
83
76
 
84
- def __init__(self, cfg, reachable):
77
+ def __init__(self, cfg, reachable, answer='allow'):
85
78
  self._cfg = cfg
86
79
  self._reachable = reachable
80
+ self._answer = answer
87
81
  self.decisions = []
82
+ self.registered = 0
88
83
 
89
84
  def load_config(self):
90
85
  return self._cfg
@@ -92,20 +87,40 @@ class GuardDatalink:
92
87
  def ping(self, cfg):
93
88
  return self._reachable
94
89
 
90
+ # ★便 ab6e36ca: 入口の番人が関門をその場で聞く。届かなければ登録できない。
91
+ def register(self, cfg, payload):
92
+ self.registered += 1
93
+ return {'id': 'r1', 'status': 'waiting'} if self._reachable else None
94
+
95
+ def wait_for_decision(self, cfg, request_id, max_wait_sec=None):
96
+ return self._answer
97
+
98
+ def abandon(self, cfg, request_id):
99
+ pass
100
+
95
101
  def record_decision(self, tool_name, decision, gate=None):
96
102
  self.decisions.append((tool_name, decision, gate))
97
103
 
98
104
 
99
- GATE_CMDS = ['git push origin main', 'vercel --prod', 'npx prisma migrate deploy',
100
- 'git push -u origin master', 'npm publish', 'supabase db push',
101
- 'psql -c "select 1"']
105
+ GATE_CMDS = ['vercel --prod', 'npm publish', 'createdb newdb',
106
+ 'supabase db query "select 1"', 'run-sql "ALTER TABLE x ADD COLUMN y int"']
102
107
 
103
- print('\n=== (a2) 関門は「管制に到達できる」ときは ask(確認を必ず出す) ===')
108
+ print('\n=== (a2) 関門は入口の番人がその場で管制に聞き、答えで allow / deny を返す(便 ab6e36ca) ===')
104
109
  gd_ok = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True)
105
110
  for cmd in GATE_CMDS:
106
111
  out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': cmd}}, datalink=gd_ok)
107
- check(f'{cmd} → ask(到達可)', out['hookSpecificOutput']['permissionDecision'] if out else None, 'ask')
112
+ check(f'{cmd} → allow(「はい」)', out['hookSpecificOutput']['permissionDecision'] if out else None,
113
+ 'allow')
114
+ check('関門ごとに管制へ1回聞いた', gd_ok.registered, len(GATE_CMDS))
108
115
  check('起動記録が残る(呼ばれた事実)', len(gd_ok.decisions) >= len(GATE_CMDS), True)
116
+ for answer in ('deny', None, 'weird'):
117
+ gd = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True, answer=answer)
118
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
119
+ datalink=gd)
120
+ check(f'答え {answer!r} → deny', out['hookSpecificOutput']['permissionDecision'], 'deny')
121
+ for cmd in GATE_CMDS:
122
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': cmd}}, datalink=gd_ok)
123
+ check(f'★ask は返さない: {cmd[:40]}', out['hookSpecificOutput']['permissionDecision'] != 'ask', True)
109
124
 
110
125
  print('\n=== (a3) ★フェイルクローズ: 聞けないとき関門は deny(素通りさせない) ===')
111
126
  gd_ng = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=False)
@@ -115,7 +130,7 @@ for cmd in GATE_CMDS:
115
130
  check(f'{cmd} → deny(管制不通)', got, 'deny')
116
131
 
117
132
  gd_unset = GuardDatalink(None, reachable=False) # 管制未設定(load_config が None)
118
- out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'git push origin main'}},
133
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
119
134
  datalink=gd_unset)
120
135
  check('管制未設定でも関門は deny', out['hookSpecificOutput']['permissionDecision'], 'deny')
121
136
 
@@ -127,11 +142,17 @@ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'ca
127
142
  datalink=gd_unset)
128
143
  check('危険は管制不通でも deny のまま', out['hookSpecificOutput']['permissionDecision'], 'deny')
129
144
 
130
- # 無効化フラグ(人間が「不通の間は止めない」と決めたとき)→ 止めずに ask(確認は出す)。
145
+ # ★便 ab6e36ca: 以前の無効化フラグ(CC2_GATE_FAILCLOSE=0 で止めずに ask)は外した。
146
+ # ask は auto モードでは人の答えを待たずに実行されうる(便 c525bb2b)=止めが消える回避路になるため。
131
147
  os.environ['CC2_GATE_FAILCLOSE'] = '0'
132
- out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'git push origin main'}},
148
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
133
149
  datalink=gd_unset)
134
- check('CC2_GATE_FAILCLOSE=0 なら関門は ask', out['hookSpecificOutput']['permissionDecision'], 'ask')
150
+ check('★CC2_GATE_FAILCLOSE=0 でも関門は deny(回避路を作らない)',
151
+ out['hookSpecificOutput']['permissionDecision'], 'deny')
152
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
153
+ datalink=gd_ng)
154
+ check('★CC2_GATE_FAILCLOSE=0 でも管制不通の関門は deny',
155
+ out['hookSpecificOutput']['permissionDecision'], 'deny')
135
156
  os.environ.pop('CC2_GATE_FAILCLOSE', None)
136
157
 
137
158
  # 関門でも、人に見せる日本語1行は用意できている(管制のカードへ送る文言)。
@@ -150,7 +171,8 @@ MERGE_ALLOW = [
150
171
  'gh pr merge --auto 12',
151
172
  'gh pr merge 3 --merge --delete-branch',
152
173
  'gh pr merge --rebase 7',
153
- 'gh api -X PUT repos/o/r/pulls/1/merge',
174
+ # ★API での合流(gh api -X PUT …/pulls/N/merge)は便 fc22bc34 で deny へ移した
175
+ # (保護の迂回・えふさん確定 2026-09-18)→ test_bypass_deny.py。
154
176
  'cd /srv/app && gh pr merge 12 --squash', # 複合コマンドでも自走
155
177
  'bash -c "gh pr merge 12 --squash"', # 包んでも自走
156
178
  'gh pr merge 12 --squash && git pull',
@@ -191,12 +213,12 @@ for _gd in (GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True),
191
213
  out['hookSpecificOutput']['permissionDecision'], 'allow')
192
214
 
193
215
  # ★マージを外しても、他の関門と危険は一切緩めていない(取り違え防止の回帰テスト)。
194
- check('main 直 push は関門のまま',
195
- policy.decide_bash('git push origin main')['gate'], policy.GATE_MERGE)
216
+ check('main 直 push は deny(便 fc22bc34 で関門から移した)',
217
+ policy.decide_bash('git push origin main')['decision'], policy.DENY)
196
218
  check('force push は deny のまま',
197
219
  policy.decide_bash('git push --force origin main')['decision'], policy.DENY)
198
- check('DB実行は関門のまま',
199
- policy.decide_bash('supabase db push')['gate'], policy.GATE_DB)
220
+ check('DB実行(見分けられないもの)は関門のまま',
221
+ policy.decide_bash('createdb newdb')['gate'], policy.GATE_DB)
200
222
  check('SQL DDL は関門のまま',
201
223
  policy.decide_bash('run-sql "CREATE TABLE x (id int)"')['gate'], policy.GATE_DB)
202
224
  check('本番反映は関門のまま',
@@ -537,12 +559,11 @@ check('枝への push は通常の文言',
537
559
  summary_ja.summarize_bash('git push origin feat/main-fix'),
538
560
  '作業内容をGitHubへ送ってよいですか')
539
561
  # ★見出しは「関門に当たったセグメント」から作る(複合コマンドの先頭語 cd に引きずられない)。
540
- check('複合コマンドでも関門セグメントから見出し(main直変更)',
541
- summary_ja.summarize_bash('cd /srv/app && git push origin main', 'merge'),
542
- '【お客さまの確認:本番の元を直接変更】本番の元(main)を直接書き換えてよいですか')
543
- check('複合コマンドでも関門セグメントから見出し(DB)',
544
- summary_ja.summarize_bash('cd /srv && supabase db push', 'db'),
545
- '【お客さまの確認:データベースの変更】データベースの構造を変更してよいですか')
562
+ # ★main 直 push と本番 DB を直接変える道具は、便 fc22bc34 で関門から deny へ移した
563
+ # (カードは出ない)。見出しの作り方は、今も関門のままの操作で確かめる。
564
+ check('複合コマンドでも関門セグメントから見出し(公開)',
565
+ summary_ja.summarize_bash('cd /srv/app && npm publish', 'release'),
566
+ '【お客さまの確認:本番へ反映】部品を公開(配布)してよいですか')
546
567
 
547
568
 
548
569
  # ─────────────────────────────────────────────────────────────────────────────
@@ -634,7 +655,7 @@ check('保存先は session(設定を汚さない)', up[0]['destination'], '
634
655
 
635
656
  # 関門には恒久許可を与えない。
636
657
  fd = FakeDatalink('allow_always')
637
- GATE_EV = {'tool_name': 'Bash', 'tool_input': {'command': 'git push origin main'},
658
+ GATE_EV = {'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'},
638
659
  'cwd': '/home/fshim/projects/conductor', 'session_id': 's1'}
639
660
  out = permission_request_hook.decide(GATE_EV, cfg={'url': 'http://x', 'token': 't'}, datalink=fd)
640
661
  check('関門に恒久許可を与えない',
@@ -664,7 +685,7 @@ check('送る payload に choices が入る(3択)',
664
685
 
665
686
  # ★フェイルクローズ: 関門は「聞けない」とき deny を返す(委譲=素通りにしない)。
666
687
  print('\n=== (g2) PermissionRequest フックのフェイルクローズ(関門のみ) ===')
667
- GATE_PR_EV = {'tool_name': 'Bash', 'tool_input': {'command': 'git push origin main'},
688
+ GATE_PR_EV = {'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'},
668
689
  'cwd': '/home/fshim/projects/conductor', 'session_id': 's1'}
669
690
 
670
691