@tyhld/conductor 0.3.0 → 0.6.0
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/README.md +301 -19
- package/dist/cli.js +143 -18
- package/dist/ear-routing.js +57 -0
- package/dist/ear.js +57 -0
- package/dist/env-file-perm.js +67 -0
- package/dist/nudge.js +172 -0
- package/dist/realtime-parse.js +116 -0
- package/dist/realtime.js +251 -0
- package/dist/relay-runner.js +65 -0
- package/dist/relay.js +832 -133
- package/dist/websocket-transport.js +66 -0
- package/launchd/ear-install.sh +96 -0
- package/package.json +30 -1
- package/sales-template/README.md +185 -25
- package/sales-template/install.sh +890 -0
- package/sales-template/launchd/install.sh +151 -0
- package/sales-template/settings.json +26 -97
- package/sales-template/setup.sh +754 -117
- package/sales-template/systemd/README.md +28 -4
- package/sales-template/systemd/install.sh +54 -5
- package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
- package/sales-template/uninstall.sh +245 -0
- package/scripts/hooks/README.md +246 -0
- package/scripts/hooks/cc2_guard.py +145 -0
- package/scripts/hooks/codex-hooks.sample.json +58 -0
- package/scripts/hooks/hook_datalink.py +440 -0
- package/scripts/hooks/install-codex-hooks.sh +127 -0
- package/scripts/hooks/notification_hook.py +167 -0
- package/scripts/hooks/permission_request_hook.py +207 -0
- package/scripts/hooks/policy.py +759 -0
- package/scripts/hooks/settings.sample.json +142 -0
- package/scripts/hooks/stop_hook.py +275 -0
- package/scripts/hooks/summary_ja.py +155 -0
- package/scripts/hooks/test_hook_datalink.py +282 -0
- package/scripts/hooks/test_policy.py +1241 -0
- package/skills/conductor-craftsman/SKILL.md +40 -0
- package/systemd/conductor-ear.service +63 -0
- package/systemd/conductor@.service +62 -0
- package/systemd/ear-install.sh +131 -0
- package/systemd/guard-sync-install.sh +94 -0
- package/systemd/tyhld-guard-sync.service +28 -0
- package/systemd/tyhld-guard-sync.timer +25 -0
- package/sales-template/cc2_guard.py +0 -395
- package/sales-template/systemd/conductor@.service +0 -47
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""notification_hook.py — Notification フック(★端末でしか押せない問いを管制へ出す・ADR-016)。
|
|
3
|
+
|
|
4
|
+
【なぜ要るか(実機 2026-09-02 14:12)】
|
|
5
|
+
devlog-tracker の職人が端末でこう聞かれて止まっていた。★管制には1件も出ていなかった。
|
|
6
|
+
|
|
7
|
+
Network request outside of sandbox
|
|
8
|
+
Host: results-receiver.actions.githubusercontent.com
|
|
9
|
+
Do you want to allow this connection?
|
|
10
|
+
1. Yes 2. Yes, and don't ask again for … 3. No, …
|
|
11
|
+
|
|
12
|
+
直前に打っていたのは `gh run view … --log`(GitHub Actions の記録を読む操作)。
|
|
13
|
+
同じ型は 2026-09-01 にも Playwright(cdn.playwright.dev)で起きている。
|
|
14
|
+
|
|
15
|
+
【調べて分かったこと(本体の実物で確認)】
|
|
16
|
+
・この問いは PermissionRequest を鳴らさない(番人より外側の OS ゲート)。
|
|
17
|
+
・しかし Claude Code 2.1.246 以降は【Notification フックを鳴らす】
|
|
18
|
+
(本体同梱の変更履歴に「サンドボックスの許可待ち中に Notification が鳴らない不具合を修正」)。
|
|
19
|
+
実機は 2.1.258 なので鳴る。
|
|
20
|
+
・ところが Notification は【ホームにも現場にも1つも登録されていなかった】。
|
|
21
|
+
=鳴っていても誰も聞いていなかった。これが「管制に出ない」の根本原因。
|
|
22
|
+
|
|
23
|
+
【この入口の役目】
|
|
24
|
+
1. 受けた通知を必ず1行記録する(見張り。送った/送らなかったを後から数えられる)
|
|
25
|
+
2. 「人が端末で操作しないと進まない」種類だけを、管制へカードとして出す
|
|
26
|
+
3. カードには★答えを付けない。この問いは端末でしか押せないので、
|
|
27
|
+
「端末で押してください」と分かる形で出し、押されるまで出したままにする
|
|
28
|
+
|
|
29
|
+
【★二重に出さない】
|
|
30
|
+
PermissionRequest / Stop は自分でカードを出して待つ。その最中の通知は送らない
|
|
31
|
+
(hook_datalink の印で判る)。同じ止まりで2枚出す病気は ADR-013 で潰した。
|
|
32
|
+
|
|
33
|
+
【入出力】
|
|
34
|
+
入力(stdin JSON): {"hook_event_name":"Notification","message":…,"title":…,
|
|
35
|
+
"notification_type":…,"session_id":…,"cwd":…}
|
|
36
|
+
出力: 無し(Notification は判断を返せない入口。何があっても exit 0 で会話を止めない)
|
|
37
|
+
"""
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
43
|
+
|
|
44
|
+
import hook_datalink # noqa: E402
|
|
45
|
+
|
|
46
|
+
# 人が【端末で】操作しないと先へ進まない通知の種別。ここだけを管制へ出す。
|
|
47
|
+
# permission_prompt … 許可待ち(サンドボックスの通信許可もここに乗る)
|
|
48
|
+
# worker_permission_prompt … 手伝いの職人が許可待ち
|
|
49
|
+
# idle_prompt … 入力待ちのまま止まっている
|
|
50
|
+
HUMAN_BLOCKING_TYPES = frozenset({
|
|
51
|
+
'permission_prompt',
|
|
52
|
+
'worker_permission_prompt',
|
|
53
|
+
'idle_prompt',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
# 進行の知らせで、人が何かする必要のないもの(=出さない)。
|
|
57
|
+
# ★「知らない種別は出す」側に倒す。新しい種別が増えたとき黙って見えなくなるより、
|
|
58
|
+
# 1枚多く出るほうが害が小さい(見えない停止こそが今回の事故)。
|
|
59
|
+
INFORMATIONAL_TYPES = frozenset({
|
|
60
|
+
'agent_completed',
|
|
61
|
+
'auth_success',
|
|
62
|
+
'computer_use_exit',
|
|
63
|
+
'elicitation_complete',
|
|
64
|
+
'elicitation_response',
|
|
65
|
+
'push_notification',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
# カードを出したままにする上限(秒)。押されれば人は端末で先へ進む。
|
|
69
|
+
# ★フックの打ち切り(600秒)より先に切り上げ、後始末(カードを閉じる)を必ず走らせる。
|
|
70
|
+
NOTIFY_WAIT_SEC = int(os.environ.get('CC2_NOTIFY_WAIT_SEC') or 540)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def should_forward(notification_type):
|
|
74
|
+
"""管制へ出すか。返り: (出すか, 出さない理由)"""
|
|
75
|
+
kind = (notification_type or '').strip()
|
|
76
|
+
if not kind:
|
|
77
|
+
return False, 'no-type'
|
|
78
|
+
if kind in INFORMATIONAL_TYPES:
|
|
79
|
+
return False, 'informational'
|
|
80
|
+
if kind in HUMAN_BLOCKING_TYPES:
|
|
81
|
+
return True, ''
|
|
82
|
+
# 知らない種別=出す(見えない停止を作らないため安全側に倒す)。
|
|
83
|
+
return True, ''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_payload(data):
|
|
87
|
+
"""管制へ出すカードの中身。★答えの選択肢は付けない(端末でしか押せないため)。"""
|
|
88
|
+
cwd = data.get('cwd') or ''
|
|
89
|
+
site = os.environ.get('CONDUCTOR_SITE') or (cwd.rstrip('/').rsplit('/', 1)[-1] if cwd else '')
|
|
90
|
+
machine = os.environ.get('CONDUCTOR_MACHINE') or os.uname().nodename
|
|
91
|
+
message = (data.get('message') or '').strip()
|
|
92
|
+
title = (data.get('title') or '').strip()
|
|
93
|
+
kind = (data.get('notification_type') or '').strip() or 'unknown'
|
|
94
|
+
detail = ' / '.join(x for x in (title, message) if x)[:400]
|
|
95
|
+
lines = [
|
|
96
|
+
'★この確認は管制からは押せません。職人の端末(黒い画面)で選んでください。',
|
|
97
|
+
f'現場: {site or "不明"}/種別: {kind}',
|
|
98
|
+
]
|
|
99
|
+
if detail:
|
|
100
|
+
lines.append(detail)
|
|
101
|
+
body = '\n'.join(lines)
|
|
102
|
+
return {
|
|
103
|
+
'site': site or 'unknown',
|
|
104
|
+
'machine': machine,
|
|
105
|
+
# ★sessionId は絶対に空で送らない(空だと管制が 400 で弾く)。
|
|
106
|
+
'sessionId': hook_datalink.stable_session_id(data.get('session_id'), machine, site),
|
|
107
|
+
'kind': 'message',
|
|
108
|
+
'question': body,
|
|
109
|
+
'summary': f'端末で操作が要ります({site or "現場不明"})',
|
|
110
|
+
'waitMs': NOTIFY_WAIT_SEC * 1000,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run(data, cfg=None, datalink=hook_datalink):
|
|
115
|
+
"""返り値: 出したカードのID / None(出さなかった)。"""
|
|
116
|
+
kind = data.get('notification_type')
|
|
117
|
+
session_id = data.get('session_id') or ''
|
|
118
|
+
|
|
119
|
+
forward, reason = should_forward(kind)
|
|
120
|
+
if forward and datalink.handoff_active(session_id):
|
|
121
|
+
# 別のフック(PermissionRequest / Stop)が既に同じ止まりを管制へ出して待っている。
|
|
122
|
+
forward, reason = False, 'already-on-console'
|
|
123
|
+
|
|
124
|
+
cfg = datalink.load_config() if (forward and cfg is None) else cfg
|
|
125
|
+
if forward and cfg is None:
|
|
126
|
+
forward, reason = False, 'no-console'
|
|
127
|
+
|
|
128
|
+
datalink.record_notification(kind, forward, reason) # ★出しても出さなくても必ず残す
|
|
129
|
+
if not forward:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
reg = datalink.register(cfg, build_payload(data))
|
|
133
|
+
if not reg or not reg.get('id'):
|
|
134
|
+
return None
|
|
135
|
+
request_id = reg['id']
|
|
136
|
+
|
|
137
|
+
# ★押されるまでカードを出したままにする=「止まっていること」が管制で見え続ける。
|
|
138
|
+
# 管制側の答えは使わない(端末でしか押せない)。閉じる合図としてだけ受け取る。
|
|
139
|
+
datalink.mark_handoff(session_id)
|
|
140
|
+
answered = None
|
|
141
|
+
try:
|
|
142
|
+
answered = datalink.wait_for_decision(cfg, request_id, max_wait_sec=NOTIFY_WAIT_SEC)
|
|
143
|
+
finally:
|
|
144
|
+
datalink.abandon(cfg, request_id) # 去るときは必ず閉じる(残骸カードを作らない)
|
|
145
|
+
datalink.clear_handoff(session_id)
|
|
146
|
+
# ★時間切れ=「まだ端末で押されていない可能性が高い」。見えなくして終わりにせず、
|
|
147
|
+
# 後から数えられるように記録に残す(ADR-016 の見張り)。
|
|
148
|
+
if answered is None:
|
|
149
|
+
datalink.record_notification(kind, False, 'timeout-still-stuck')
|
|
150
|
+
return request_id
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> int:
|
|
154
|
+
try:
|
|
155
|
+
raw = sys.stdin.read()
|
|
156
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
157
|
+
except Exception: # noqa: BLE001
|
|
158
|
+
return 0
|
|
159
|
+
try:
|
|
160
|
+
run(data)
|
|
161
|
+
except Exception: # noqa: BLE001
|
|
162
|
+
pass
|
|
163
|
+
return 0 # ★Notification は会話を止めない(何があっても exit 0・出力なし)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == '__main__':
|
|
167
|
+
sys.exit(main())
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""permission_request_hook.py — PermissionRequest フック(段2・管制へデータで確認を渡す)。
|
|
3
|
+
|
|
4
|
+
【いつ走るか】
|
|
5
|
+
Claude Code が「人に聞く必要がある」と判定して確認ダイアログを出す瞬間。
|
|
6
|
+
番人(cc2_guard.py)が関門を "ask" で返したときが主な入口。
|
|
7
|
+
|
|
8
|
+
【何をするか】
|
|
9
|
+
1. 管制へ確認を1件登録する(POST /api/conductor/hook-requests)
|
|
10
|
+
2. 人の答えが出るまで待つ(GET を短く繰り返して最大600秒)
|
|
11
|
+
3. 答えを permissionDecision へ写して返す
|
|
12
|
+
4. 途中で去るときは DELETE(管制のカードを即座に閉じる)
|
|
13
|
+
|
|
14
|
+
【★答えの扱い】
|
|
15
|
+
allow → そのまま許可
|
|
16
|
+
allow_always → 許可+「今後は聞かない」を updatedPermissions で登録する。
|
|
17
|
+
★配列形式で返す(文字列形式は 2.1.218 で無効=検証済 27c5a9d1)。
|
|
18
|
+
★destination は既定 'session'(設定ファイルを汚さない)。
|
|
19
|
+
★ただし【関門】には恒久許可を与えない(allow へ落とす)。関門は毎回人が判断する。
|
|
20
|
+
deny → 却下
|
|
21
|
+
答えが取れない(時間切れ・通信不能・管制未設定)→ 何も出力しない=従来の確認ダイアログのまま。
|
|
22
|
+
★「答えないこと」を許可に読み替えない。
|
|
23
|
+
|
|
24
|
+
【接続先が未設定の環境】何もせず委譲する(このフックを入れても壊れない)。
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
|
|
32
|
+
import hook_datalink # noqa: E402
|
|
33
|
+
import policy # noqa: E402
|
|
34
|
+
import summary_ja # noqa: E402
|
|
35
|
+
|
|
36
|
+
# 「今後は聞かない」の保存先。★'session' 固定(設定ファイルを絶対に書き換えない)。
|
|
37
|
+
# 以前は CC2_ALLOW_ALWAYS_DESTINATION で上書きできたが、環境変数の設定ミス1つで
|
|
38
|
+
# localSettings に恒久 allow が書かれ、関門が静かに消える経路になっていた。塞ぐため固定する。
|
|
39
|
+
ALLOW_ALWAYS_DESTINATION = 'session'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_choices(gate=None):
|
|
43
|
+
"""管制のカードに出す選択肢。端末の確認と同じ数にする(確定仕様)。
|
|
44
|
+
|
|
45
|
+
・関門(gate) … 2択[はい/いいえ]。★関門に「今後は聞かない」は出さない
|
|
46
|
+
(恒久許可を与えない方針。decide でも gate は allow_always を無視する)。
|
|
47
|
+
・関門でない確認 … 3択[はい/今後は聞かない/いいえ]。
|
|
48
|
+
取得できない状況は無い(gate の有無だけで決まる)が、将来の互換のため
|
|
49
|
+
空を返す場合は build_payload 側で送らない(管制側にフォールバックがある)。
|
|
50
|
+
"""
|
|
51
|
+
yes = {'value': 'allow', 'label': 'はい'}
|
|
52
|
+
no = {'value': 'deny', 'label': 'いいえ'}
|
|
53
|
+
if gate:
|
|
54
|
+
return [yes, no]
|
|
55
|
+
return [yes, {'value': 'allow_always', 'label': '今後は聞かない'}, no]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_payload(data, gate=None):
|
|
59
|
+
"""管制へ送る値を組み立てる。★人が読む文言は日本語1行(summary)。"""
|
|
60
|
+
tool = data.get('tool_name')
|
|
61
|
+
tin = data.get('tool_input') if isinstance(data.get('tool_input'), dict) else {}
|
|
62
|
+
cwd = data.get('cwd') or ''
|
|
63
|
+
site = os.environ.get('CONDUCTOR_SITE') or (cwd.rstrip('/').rsplit('/', 1)[-1] if cwd else '')
|
|
64
|
+
machine = os.environ.get('CONDUCTOR_MACHINE') or os.uname().nodename
|
|
65
|
+
payload = {
|
|
66
|
+
'site': site or 'unknown',
|
|
67
|
+
'machine': machine,
|
|
68
|
+
# ★sessionId は絶対に空で送らない(空だと管制が 400 で弾く)。取れないときは
|
|
69
|
+
# 機械名・現場・親PID から復元可能な値を作る(hook_datalink.stable_session_id)。
|
|
70
|
+
'sessionId': hook_datalink.stable_session_id(data.get('session_id'), machine, site),
|
|
71
|
+
'kind': 'tool',
|
|
72
|
+
'toolName': tool or 'unknown',
|
|
73
|
+
'toolInput': tin,
|
|
74
|
+
'summary': summary_ja.summarize_tool(tool, tin, gate),
|
|
75
|
+
'waitMs': hook_datalink.MAX_WAIT_SEC * 1000,
|
|
76
|
+
}
|
|
77
|
+
# 選択肢を添える(取得できたときだけ。空なら送らない=管制のフォールバックに任せる)。
|
|
78
|
+
choices = build_choices(gate)
|
|
79
|
+
if choices:
|
|
80
|
+
payload['choices'] = choices
|
|
81
|
+
return payload
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_updated_permissions(data):
|
|
85
|
+
"""「今後は聞かない」を表す更新(★配列形式・destination は既定 session)。"""
|
|
86
|
+
tool = data.get('tool_name')
|
|
87
|
+
tin = data.get('tool_input') if isinstance(data.get('tool_input'), dict) else {}
|
|
88
|
+
rule = tin.get('command') if tool == 'Bash' else None
|
|
89
|
+
return [{
|
|
90
|
+
'type': 'addRules',
|
|
91
|
+
'rules': [{'toolName': tool, **({'ruleContent': rule} if rule else {})}],
|
|
92
|
+
'behavior': 'allow',
|
|
93
|
+
'destination': ALLOW_ALWAYS_DESTINATION,
|
|
94
|
+
}]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# 「聞けない」ときに関門を止める理由(お客さまがカードで読む日本語)。
|
|
98
|
+
# ★不通(到達不能・認証・タイムアウト)用。相手側/連絡線の問題として「回復を待つ」を促す。
|
|
99
|
+
_GATE_FAILCLOSE_MESSAGE = (
|
|
100
|
+
'お客さまへの確認(管制)に今つながらないため、安全のためこの操作を止めました。'
|
|
101
|
+
'マージ・本番反映・データベースの変更は、確認できないときは実行しません。'
|
|
102
|
+
'連絡線が回復してから、もう一度お試しください。'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ★送信内容の不備(4xx)用。管制には届いているのに登録が弾かれた=システム内部の不具合。
|
|
106
|
+
# 「つながらない(待てば直る)」と取り違えないよう、別の文言で不具合として明示する。
|
|
107
|
+
_GATE_DEFECT_MESSAGE = (
|
|
108
|
+
'お客さまへの確認(管制)の登録に失敗しました(システム内部の不具合の可能性)。'
|
|
109
|
+
'安全のためこの操作を止めました。時間をおいても直らない場合は担当者へご連絡ください。'
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _register_failure_kind(datalink):
|
|
114
|
+
"""register が失敗した直後の種別(FAIL_*)を読む。読めなければ None(従来どおり不通扱い)。
|
|
115
|
+
★テスト用の偽 datalink には last_failure_kind が無いことがあるため getattr で守る。"""
|
|
116
|
+
fn = getattr(datalink, 'last_failure_kind', None)
|
|
117
|
+
if not callable(fn):
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return fn()
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _gate_deny_for_failure(kind):
|
|
126
|
+
"""登録できなかったときに関門へ出す deny(種別で文言を分ける・止めるのは変えない)。"""
|
|
127
|
+
if kind == hook_datalink.FAIL_CLIENT:
|
|
128
|
+
return _deny(_GATE_DEFECT_MESSAGE)
|
|
129
|
+
return _deny(_GATE_FAILCLOSE_MESSAGE)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _deny(message):
|
|
133
|
+
return {'hookSpecificOutput': {
|
|
134
|
+
'hookEventName': 'PermissionRequest',
|
|
135
|
+
'decision': {'behavior': 'deny', 'message': message},
|
|
136
|
+
}}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def decide(data, cfg=None, datalink=hook_datalink):
|
|
140
|
+
"""1件分の判定。返り値 None は「何も出力しない(従来の確認ダイアログへ委譲)」。
|
|
141
|
+
|
|
142
|
+
★関門(gate)は「聞けない」ときに委譲(None)ではなく deny で返す(フェイルクローズ)。
|
|
143
|
+
委譲すると端末の確認ダイアログに落ち、非対話の現場では黙って素通りするため。
|
|
144
|
+
関門でない確認は従来どおり委譲する(全部は止めない)。
|
|
145
|
+
"""
|
|
146
|
+
# 何の関門かを先に知る(フェイルクローズの判断に要る)。
|
|
147
|
+
verdict = policy.decide_event(data)
|
|
148
|
+
gate = verdict.get('gate')
|
|
149
|
+
|
|
150
|
+
cfg = cfg if cfg is not None else datalink.load_config()
|
|
151
|
+
if cfg is None:
|
|
152
|
+
# 管制につながっていない環境。関門は素通りさせず止める。関門以外は従来どおり委譲。
|
|
153
|
+
return _deny(_GATE_FAILCLOSE_MESSAGE) if gate else None
|
|
154
|
+
|
|
155
|
+
reg = datalink.register(cfg, build_payload(data, gate))
|
|
156
|
+
if not reg or not reg.get('id'):
|
|
157
|
+
# 登録できない。関門は止める(フェイルクローズ)、関門以外は委譲。
|
|
158
|
+
# ★止めること自体は変えないが、原因が「送信内容の不備(4xx)」か「不通」かで文言を分ける。
|
|
159
|
+
# 400 を「つながらない」と読み替えない(報告 CC1・本ADR の根治)。
|
|
160
|
+
return _gate_deny_for_failure(_register_failure_kind(datalink)) if gate else None
|
|
161
|
+
request_id = reg['id']
|
|
162
|
+
|
|
163
|
+
# 既に答えが出ている(同じ確認の再送)ならそれを使う。
|
|
164
|
+
answer = reg.get('decision') if reg.get('status') == 'answered' else None
|
|
165
|
+
if answer is None:
|
|
166
|
+
# ★待っている間だけ「この止まりは管制が受け持っている」印を置く(ADR-016)。
|
|
167
|
+
# Claude Code の Notification(「許可待ちです」)で同じ止まりが2枚出るのを防ぐ。
|
|
168
|
+
datalink.mark_handoff(data.get('session_id') or '')
|
|
169
|
+
try:
|
|
170
|
+
answer = datalink.wait_for_decision(cfg, request_id)
|
|
171
|
+
finally:
|
|
172
|
+
datalink.clear_handoff(data.get('session_id') or '')
|
|
173
|
+
if answer is None:
|
|
174
|
+
# 時間切れ・通信不能 → カードを閉じる。関門は止める、関門以外は従来ダイアログへ委譲。
|
|
175
|
+
datalink.abandon(cfg, request_id)
|
|
176
|
+
return _deny(_GATE_FAILCLOSE_MESSAGE) if gate else None
|
|
177
|
+
|
|
178
|
+
if answer == 'deny':
|
|
179
|
+
return _deny('お客さまが「いいえ」を選びました。')
|
|
180
|
+
|
|
181
|
+
decision = {'behavior': 'allow'}
|
|
182
|
+
tin = data.get('tool_input')
|
|
183
|
+
if isinstance(tin, dict) and tin:
|
|
184
|
+
decision['updatedInput'] = tin
|
|
185
|
+
if answer == 'allow_always' and not gate:
|
|
186
|
+
# ★関門には恒久許可を与えない(gate のときは付けない)。
|
|
187
|
+
decision['updatedPermissions'] = build_updated_permissions(data)
|
|
188
|
+
return {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': decision}}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main() -> int:
|
|
192
|
+
try:
|
|
193
|
+
raw = sys.stdin.read()
|
|
194
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
195
|
+
except Exception: # noqa: BLE001
|
|
196
|
+
return 0
|
|
197
|
+
try:
|
|
198
|
+
out = decide(data)
|
|
199
|
+
if out is not None:
|
|
200
|
+
sys.stdout.write(json.dumps(out, ensure_ascii=False))
|
|
201
|
+
return 0
|
|
202
|
+
except Exception: # noqa: BLE001
|
|
203
|
+
return 0 # 何が起きても許可を勝手に出さない=委譲
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == '__main__':
|
|
207
|
+
sys.exit(main())
|