@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.
@@ -0,0 +1,216 @@
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
+ GATE_CMD = 'psql -c "select 1"' # 関門 db の見本(危険語を含まない・実行はしない)
136
+ ROUTINE_CMD = 'npm test'
137
+
138
+ with tempfile.TemporaryDirectory() as home:
139
+ print('── ① 答えが来ない関門は deny(どのモードでも実行されない) ' + '─' * 10)
140
+ c = FakeConsole('none')
141
+ d, reason, took = run_guard(GATE_CMD, c.url, home=home)
142
+ check('① 答えが来ない → deny', d, 'deny')
143
+ check('① 理由は人の言葉(確認が取れなかった)', '確認' in (reason or '') and '実行しません' in reason, True)
144
+ check('① ★ask を返さない(auto モードで通りうる返事をしない)', d != 'ask', True)
145
+ check('① 管制へ1回だけ聞いた(カード1枚)', len(c.posts), 1)
146
+ check('① 答えを待った(問い合わせ1回以上)', c.gets >= 1, True)
147
+ check('① 答えが来ないのでカードを閉じた', c.deletes, 1)
148
+ check('① 待つ上限(2秒)で返した', 1.5 <= took < 10, True)
149
+ c.close()
150
+
151
+ print('── ② 「いいえ」は deny/「はい」は allow ' + '─' * 26)
152
+ c = FakeConsole('deny')
153
+ d, reason, _ = run_guard(GATE_CMD, c.url, home=home)
154
+ check('② 「いいえ」→ deny', d, 'deny')
155
+ check('② 理由に「いいえ」', '「いいえ」' in (reason or ''), True)
156
+ c.close()
157
+ c = FakeConsole('allow')
158
+ d, reason, _ = run_guard(GATE_CMD, c.url, home=home)
159
+ check('② 「はい」→ allow', d, 'allow')
160
+ check('② 「はい」のときカードは閉じない(答えがある)', c.deletes, 0)
161
+ c.close()
162
+
163
+ print('── ③ 管制に届かない関門は deny ' + '─' * 36)
164
+ d, reason, took = run_guard(GATE_CMD, f'http://127.0.0.1:{free_port()}', home=home)
165
+ check('③ 届かない → deny', d, 'deny')
166
+ check('③ 理由は人の言葉(つながらない)', 'つながらない' in (reason or ''), True)
167
+ check('③ すぐ返る', took < 10, True)
168
+
169
+ print('── ④ 管制が固まっても、最後の歯止めで上限の内側に deny ' + '─' * 12)
170
+ c = FakeConsole('hang')
171
+ d, reason, took = run_guard(GATE_CMD, c.url, home=home,
172
+ extra_env={'CC2_GATE_WAIT_SEC': '20', 'CC2_GATE_HARD_LIMIT_SEC': '3'})
173
+ check('④ 固まった管制 → deny', d, 'deny')
174
+ check('④ 歯止め(3秒)で返した', 2.5 <= took < 8, True)
175
+ check('④ 理由は人の言葉(時間内に取れなかった)', '時間内' in (reason or ''), True)
176
+ c.close()
177
+
178
+ print('── ⑤ ふつうの操作は管制に1回も聞かない ' + '─' * 28)
179
+ c = FakeConsole('none')
180
+ d, _, took = run_guard(ROUTINE_CMD, c.url, home=home)
181
+ check('⑤ npm test → allow', d, 'allow')
182
+ check('⑤ 管制へ0回', (len(c.posts), c.gets), (0, 0))
183
+ check('⑤ すぐ返る', took < 5, True)
184
+ c.close()
185
+
186
+ print('── ⑥ カードは2択・期限は待つ上限と同じ ' + '─' * 28)
187
+ c = FakeConsole('allow')
188
+ run_guard(GATE_CMD, c.url, home=home)
189
+ p = c.posts[0] if c.posts else {}
190
+ check('⑥ 2択(今後は聞かないを出さない)', [x.get('value') for x in p.get('choices', [])],
191
+ ['allow', 'deny'])
192
+ check('⑥ カードの期限 = 待つ上限(2秒)', p.get('waitMs'), 2000)
193
+ check('⑥ 題はデータベースの変更', (p.get('summary') or '').startswith('【お客さまの確認:データベース'),
194
+ True)
195
+ c.close()
196
+
197
+ print('── ⑦ 同じ操作の答えを使い回さない ' + '─' * 34)
198
+ c = FakeConsole('allow')
199
+ run_guard(GATE_CMD, c.url, home=home)
200
+ run_guard(GATE_CMD, c.url, home=home)
201
+ keys = [x.get('requestKey') for x in c.posts]
202
+ check('⑦ 同じ操作を2回 → カードも2枚・鍵は毎回ちがう', len(set(k for k in keys if k)), 2)
203
+ c.close()
204
+ c = FakeConsole('preanswered')
205
+ d, _, _ = run_guard(GATE_CMD, c.url, home=home)
206
+ check('⑦ ★登録の時点で付いてきた「はい」は使わず、答えを待つ(来なければ deny)', d, 'deny')
207
+ c.close()
208
+
209
+ print('\n' + '=' * 72)
210
+ if failures:
211
+ print(f'NG {len(failures)} 件:')
212
+ for f in failures:
213
+ print(' -', f)
214
+ sys.exit(1)
215
+ print('すべて期待どおり')
216
+ sys.exit(0)
@@ -74,17 +74,19 @@ for cmd, want_gate in GATE_CASES:
74
74
  r = policy.decide_bash(cmd)
75
75
  check(f'gate {cmd[:58]}', (r['decision'], r['gate']), (policy.GATE, want_gate))
76
76
 
77
- # ★関門はフックから、管制に到達できるときだけ "defer"(=何も出力しない)で返る。
78
- # 実機検証 26c878b7: defer なら確認カードが3択(はい/今後は聞かない/いいえ)になる。
79
- # ask にすると permission_suggestions が null になり2択に落ちるため、defer を選ぶ。
77
+ # ★関門はフックから、管制に到達できるときは "ask"(確認を必ず出す)で返る(便 918ce835)。
78
+ # 以前は defer(何も出力しない)だったが、defer は一致する allow 規則に負けて確認なしで
79
+ # 実行される(便 e6c5658a の実測で21件)。関門の確認はもともと2択なので ask で失うものは無い。
80
80
  # 到達できないときは deny(フェイルクローズ)——下の (a3) で確かめる。
81
81
  class GuardDatalink:
82
82
  """cc2_guard 用の偽 datalink(到達可否を固定し、ネットワークもディスクも触らない)。"""
83
83
 
84
- def __init__(self, cfg, reachable):
84
+ def __init__(self, cfg, reachable, answer='allow'):
85
85
  self._cfg = cfg
86
86
  self._reachable = reachable
87
+ self._answer = answer
87
88
  self.decisions = []
89
+ self.registered = 0
88
90
 
89
91
  def load_config(self):
90
92
  return self._cfg
@@ -92,6 +94,17 @@ class GuardDatalink:
92
94
  def ping(self, cfg):
93
95
  return self._reachable
94
96
 
97
+ # ★便 ab6e36ca: 入口の番人が関門をその場で聞く。届かなければ登録できない。
98
+ def register(self, cfg, payload):
99
+ self.registered += 1
100
+ return {'id': 'r1', 'status': 'waiting'} if self._reachable else None
101
+
102
+ def wait_for_decision(self, cfg, request_id, max_wait_sec=None):
103
+ return self._answer
104
+
105
+ def abandon(self, cfg, request_id):
106
+ pass
107
+
95
108
  def record_decision(self, tool_name, decision, gate=None):
96
109
  self.decisions.append((tool_name, decision, gate))
97
110
 
@@ -100,12 +113,22 @@ GATE_CMDS = ['git push origin main', 'vercel --prod', 'npx prisma migrate deploy
100
113
  'git push -u origin master', 'npm publish', 'supabase db push',
101
114
  'psql -c "select 1"']
102
115
 
103
- print('\n=== (a2) 関門は「管制に到達できる」ときは defer(何も出力しない) ===')
116
+ print('\n=== (a2) 関門は入口の番人がその場で管制に聞き、答えで allow / deny を返す(便 ab6e36ca) ===')
104
117
  gd_ok = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True)
105
118
  for cmd in GATE_CMDS:
106
119
  out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': cmd}}, datalink=gd_ok)
107
- check(f'{cmd} → defer(到達可)', out, None)
120
+ check(f'{cmd} → allow(「はい」)', out['hookSpecificOutput']['permissionDecision'] if out else None,
121
+ 'allow')
122
+ check('関門ごとに管制へ1回聞いた', gd_ok.registered, len(GATE_CMDS))
108
123
  check('起動記録が残る(呼ばれた事実)', len(gd_ok.decisions) >= len(GATE_CMDS), True)
124
+ for answer in ('deny', None, 'weird'):
125
+ gd = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True, answer=answer)
126
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'npm publish'}},
127
+ datalink=gd)
128
+ check(f'答え {answer!r} → deny', out['hookSpecificOutput']['permissionDecision'], 'deny')
129
+ for cmd in GATE_CMDS:
130
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': cmd}}, datalink=gd_ok)
131
+ check(f'★ask は返さない: {cmd[:40]}', out['hookSpecificOutput']['permissionDecision'] != 'ask', True)
109
132
 
110
133
  print('\n=== (a3) ★フェイルクローズ: 聞けないとき関門は deny(素通りさせない) ===')
111
134
  gd_ng = GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=False)
@@ -127,11 +150,17 @@ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'ca
127
150
  datalink=gd_unset)
128
151
  check('危険は管制不通でも deny のまま', out['hookSpecificOutput']['permissionDecision'], 'deny')
129
152
 
130
- # 無効化フラグ(人間が「不通の間は止めない」と決めたとき)→ 従来どおり defer。
153
+ # ★便 ab6e36ca: 以前の無効化フラグ(CC2_GATE_FAILCLOSE=0 で止めずに ask)は外した。
154
+ # ask は auto モードでは人の答えを待たずに実行されうる(便 c525bb2b)=止めが消える回避路になるため。
131
155
  os.environ['CC2_GATE_FAILCLOSE'] = '0'
132
156
  out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'git push origin main'}},
133
157
  datalink=gd_unset)
134
- check('CC2_GATE_FAILCLOSE=0 なら関門は defer', out, None)
158
+ check('★CC2_GATE_FAILCLOSE=0 でも関門は deny(回避路を作らない)',
159
+ out['hookSpecificOutput']['permissionDecision'], 'deny')
160
+ out = cc2_guard.build_output({'tool_name': 'Bash', 'tool_input': {'command': 'git push origin main'}},
161
+ datalink=gd_ng)
162
+ check('★CC2_GATE_FAILCLOSE=0 でも管制不通の関門は deny',
163
+ out['hookSpecificOutput']['permissionDecision'], 'deny')
135
164
  os.environ.pop('CC2_GATE_FAILCLOSE', None)
136
165
 
137
166
  # 関門でも、人に見せる日本語1行は用意できている(管制のカードへ送る文言)。
@@ -154,14 +183,34 @@ MERGE_ALLOW = [
154
183
  'cd /srv/app && gh pr merge 12 --squash', # 複合コマンドでも自走
155
184
  'bash -c "gh pr merge 12 --squash"', # 包んでも自走
156
185
  'gh pr merge 12 --squash && git pull',
157
- # ★便(指示)で実際に使う形。鉄則7「便の終点はマージまで」の2通りをそのまま置く。
158
- 'gh pr merge 151 --admin --merge --delete-branch',
186
+ # ★便(指示)で実際に使う形。鉄則7「便の終点はマージまで」の形をそのまま置く。
159
187
  'gh pr merge 151 --auto --merge --delete-branch',
188
+ 'gh pr merge 151 --merge --delete-branch',
160
189
  ]
161
190
  for cmd in MERGE_ALLOW:
162
191
  r = policy.decide_bash(cmd)
163
192
  check(f'merge allow {cmd[:56]}', (r['decision'], r['gate']), (policy.ALLOW, None))
164
193
 
194
+ # ★保護を迂回する合流(--admin)は職人には禁止(便 918ce835・えふさん確定 2026-09-17)。
195
+ MERGE_ADMIN_DENY = [
196
+ 'gh pr merge 151 --admin --merge --delete-branch',
197
+ 'gh pr merge --admin 12',
198
+ 'gh pr merge 12 --squash --admin',
199
+ 'gh pr merge 12 --admin=true',
200
+ 'gh pr merge -R o/r 12 --admin',
201
+ 'cd /srv/app && gh pr merge 12 --admin',
202
+ 'bash -c "gh pr merge 12 --admin"',
203
+ ]
204
+ for cmd in MERGE_ADMIN_DENY:
205
+ r = policy.decide_bash(cmd)
206
+ check(f'merge --admin deny {cmd[:48]}', r['decision'], policy.DENY)
207
+ # 字面に --admin が出るだけの操作は止めない(PR の本文・コミットの説明・検索)。
208
+ for cmd in ['gh pr create --title x --body "gh pr merge --admin は使わない"',
209
+ 'git commit -m "gh pr merge --admin を禁止"',
210
+ 'gh pr view 12 --json mergeStateStatus',
211
+ 'gh api repos/o/r --jq .admin']:
212
+ check(f'--admin の字面だけは allow {cmd[:40]}', policy.decide_bash(cmd)['decision'], policy.ALLOW)
213
+
165
214
  # フックの出口でも allow(=確認カードを出さずに実行される)。管制が不通でも止まらない。
166
215
  for _gd in (GuardDatalink({'url': 'http://x', 'token': 't'}, reachable=True),
167
216
  GuardDatalink(None, reachable=False)):