@tyhld/conductor 0.11.16 → 0.12.1
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/sales-template/install.sh +13 -1
- package/sales-template/settings.json +30 -1
- package/sales-template/setup.sh +1 -1
- package/scripts/hooks/hook_datalink.py +57 -4
- package/scripts/hooks/notification_hook.py +119 -19
- package/scripts/hooks/policy.py +140 -2
- package/scripts/hooks/test_policy.py +316 -3
package/package.json
CHANGED
|
@@ -65,10 +65,22 @@ NODE_MAJOR_WANTED=24
|
|
|
65
65
|
# 入らないまま古い版で回り続ける。どちらも 0.11.0 から入った物なので、下限をそこへ上げる。
|
|
66
66
|
# ★上げても手数は増えない。下限より古ければ、この台本が自分で
|
|
67
67
|
# `npm install -g @tyhld/conductor@latest` を打って新しくする(人の操作は不要)。
|
|
68
|
+
# 【0.12.0 へ上げた理由(えふさん確定 2026-09-16・便 93b2fa03)】
|
|
69
|
+
# 0.11.x までの番人は【読み取りを1件も判定していなかった】。`cat .env` は止まるのに、
|
|
70
|
+
# 職人の読み取りの道具で同じファイルを開くのは素通りしていた(秘密が読める穴)。
|
|
71
|
+
# 0.12.0 でその穴を塞いだが、下限が 0.11.0 のままだと 0.11.x が入っている機は
|
|
72
|
+
# 「入っています」で飛ばされ、★穴の開いた番人のまま回り続ける。だから下限を 0.12.0 にする。
|
|
73
|
+
# =この行こそが「直しをお客さま機へ届ける唯一の道」なので、下げないこと。
|
|
74
|
+
# 【0.12.1 へ上げた理由(えふさん確定 2026-09-16・便 927da60e)】
|
|
75
|
+
# 0.12.0 までの番人は、利用上限からの自動再開の知らせ3種を「知らない種類=出す」に
|
|
76
|
+
# 落としていた。もう自動で続いているのに【押せない確認カードが9分出たまま】になり
|
|
77
|
+
# (2026-09-16 11:01 に3現場で発生)、逆に本当に止まる2種(端末で Enter が要る/
|
|
78
|
+
# 続きの指示が要る)も同じ文面で出るので、何をすればよいか分からなかった。
|
|
79
|
+
# 0.12.1 で3種を分けた。下限を上げないと 0.12.0 の機に届かない。
|
|
68
80
|
# ★食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
|
|
69
81
|
# ★公開 npm の latest より新しい値を書かないこと(書くと全員が設置できなくなる)。
|
|
70
82
|
# package.json の版を超えていないことをテストで固定してある。
|
|
71
|
-
MIN_CONDUCTOR_VERSION="0.
|
|
83
|
+
MIN_CONDUCTOR_VERSION="0.12.1"
|
|
72
84
|
|
|
73
85
|
# nvm の版は固定する(毎回ちがう版が降ってくると再現しない)。
|
|
74
86
|
NVM_VERSION='v0.40.1'
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"_note": "采配くん販売用テンプレート — プロジェクトの .claude/settings.json に配置する。<PLACEHOLDER> を顧客環境に合わせて差し替えること。差し替え箇所は README.md の「プレースホルダ一覧」を参照。",
|
|
3
3
|
"_note_guard": "★段2(2026-07-23): 許可/確認の判定は番人(~/.tyhld/hooks の cc2_guard.py ほか3フック)が持つようになった。既定は allow(危険と関門だけを番人が deny/ask 相当で止め、それ以外は自走)。そのため settings 側の allow・ask 列挙は不要になり、空にしてある。番人フックはこのファイルではなく顧客PCの ~/.claude/settings.json に1本だけ登録する(setup.sh ⑤)。この現場ファイルには権限ルール(deny)と現場固有の sandbox だけを置く。",
|
|
4
4
|
"_note_deny": "★deny は番人の allow 既定より常に先に効く(Claude Code の評価順 deny→ask→allow)。ここに残す床(.env/.ssh/鍵/.tyhld/settings.json/hooks と Supabase 書込3種など)は allow を空にしても当然そのまま優先される。deny は消さない。",
|
|
5
|
+
"_note_read": "★Read(…) の禁止は【二重の守りの外側】(便 93b2fa03・えふさん確定 2026-09-16)。秘密の読み取りを本当に判定しているのは番人(~/.tyhld/hooks の policy.py の secret_reason)で、そちらは Read だけでなく NotebookRead・Grep・Glob・MCP・file:// まで同じ境界で止める。この一覧は【番人が外された機体でも Read だけは止まる】ための保険なので、番人より粗い。粗さの実害は1つだけ: Read(**/*.env) は example.env のような前置きの雛形まで止める(.env.example は止まらない)。番人の方は雛形を通すので、番人が入っている機体では今までどおり雛形を読める。",
|
|
5
6
|
"_note_network": "★外への通信は全部通す(allowedDomains: [\"*\"]・えふさん決定 2026-09-03=甲)。販売版もお客さま機で自走するのが既定(2026-08-23 決定)。理由: サンドボックスの通信許可の問いは管制の3ボタンでは押せず(Claude Code 2.1.258 本体の作り・実測 2026-09-03)、端末に人が居ないと職人が止まったままになるため。★緩むのは『電話をかける』だけ。『金庫を持ち出す』(秘密の持ち出し)を止めるのは番人 cc2_guard.py の別判定で、通信設定とは独立に効き続ける。★下の deniedDomains は allowedDomains より先に判定されるので、本番DBへの直結禁止は \"*\" を入れても効いたまま(実測で確認)。この行を消すと本番DBが開くので消さないこと。",
|
|
6
7
|
"permissions": {
|
|
7
8
|
"defaultMode": "default",
|
|
@@ -68,7 +69,35 @@
|
|
|
68
69
|
"Write(**/*.key)",
|
|
69
70
|
"Edit(**/*.key)",
|
|
70
71
|
"Write(**/.npmrc)",
|
|
71
|
-
"Edit(**/.npmrc)"
|
|
72
|
+
"Edit(**/.npmrc)",
|
|
73
|
+
"Read(**/.env)",
|
|
74
|
+
"Read(**/.env.local)",
|
|
75
|
+
"Read(**/.env.development)",
|
|
76
|
+
"Read(**/.env.production)",
|
|
77
|
+
"Read(**/.env.test)",
|
|
78
|
+
"Read(**/*.env)",
|
|
79
|
+
"Read(**/.ssh/**)",
|
|
80
|
+
"Read(**/.gnupg/**)",
|
|
81
|
+
"Read(**/.aws/**)",
|
|
82
|
+
"Read(**/.gcloud/**)",
|
|
83
|
+
"Read(**/.tyhld/**)",
|
|
84
|
+
"Read(**/*.pem)",
|
|
85
|
+
"Read(**/*.key)",
|
|
86
|
+
"Read(**/.npmrc)",
|
|
87
|
+
"Read(**/.netrc)",
|
|
88
|
+
"Read(**/.pypirc)",
|
|
89
|
+
"Read(**/id_rsa*)",
|
|
90
|
+
"Read(**/id_dsa*)",
|
|
91
|
+
"Read(**/id_ecdsa*)",
|
|
92
|
+
"Read(**/id_ed25519*)",
|
|
93
|
+
"Read(**/authorized_keys)",
|
|
94
|
+
"Read(**/known_hosts)",
|
|
95
|
+
"Read(**/credentials)",
|
|
96
|
+
"Read(**/credentials.json)",
|
|
97
|
+
"Read(**/.credentials.json)",
|
|
98
|
+
"Read(**/auth.json)",
|
|
99
|
+
"Read(**/*login.json)",
|
|
100
|
+
"Read(**/.config/gh/hosts.yml)"
|
|
72
101
|
]
|
|
73
102
|
},
|
|
74
103
|
"sandbox": {
|
package/sales-template/setup.sh
CHANGED
|
@@ -102,7 +102,7 @@ PROJECT_DIR="${2:-$HOME/projects/$SITE}"
|
|
|
102
102
|
# 食い違うと「入口は通したのに設置係が止める」が起きる。テストで同一を固定してある。
|
|
103
103
|
# ★公開 npm の latest より新しい値を書かないこと(書くと全員が設置できなくなる)。
|
|
104
104
|
# package.json の版を超えていないことをテストで固定してある。
|
|
105
|
-
MIN_CONDUCTOR_VERSION="0.
|
|
105
|
+
MIN_CONDUCTOR_VERSION="0.12.1"
|
|
106
106
|
|
|
107
107
|
# ─── ヘルパ ───────────────────────────────────────────────────────────────────
|
|
108
108
|
fail() { echo; echo "[setup] エラー: $1" >&2; exit 1; }
|
|
@@ -221,21 +221,74 @@ def record_decision(tool_name, decision, gate=None, env=None):
|
|
|
221
221
|
return _append_line(decision_log_path(env), line)
|
|
222
222
|
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
# 通知の本文をどれだけ残すか(★短く切る。理由は下の record_notification を参照)。
|
|
225
|
+
NOTIFICATION_DETAIL_CHARS = 120
|
|
226
|
+
|
|
227
|
+
# 秘密らしき字面を伏せるための型。★「読めなくする」ためではなく「写さない」ため。
|
|
228
|
+
# ① 名前つきの代入・項目(TOKEN= / secret: / api_key= …)
|
|
229
|
+
_SECRETISH_ASSIGN_RE = re.compile(
|
|
230
|
+
r'\b([A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|CREDENTIAL)'
|
|
231
|
+
r'[A-Za-z0-9_]*)\s*[=:]\s*\S+',
|
|
232
|
+
re.IGNORECASE,
|
|
233
|
+
)
|
|
234
|
+
# ② 認可ヘッダの値(Bearer xxx / Basic xxx)
|
|
235
|
+
_BEARER_RE = re.compile(r'\b(bearer|basic)\s+\S+', re.IGNORECASE)
|
|
236
|
+
# ③ 見るからに乱数の長い塊(英数が混ざった32文字以上のひと続き)。
|
|
237
|
+
# ★英単語・パス・種別の名前(quota_auto_resume_disabled 等)は数字を含まないので当たらない。
|
|
238
|
+
# ★`/` と `.` を字の仲間に入れない=パスやホスト名は区切りで千切れて短くなり、当たらない。
|
|
239
|
+
_LONG_TOKEN_RE = re.compile(
|
|
240
|
+
r'\b(?=[A-Za-z0-9+_-]*\d)(?=[A-Za-z0-9+_-]*[A-Za-z])[A-Za-z0-9+_-]{32,}={0,2}'
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def mask_secretish(text):
|
|
245
|
+
"""秘密らしき字面を `***` に置き換える(★記録へ写す前に必ず通す)。
|
|
246
|
+
|
|
247
|
+
★完全な見分けはできない。ここは「よくある形を写さない」ための最後の一拭きで、
|
|
248
|
+
本当の守りは『そもそも短くしか残さない』こと(NOTIFICATION_DETAIL_CHARS)。
|
|
249
|
+
"""
|
|
250
|
+
if not text:
|
|
251
|
+
return ''
|
|
252
|
+
s = _SECRETISH_ASSIGN_RE.sub(lambda m: f'{m.group(1)}=***', str(text))
|
|
253
|
+
s = _BEARER_RE.sub(lambda m: f'{m.group(1)} ***', s)
|
|
254
|
+
return _LONG_TOKEN_RE.sub('***', s)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def record_notification(notification_type, forwarded, reason='', env=None, detail=''):
|
|
225
258
|
"""Claude Code 自身の通知(Notification)を受けた1回を1行だけ残す(★ADR-016 の見張り)。
|
|
226
259
|
|
|
227
|
-
形: `<ISO時刻> Notification <種別> -> forwarded|skipped(<理由>)`
|
|
260
|
+
形: `<ISO時刻> Notification <種別> -> forwarded|skipped(<理由>) "<本文の先頭>"`
|
|
228
261
|
【なぜ要るか】端末にだけ出て管制に出ない問いは、これまで「起きたことすら分からなかった」。
|
|
229
262
|
2026-09-01(Playwright)と 2026-09-02(GitHub Actions)で同じ型が2回起きたのに、
|
|
230
263
|
2回目も端末を見ていた人しか気づけなかった。種別だけでも残しておけば、
|
|
231
264
|
新しい型が出たとき「いつから・どの種別が来ていたか」を後から数えられる。
|
|
232
|
-
|
|
265
|
+
|
|
266
|
+
【★本文の先頭も残すようにした理由(便 927da60e)】
|
|
267
|
+
2026-09-16 11:01 に利用上限の通知が3現場へ来たとき、記録に残っていたのは【種別だけ】で、
|
|
268
|
+
実際に何と書いてあったのかを後から一度も確かめられなかった(調査便 68a6f75e で行き止まり)。
|
|
269
|
+
種別が同じでも本文で意味が変わる型(quota_auto_resume_disabled は理由が6通りある)が
|
|
270
|
+
あるので、種別だけでは足りない。
|
|
271
|
+
|
|
272
|
+
【★秘密が混ざり得る場合の扱い(この便で決めたこと)】
|
|
273
|
+
通知の本文は Claude Code が作る画面の文だが、止まった操作のコマンド行やホスト名が
|
|
274
|
+
混ざることがある(例: サンドボックスの通信許可)。そこにお客さまの鍵が載る道が
|
|
275
|
+
ゼロだとは言い切れない。よって次の2段で扱う。
|
|
276
|
+
(1) 写す前に mask_secretish を通す(名前つきの代入・Bearer・長い乱数の塊を `***` に)
|
|
277
|
+
(2) ★そのうえで先頭 NOTIFICATION_DETAIL_CHARS 文字だけに切る
|
|
278
|
+
★見分けは完全ではない。だから「短くしか残さない」ことを本体の守りにしている。
|
|
279
|
+
★それでも残したくない環境は、環境変数 CONDUCTOR_NOTIFY_LOG_DETAIL=0 で本文を落とせる。
|
|
233
280
|
"""
|
|
281
|
+
env = os.environ if env is None else env
|
|
234
282
|
safe_type = str(notification_type or 'unknown').split()[0][:40]
|
|
235
283
|
verdict = 'forwarded' if forwarded else 'skipped'
|
|
236
284
|
suffix = f'({str(reason)[:32]})' if (reason and not forwarded) else ''
|
|
285
|
+
tail = ''
|
|
286
|
+
if (env.get('CONDUCTOR_NOTIFY_LOG_DETAIL') or '1').strip() != '0':
|
|
287
|
+
flat = ' '.join(mask_secretish(detail).split())[:NOTIFICATION_DETAIL_CHARS]
|
|
288
|
+
if flat:
|
|
289
|
+
tail = f' "{flat}"'
|
|
237
290
|
line = (f'{time.strftime("%Y-%m-%dT%H:%M:%S%z")} Notification {safe_type} '
|
|
238
|
-
f'-> {verdict}{suffix}\n')
|
|
291
|
+
f'-> {verdict}{suffix}{tail}\n')
|
|
239
292
|
return _append_line(decision_log_path(env), line)
|
|
240
293
|
|
|
241
294
|
|
|
@@ -22,9 +22,18 @@
|
|
|
22
22
|
|
|
23
23
|
【この入口の役目】
|
|
24
24
|
1. 受けた通知を必ず1行記録する(見張り。送った/送らなかったを後から数えられる)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
★本文の先頭も残す(伏せ字と長さの上限つき・便 927da60e)。種別だけでは、同じ種別で
|
|
26
|
+
意味が変わる型を後から確かめられなかった。
|
|
27
|
+
2. 「人が操作しないと進まない」種類だけを、管制へカードとして出す
|
|
28
|
+
3. カードには★答えを付けない。人は別の場所(端末か、管制からの便)で動かすので、
|
|
29
|
+
★「何が止まっているか」と「何をすれば再開するか」を書いて出す(便 927da60e)
|
|
30
|
+
|
|
31
|
+
【★利用上限からの自動再開は3種類に分ける(えふさん確定 2026-09-16③)】
|
|
32
|
+
自動再開した(fired) → カードを出さない・履歴だけ
|
|
33
|
+
端末での操作待ち(stale) → 要操作カード(端末で Enter)
|
|
34
|
+
自動再開が切れている(disabled) → 要操作カード(続きの指示を送る)
|
|
35
|
+
★以前は3つとも「知らない種別=出す」に落ち、もう動いているのに押せないカードが
|
|
36
|
+
9分出たままになっていた(2026-09-16 11:01 に3現場で発生)。
|
|
28
37
|
|
|
29
38
|
【★二重に出さない】
|
|
30
39
|
PermissionRequest / Stop は自分でカードを出して待つ。その最中の通知は送らない
|
|
@@ -43,28 +52,93 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
43
52
|
|
|
44
53
|
import hook_datalink # noqa: E402
|
|
45
54
|
|
|
46
|
-
#
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
# 利用上限からの自動再開の知らせ(★3種類・えふさん確定 2026-09-16③)
|
|
57
|
+
#
|
|
58
|
+
# 実測(便 68a6f75e・Claude Code 2.1.273 の中の文字列 + 2026-09-16 11:01 の3現場):
|
|
59
|
+
# fired … 見出し「Usage limit reset」本文「continuing automatically」
|
|
60
|
+
# =もう自動で続いた。3現場とも【誰も答えずに】4〜8秒で職人が再開した。
|
|
61
|
+
# stale … 本文「press enter to continue」
|
|
62
|
+
# =端末で Enter を1回押すまで止まる。
|
|
63
|
+
# disabled … 本文「this task will not resume on its own」「send a prompt to continue」
|
|
64
|
+
# =自動で続く仕組みが切れている。人が続きの指示を送るまで止まる。
|
|
65
|
+
#
|
|
66
|
+
# ★名前が似ているだけで扱いは別。3つまとめて「お知らせ」にすると、止まったまま誰も
|
|
67
|
+
# 気づけない(stale と disabled)。3つまとめて「カード」にすると、もう動いているのに
|
|
68
|
+
# 押せないカードが9分出たままになる(fired・11:01 に実際に起きた)。
|
|
69
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
QUOTA_RESUME_FIRED = 'quota_auto_resume_fired'
|
|
71
|
+
QUOTA_RESUME_STALE = 'quota_auto_resume_stale'
|
|
72
|
+
QUOTA_RESUME_DISABLED = 'quota_auto_resume_disabled'
|
|
73
|
+
|
|
74
|
+
# 人が【操作しないと先へ進まない】通知の種別。ここだけを管制へ出す。
|
|
47
75
|
# permission_prompt … 許可待ち(サンドボックスの通信許可もここに乗る)
|
|
48
76
|
# worker_permission_prompt … 手伝いの職人が許可待ち
|
|
49
77
|
# idle_prompt … 入力待ちのまま止まっている
|
|
78
|
+
# quota_auto_resume_stale … 利用上限から戻ったが自動で続かなかった(端末で Enter)
|
|
79
|
+
# quota_auto_resume_disabled … 自動で続く仕組みが切れている(続きの指示を送る)
|
|
50
80
|
HUMAN_BLOCKING_TYPES = frozenset({
|
|
51
81
|
'permission_prompt',
|
|
52
82
|
'worker_permission_prompt',
|
|
53
83
|
'idle_prompt',
|
|
84
|
+
QUOTA_RESUME_STALE,
|
|
85
|
+
QUOTA_RESUME_DISABLED,
|
|
54
86
|
})
|
|
55
87
|
|
|
56
|
-
#
|
|
88
|
+
# 進行の知らせで、人が何かする必要のないもの(=出さない・記録だけ)。
|
|
57
89
|
# ★「知らない種別は出す」側に倒す。新しい種別が増えたとき黙って見えなくなるより、
|
|
58
90
|
# 1枚多く出るほうが害が小さい(見えない停止こそが今回の事故)。
|
|
91
|
+
# ★ここに入れてよいのは「実測で、誰も答えなくても先へ進むと確かめた種別」だけ。
|
|
59
92
|
INFORMATIONAL_TYPES = frozenset({
|
|
60
93
|
'agent_completed',
|
|
61
94
|
'auth_success',
|
|
62
95
|
'computer_use_exit',
|
|
63
|
-
'elicitation_complete',
|
|
64
|
-
'elicitation_response',
|
|
65
96
|
'push_notification',
|
|
97
|
+
QUOTA_RESUME_FIRED,
|
|
66
98
|
})
|
|
67
99
|
|
|
100
|
+
# ★本体(Claude Code 2.1.273)が実際に出す種別の一覧(実測・便 68a6f75e)。
|
|
101
|
+
# 上の2つの表に、本体に無い名前を書かないための突き合わせ用。
|
|
102
|
+
# 【なぜ要るか】以前この表には elicitation_complete / elicitation_response という
|
|
103
|
+
# 【本体に存在しない名前】が入っていた。届かない名前なので実害は出ていなかったが、
|
|
104
|
+
# 表を読んだ人が「elicitation は出さない設定だ」と誤って理解する元になる。
|
|
105
|
+
# 実在するのは elicitation_dialog / elicitation_url_dialog で、どちらも入力を求める
|
|
106
|
+
# 画面なので「知らない種別=出す」で正しくカードになる。
|
|
107
|
+
KNOWN_NOTIFICATION_TYPES = frozenset({
|
|
108
|
+
'permission_prompt',
|
|
109
|
+
'idle_prompt',
|
|
110
|
+
'auth_success',
|
|
111
|
+
'elicitation_dialog',
|
|
112
|
+
'agent_needs_input',
|
|
113
|
+
'agent_completed',
|
|
114
|
+
'elicitation_url_dialog',
|
|
115
|
+
'worker_permission_prompt',
|
|
116
|
+
'push_notification',
|
|
117
|
+
'computer_use_enter',
|
|
118
|
+
'computer_use_exit',
|
|
119
|
+
QUOTA_RESUME_FIRED,
|
|
120
|
+
QUOTA_RESUME_STALE,
|
|
121
|
+
QUOTA_RESUME_DISABLED,
|
|
122
|
+
'model_refusal_fallback',
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
# 要操作カードの書き分け(★種別ごとに「何が止まっているか」「何をすれば再開するか」)。
|
|
126
|
+
# ここに無い種別は、従来どおりの共通の文面(端末でしか押せない問い)を使う。
|
|
127
|
+
ACTION_GUIDE = {
|
|
128
|
+
QUOTA_RESUME_STALE: {
|
|
129
|
+
'summary': '利用上限から戻りましたが、自動では続いていません',
|
|
130
|
+
'stopped': '利用上限から戻りましたが、職人が自動で続かずに止まっています。',
|
|
131
|
+
'howto': 'その現場の職人の端末(黒い画面)で Enter を1回押すと続きます。',
|
|
132
|
+
'console': '★このカードは管制からは押せません(端末での操作が要ります)。',
|
|
133
|
+
},
|
|
134
|
+
QUOTA_RESUME_DISABLED: {
|
|
135
|
+
'summary': '自動で再開しない設定のため、続きの指示を待っています',
|
|
136
|
+
'stopped': '自動で続く仕組みが切れているため、利用上限から戻っても職人は自分では再開しません。',
|
|
137
|
+
'howto': 'その現場へ続きの指示を送ると動き出します(管制から便を出すか、端末で指示を打つ)。',
|
|
138
|
+
'console': '★このカードは管制から便を出せば解けます(端末で打っても構いません)。',
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
68
142
|
# カードを出したままにする上限(秒)。押されれば人は端末で先へ進む。
|
|
69
143
|
# ★フックの打ち切り(600秒)より先に切り上げ、後始末(カードを閉じる)を必ず走らせる。
|
|
70
144
|
NOTIFY_WAIT_SEC = int(os.environ.get('CC2_NOTIFY_WAIT_SEC') or 540)
|
|
@@ -83,21 +157,43 @@ def should_forward(notification_type):
|
|
|
83
157
|
return True, ''
|
|
84
158
|
|
|
85
159
|
|
|
160
|
+
def notice_detail(data):
|
|
161
|
+
"""通知の本文(見出し+本文)を1つに畳んだ字面。記録にもカードにも同じものを使う。"""
|
|
162
|
+
message = (data.get('message') or '').strip()
|
|
163
|
+
title = (data.get('title') or '').strip()
|
|
164
|
+
return ' / '.join(x for x in (title, message) if x)
|
|
165
|
+
|
|
166
|
+
|
|
86
167
|
def build_payload(data):
|
|
87
|
-
"""
|
|
168
|
+
"""管制へ出すカードの中身。★答えの選択肢は付けない(人が別の場所で操作するため)。
|
|
169
|
+
|
|
170
|
+
★種別ごとの書き分け(ACTION_GUIDE にある種別)では、
|
|
171
|
+
「いま止まっているもの」「再開のしかた」を日本語で書く。
|
|
172
|
+
★それ以外の種別の文面は1文字も変えない(従来どおり)。
|
|
173
|
+
"""
|
|
88
174
|
cwd = data.get('cwd') or ''
|
|
89
175
|
site = os.environ.get('CONDUCTOR_SITE') or (cwd.rstrip('/').rsplit('/', 1)[-1] if cwd else '')
|
|
90
176
|
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
177
|
kind = (data.get('notification_type') or '').strip() or 'unknown'
|
|
94
|
-
detail =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
178
|
+
detail = notice_detail(data)[:400]
|
|
179
|
+
guide = ACTION_GUIDE.get(kind)
|
|
180
|
+
if guide:
|
|
181
|
+
where = site or '不明'
|
|
182
|
+
lines = [
|
|
183
|
+
f'いま止まっているもの: 現場「{where}」の職人が、{guide["stopped"]}',
|
|
184
|
+
f'再開のしかた: {guide["howto"]}',
|
|
185
|
+
guide['console'],
|
|
186
|
+
f'現場: {where}/種別: {kind}',
|
|
187
|
+
]
|
|
188
|
+
summary = f'{guide["summary"]}({site or "現場不明"})'
|
|
189
|
+
else:
|
|
190
|
+
lines = [
|
|
191
|
+
'★この確認は管制からは押せません。職人の端末(黒い画面)で選んでください。',
|
|
192
|
+
f'現場: {site or "不明"}/種別: {kind}',
|
|
193
|
+
]
|
|
194
|
+
summary = f'端末で操作が要ります({site or "現場不明"})'
|
|
99
195
|
if detail:
|
|
100
|
-
lines.append(detail)
|
|
196
|
+
lines.append(f'知らせの中身: {detail}' if guide else detail)
|
|
101
197
|
body = '\n'.join(lines)
|
|
102
198
|
return {
|
|
103
199
|
'site': site or 'unknown',
|
|
@@ -106,7 +202,7 @@ def build_payload(data):
|
|
|
106
202
|
'sessionId': hook_datalink.stable_session_id(data.get('session_id'), machine, site),
|
|
107
203
|
'kind': 'message',
|
|
108
204
|
'question': body,
|
|
109
|
-
'summary':
|
|
205
|
+
'summary': summary,
|
|
110
206
|
'waitMs': NOTIFY_WAIT_SEC * 1000,
|
|
111
207
|
}
|
|
112
208
|
|
|
@@ -125,7 +221,10 @@ def run(data, cfg=None, datalink=hook_datalink):
|
|
|
125
221
|
if forward and cfg is None:
|
|
126
222
|
forward, reason = False, 'no-console'
|
|
127
223
|
|
|
128
|
-
|
|
224
|
+
# ★出しても出さなくても必ず残す。本文の先頭も一緒に残す(伏せ字と長さの上限は
|
|
225
|
+
# record_notification が掛ける)。種別だけでは、同じ種別で意味が変わる型
|
|
226
|
+
# (quota_auto_resume_disabled は理由が6通りある)を後から確かめられない。
|
|
227
|
+
datalink.record_notification(kind, forward, reason, detail=notice_detail(data))
|
|
129
228
|
if not forward:
|
|
130
229
|
return None
|
|
131
230
|
|
|
@@ -146,7 +245,8 @@ def run(data, cfg=None, datalink=hook_datalink):
|
|
|
146
245
|
# ★時間切れ=「まだ端末で押されていない可能性が高い」。見えなくして終わりにせず、
|
|
147
246
|
# 後から数えられるように記録に残す(ADR-016 の見張り)。
|
|
148
247
|
if answered is None:
|
|
149
|
-
datalink.record_notification(kind, False, 'timeout-still-stuck'
|
|
248
|
+
datalink.record_notification(kind, False, 'timeout-still-stuck',
|
|
249
|
+
detail=notice_detail(data))
|
|
150
250
|
return request_id
|
|
151
251
|
|
|
152
252
|
|
package/scripts/hooks/policy.py
CHANGED
|
@@ -752,6 +752,130 @@ def decide_write(file_path: str, cwd=None):
|
|
|
752
752
|
return _r(ALLOW, 'no gate, no danger')
|
|
753
753
|
|
|
754
754
|
|
|
755
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
756
|
+
# 入口: ファイル読み取りツール(Read / NotebookRead / 検索 / 未知の道具)
|
|
757
|
+
#
|
|
758
|
+
# 【なぜ足したか(実地の穴・便 b80b76c4 の緊急)】
|
|
759
|
+
# ここまでの判定は Bash と Write/Edit しか見ていなかった。`cat .env` は止まるのに、
|
|
760
|
+
# 職人の【読み取りの道具】で同じファイルを開くのは素通りしていた。
|
|
761
|
+
# 「秘密の名前を見分ける仕組み(secret_reason)」は既にあったのに、読む側から
|
|
762
|
+
# 一度も呼ばれていなかった=書き込みは守られ、読み取りは丸ごと開いていた。
|
|
763
|
+
#
|
|
764
|
+
# 【方針=判定点は1つ】
|
|
765
|
+
# 読み取りも書き込みも、止める理由は同じ `secret_reason(path)` から出す。
|
|
766
|
+
# ★ここに新しい秘密の一覧を作らない(写しを2つ持つと、片方に足してもう片方を
|
|
767
|
+
# 忘れる事故が必ず起きる。この製品で繰り返した型)。
|
|
768
|
+
#
|
|
769
|
+
# 【迂回経路も同じ判定を通す】
|
|
770
|
+
# ・Read / NotebookRead … file_path / notebook_path
|
|
771
|
+
# ・Grep / Glob … path(探す場所)と pattern / glob(探す名前)
|
|
772
|
+
# ・★それ以外すべての道具 … 入力に「パスらしい項目」があれば同じ判定に掛ける。
|
|
773
|
+
# MCP の道具も、将来増える道具も、名前を知らないまま同じ境界で止まる。
|
|
774
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
775
|
+
|
|
776
|
+
# 読み取り専用の道具(中身がそのまま返る)。
|
|
777
|
+
READ_TOOLS = frozenset({'Read', 'NotebookRead'})
|
|
778
|
+
|
|
779
|
+
# 探す道具(結果に中身が出る/名前だけ出る)。
|
|
780
|
+
SEARCH_TOOLS = frozenset({'Grep', 'Glob'})
|
|
781
|
+
|
|
782
|
+
# 入力の中で「1つのパス」を表す項目名。★どの道具でも同じ名前が使われる(実測)。
|
|
783
|
+
PATH_KEYS = ('file_path', 'notebook_path', 'path', 'filePath', 'file', 'target_file')
|
|
784
|
+
|
|
785
|
+
# 入力の中で「パスの並び」を表す項目名。
|
|
786
|
+
PATH_LIST_KEYS = ('file_paths', 'paths', 'files')
|
|
787
|
+
|
|
788
|
+
# 探す名前(glob)の中で使われる、パスの一部ではない記号。
|
|
789
|
+
# ★剥がしてから secret_reason に渡す=判定は1つのまま。
|
|
790
|
+
# 例 `**/.env` → `/.env` → 秘密/`**/*.ts` → `/.ts` → 秘密ではない。
|
|
791
|
+
_GLOB_META_RE = re.compile(r'\[[^\]]*\]|[*?{}]')
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def strip_glob_meta(pattern: str) -> str:
|
|
795
|
+
"""探す名前から、当てはめの記号だけを剥がして素の字面にする。
|
|
796
|
+
|
|
797
|
+
★剥がすのは記号だけ。名前そのものは1文字も変えない(緩める方向の変更はしない)。
|
|
798
|
+
`.env*` → `.env`(秘密)/`*.env.example` → `.env.example`(雛形=秘密ではない)。
|
|
799
|
+
"""
|
|
800
|
+
return _GLOB_META_RE.sub('', pattern or '')
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def glob_secret_reason(pattern: str):
|
|
804
|
+
"""探す名前が、秘密のファイルを名指ししているなら理由を返す。
|
|
805
|
+
|
|
806
|
+
★判定は secret_reason ひとつ。ここでは記号を剥がして渡すだけ。
|
|
807
|
+
"""
|
|
808
|
+
if not pattern or not isinstance(pattern, str):
|
|
809
|
+
return None
|
|
810
|
+
stripped = strip_glob_meta(pattern).strip()
|
|
811
|
+
if not stripped:
|
|
812
|
+
return None
|
|
813
|
+
return secret_reason(stripped)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def decide_read(file_path: str, cwd=None):
|
|
817
|
+
"""読み取り1件の判定。★書き込みと同じ `secret_reason` で決める。"""
|
|
818
|
+
if not file_path or not isinstance(file_path, str) or not file_path.strip():
|
|
819
|
+
return _r(ALLOW, 'empty file_path')
|
|
820
|
+
try:
|
|
821
|
+
path = norm_path(file_path, cwd)
|
|
822
|
+
except Exception: # noqa: BLE001
|
|
823
|
+
return _r(DENY, 'danger: unnormalizable path')
|
|
824
|
+
sr = secret_reason(path)
|
|
825
|
+
if sr:
|
|
826
|
+
return _r(DENY, f'danger: read from {sr}')
|
|
827
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def decide_search(tool_input, cwd=None):
|
|
831
|
+
"""探す道具(Grep / Glob)の判定。
|
|
832
|
+
|
|
833
|
+
★2つを見る。
|
|
834
|
+
① 探す【場所】が秘密のファイルそのものなら止める(中身が結果に出るため)。
|
|
835
|
+
② 探す【名前】が秘密を名指ししているなら止める。
|
|
836
|
+
★普通の探し方(リポの中を *.ts で探す 等)は1つも止めない。
|
|
837
|
+
"""
|
|
838
|
+
if not isinstance(tool_input, dict):
|
|
839
|
+
return _r(ALLOW, 'no tool_input')
|
|
840
|
+
where = tool_input.get('path')
|
|
841
|
+
if isinstance(where, str) and where.strip():
|
|
842
|
+
res = decide_read(where, cwd)
|
|
843
|
+
if res['decision'] == DENY:
|
|
844
|
+
return _r(DENY, res['reason'].replace('read from', 'search in'))
|
|
845
|
+
for key in ('glob', 'pattern'):
|
|
846
|
+
sr = glob_secret_reason(tool_input.get(key))
|
|
847
|
+
if sr:
|
|
848
|
+
return _r(DENY, f'danger: search for {sr}')
|
|
849
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def decide_any_path(tool_input, cwd=None):
|
|
853
|
+
"""★知らない道具の受け皿。入力に「パスらしい項目」があれば同じ判定に掛ける。
|
|
854
|
+
|
|
855
|
+
【なぜ要るか】道具は増える(MCP・新しい組み込み)。名前を1つずつ列挙する形だと、
|
|
856
|
+
増えたその日から素通りする。名前ではなく【入力の形】で捕まえれば、知らない道具でも
|
|
857
|
+
同じ秘密の境界で止まる。
|
|
858
|
+
★止めるのは秘密に当たったときだけ。当たらなければ今までどおり自走する。
|
|
859
|
+
"""
|
|
860
|
+
if not isinstance(tool_input, dict):
|
|
861
|
+
return _r(ALLOW, 'no tool_input')
|
|
862
|
+
for key in PATH_KEYS:
|
|
863
|
+
v = tool_input.get(key)
|
|
864
|
+
if isinstance(v, str) and v.strip():
|
|
865
|
+
res = decide_read(v, cwd)
|
|
866
|
+
if res['decision'] == DENY:
|
|
867
|
+
return _r(DENY, res['reason'].replace('read from', f'{key} points at'))
|
|
868
|
+
for key in PATH_LIST_KEYS:
|
|
869
|
+
v = tool_input.get(key)
|
|
870
|
+
if isinstance(v, (list, tuple)):
|
|
871
|
+
for item in v:
|
|
872
|
+
if isinstance(item, str) and item.strip():
|
|
873
|
+
res = decide_read(item, cwd)
|
|
874
|
+
if res['decision'] == DENY:
|
|
875
|
+
return _r(DENY, res['reason'].replace('read from', f'{key} points at'))
|
|
876
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
877
|
+
|
|
878
|
+
|
|
755
879
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
756
880
|
# 入口: ネットワーク(WebFetch)
|
|
757
881
|
# ★既定 allow。取得そのものは自走してよい。止めるのは秘密の持ち出しだが、
|
|
@@ -761,6 +885,12 @@ def decide_write(file_path: str, cwd=None):
|
|
|
761
885
|
def decide_network(url: str):
|
|
762
886
|
if not url or not isinstance(url, str) or not url.strip():
|
|
763
887
|
return _r(ALLOW, 'empty url')
|
|
888
|
+
# ★file:// は「取得」の顔をした【読み取り】。同じ秘密の境界を通す。
|
|
889
|
+
# (便 b80b76c4 の緊急・迂回経路も同じ判定を通す)
|
|
890
|
+
if url.strip().lower().startswith('file:'):
|
|
891
|
+
sr = secret_reason(re.sub(r'^file:(//)?', '', url.strip(), flags=re.IGNORECASE))
|
|
892
|
+
if sr:
|
|
893
|
+
return _r(DENY, f'danger: read from {sr} (file url)')
|
|
764
894
|
if _SECRET_ENV_RE.search(url):
|
|
765
895
|
return _r(DENY, 'danger: secret in url')
|
|
766
896
|
if re.search(r'[?&](token|secret|password|api_?key)=', url, re.IGNORECASE):
|
|
@@ -792,5 +922,13 @@ def decide_event(data):
|
|
|
792
922
|
if not fp:
|
|
793
923
|
return _r(ALLOW, 'no file_path')
|
|
794
924
|
return decide_write(fp, data.get('cwd'))
|
|
795
|
-
|
|
796
|
-
|
|
925
|
+
if tool in READ_TOOLS:
|
|
926
|
+
fp = tin.get('file_path') or tin.get('notebook_path') or ''
|
|
927
|
+
if not fp:
|
|
928
|
+
return _r(ALLOW, 'no file_path')
|
|
929
|
+
return decide_read(fp, data.get('cwd'))
|
|
930
|
+
if tool in SEARCH_TOOLS:
|
|
931
|
+
return decide_search(tin, data.get('cwd'))
|
|
932
|
+
# ★知らない道具(MCP・将来増える組み込み)も、入力にパスらしい項目があれば同じ判定を通す。
|
|
933
|
+
# 当たらなければ従来どおり自走する(WebSearch・道具の呼び出し等は1つも止まらない)。
|
|
934
|
+
return decide_any_path(tin, data.get('cwd'))
|
|
@@ -19,6 +19,7 @@ import tempfile
|
|
|
19
19
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
20
|
|
|
21
21
|
import cc2_guard # noqa: E402
|
|
22
|
+
import hook_datalink # noqa: E402
|
|
22
23
|
import notification_hook # noqa: E402
|
|
23
24
|
import permission_request_hook # noqa: E402
|
|
24
25
|
import policy # noqa: E402
|
|
@@ -543,6 +544,7 @@ class FakeDatalink:
|
|
|
543
544
|
self.handoff = [] # 「管制が受け持っている」印の置き/消しの記録(ADR-016)
|
|
544
545
|
self.handoff_is_active = False
|
|
545
546
|
self.notifications = [] # 見張りの記録(種別, 出したか, 理由)
|
|
547
|
+
self.notice_details = [] # 見張りに渡した本文(便 927da60e)
|
|
546
548
|
|
|
547
549
|
def load_config(self):
|
|
548
550
|
return {'url': 'http://x', 'token': 't'}
|
|
@@ -558,8 +560,9 @@ class FakeDatalink:
|
|
|
558
560
|
def handoff_active(self, session_id=''):
|
|
559
561
|
return self.handoff_is_active
|
|
560
562
|
|
|
561
|
-
def record_notification(self, notification_type, forwarded, reason=''):
|
|
563
|
+
def record_notification(self, notification_type, forwarded, reason='', detail=''):
|
|
562
564
|
self.notifications.append((notification_type, forwarded, reason))
|
|
565
|
+
self.notice_details.append(detail)
|
|
563
566
|
return 'line'
|
|
564
567
|
|
|
565
568
|
def last_failure_kind(self):
|
|
@@ -1025,8 +1028,7 @@ check('入口として実行権を付ける対象に入っている',
|
|
|
1025
1028
|
# ② 出す/出さないの仕分け。★知らない種別は「出す」側(見えない停止を作らない)。
|
|
1026
1029
|
for _t in ('permission_prompt', 'worker_permission_prompt', 'idle_prompt'):
|
|
1027
1030
|
check(f'人が端末で押す種別は出す: {_t}', notification_hook.should_forward(_t)[0], True)
|
|
1028
|
-
for _t in ('agent_completed', 'auth_success', 'push_notification',
|
|
1029
|
-
'elicitation_complete', 'elicitation_response', 'computer_use_exit'):
|
|
1031
|
+
for _t in ('agent_completed', 'auth_success', 'push_notification', 'computer_use_exit'):
|
|
1030
1032
|
check(f'知らせるだけの種別は出さない: {_t}', notification_hook.should_forward(_t)[0], False)
|
|
1031
1033
|
check('知らない種別は出す(安全側)', notification_hook.should_forward('brand_new_kind')[0], True)
|
|
1032
1034
|
check('種別が空なら出さない', notification_hook.should_forward('')[0], False)
|
|
@@ -1274,6 +1276,317 @@ for cmd in REDIRECT_DENY:
|
|
|
1274
1276
|
for cmd in ['echo x > .env', 'y=$(echo x > .env)', 'echo x > "/home/fshim/.ssh/config"']:
|
|
1275
1277
|
check(f'deny {cmd[:58]}', policy.decide_bash(cmd)['decision'], policy.DENY)
|
|
1276
1278
|
|
|
1279
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1280
|
+
# (h) ★利用上限からの自動再開は3種類に分ける(便 927da60e・えふさん確定 2026-09-16③)
|
|
1281
|
+
#
|
|
1282
|
+
# 自動再開した(fired) → カードを出さない・履歴だけ
|
|
1283
|
+
# 端末での操作待ち(stale) → 要操作カード(端末で Enter)
|
|
1284
|
+
# 自動再開が切れている(disabled) → 要操作カード(続きの指示を送る)
|
|
1285
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1286
|
+
_NH = notification_hook
|
|
1287
|
+
|
|
1288
|
+
# (h-1) 仕分け
|
|
1289
|
+
check('自動再開した(fired)はカードを出さない', _NH.should_forward(_NH.QUOTA_RESUME_FIRED),
|
|
1290
|
+
(False, 'informational'))
|
|
1291
|
+
check('端末での操作待ち(stale)はカードを出す', _NH.should_forward(_NH.QUOTA_RESUME_STALE)[0], True)
|
|
1292
|
+
check('自動再開が切れている(disabled)はカードを出す',
|
|
1293
|
+
_NH.should_forward(_NH.QUOTA_RESUME_DISABLED)[0], True)
|
|
1294
|
+
|
|
1295
|
+
# (h-2) 3種類の置き場が正しい表に入っている
|
|
1296
|
+
check('fired はお知らせの表に入っている', _NH.QUOTA_RESUME_FIRED in _NH.INFORMATIONAL_TYPES, True)
|
|
1297
|
+
check('fired は要操作の表に入っていない', _NH.QUOTA_RESUME_FIRED in _NH.HUMAN_BLOCKING_TYPES, False)
|
|
1298
|
+
check('stale は要操作の表に入っている', _NH.QUOTA_RESUME_STALE in _NH.HUMAN_BLOCKING_TYPES, True)
|
|
1299
|
+
check('disabled は要操作の表に入っている', _NH.QUOTA_RESUME_DISABLED in _NH.HUMAN_BLOCKING_TYPES, True)
|
|
1300
|
+
check('stale はお知らせの表に入っていない', _NH.QUOTA_RESUME_STALE in _NH.INFORMATIONAL_TYPES, False)
|
|
1301
|
+
check('disabled はお知らせの表に入っていない',
|
|
1302
|
+
_NH.QUOTA_RESUME_DISABLED in _NH.INFORMATIONAL_TYPES, False)
|
|
1303
|
+
|
|
1304
|
+
# (h-3) ★表に「本体に実在しない名前」が入っていない(外した2つの再発防止)
|
|
1305
|
+
check('お知らせの表は実在する種別だけ',
|
|
1306
|
+
sorted(_NH.INFORMATIONAL_TYPES - _NH.KNOWN_NOTIFICATION_TYPES), [])
|
|
1307
|
+
check('要操作の表は実在する種別だけ',
|
|
1308
|
+
sorted(_NH.HUMAN_BLOCKING_TYPES - _NH.KNOWN_NOTIFICATION_TYPES), [])
|
|
1309
|
+
check('外した名前はもう表に無い(elicitation_complete)',
|
|
1310
|
+
'elicitation_complete' in _NH.INFORMATIONAL_TYPES, False)
|
|
1311
|
+
check('外した名前はもう表に無い(elicitation_response)',
|
|
1312
|
+
'elicitation_response' in _NH.INFORMATIONAL_TYPES, False)
|
|
1313
|
+
# ★外しても動きは変わらない: 実在する elicitation は今までどおり「知らない種別=出す」でカードになる。
|
|
1314
|
+
for _t in ('elicitation_dialog', 'elicitation_url_dialog'):
|
|
1315
|
+
check(f'実在する elicitation は今までどおり出す: {_t}', _NH.should_forward(_t)[0], True)
|
|
1316
|
+
|
|
1317
|
+
# (h-4) 要操作カードの文面(★何が止まっているか/何をすれば再開するか)
|
|
1318
|
+
_QEV = {'hook_event_name': 'Notification', 'notification_type': _NH.QUOTA_RESUME_STALE,
|
|
1319
|
+
'title': 'Usage limit has reset', 'message': 'press enter to continue',
|
|
1320
|
+
'cwd': '/home/fshim/projects/kenzokun', 'session_id': 'sess-q'}
|
|
1321
|
+
_qp = _NH.build_payload(_QEV)
|
|
1322
|
+
check('stale: 何が止まっているかが書いてある', 'いま止まっているもの' in _qp['question'], True)
|
|
1323
|
+
check('stale: 現場名が本文に入る', '「kenzokun」' in _qp['question'], True)
|
|
1324
|
+
check('stale: 再開のしかたが書いてある', '再開のしかた' in _qp['question'], True)
|
|
1325
|
+
check('stale: 端末で Enter と書いてある', 'Enter' in _qp['question'], True)
|
|
1326
|
+
check('stale: 通知の本文の先頭も添える', 'press enter to continue' in _qp['question'], True)
|
|
1327
|
+
check('stale: 要点が日本語で分かる', '自動では続いていません' in _qp['summary'], True)
|
|
1328
|
+
check('stale: 答えの選択肢は付けない', 'choices' in _qp, False)
|
|
1329
|
+
|
|
1330
|
+
_DEV = dict(_QEV, notification_type=_NH.QUOTA_RESUME_DISABLED,
|
|
1331
|
+
title='Automatic continue was turned off',
|
|
1332
|
+
message='this task will not resume on its own')
|
|
1333
|
+
_dp = _NH.build_payload(_DEV)
|
|
1334
|
+
check('disabled: 何が止まっているかが書いてある', 'いま止まっているもの' in _dp['question'], True)
|
|
1335
|
+
check('disabled: 続きの指示を送ると書いてある', '続きの指示' in _dp['question'], True)
|
|
1336
|
+
check('disabled: 通知の本文の先頭も添える',
|
|
1337
|
+
'this task will not resume on its own' in _dp['question'], True)
|
|
1338
|
+
check('disabled: 要点が日本語で分かる', '続きの指示を待っています' in _dp['summary'], True)
|
|
1339
|
+
# ★disabled は管制から便を出せば解ける=「管制からは押せません」とは書かない。
|
|
1340
|
+
check('disabled: 「管制からは押せません」とは書かない',
|
|
1341
|
+
'管制からは押せません' in _dp['question'], False)
|
|
1342
|
+
check('stale: こちらは端末でしか押せないと書く', '管制からは押せません' in _qp['question'], True)
|
|
1343
|
+
|
|
1344
|
+
# (h-5) ★他の種別の文面は1文字も変えない
|
|
1345
|
+
_pp = _NH.build_payload({'hook_event_name': 'Notification', 'notification_type': 'permission_prompt',
|
|
1346
|
+
'message': 'Claude needs your permission to use Bash',
|
|
1347
|
+
'cwd': '/home/fshim/projects/devlog-tracker', 'session_id': 's'})
|
|
1348
|
+
check('他の種別: 共通の文面のまま',
|
|
1349
|
+
_pp['question'].startswith('★この確認は管制からは押せません。職人の端末(黒い画面)で選んでください。'), True)
|
|
1350
|
+
check('他の種別: 要点も従来どおり', _pp['summary'], '端末で操作が要ります(devlog-tracker)')
|
|
1351
|
+
check('他の種別: 「いま止まっているもの」は付けない', 'いま止まっているもの' in _pp['question'], False)
|
|
1352
|
+
|
|
1353
|
+
# (h-6) 実際の出し分け(管制へ出す/出さない)と記録
|
|
1354
|
+
fd = FakeDatalink(None)
|
|
1355
|
+
check('fired は管制へ出さない', _NH.run(dict(_QEV, notification_type=_NH.QUOTA_RESUME_FIRED,
|
|
1356
|
+
title='Usage limit reset',
|
|
1357
|
+
message='continuing automatically'), datalink=fd), None)
|
|
1358
|
+
check('fired も記録には残す', fd.notifications[0][1:], (False, 'informational'))
|
|
1359
|
+
check('fired の記録に本文の先頭が入る',
|
|
1360
|
+
'continuing automatically' in fd.notice_details[0], True)
|
|
1361
|
+
|
|
1362
|
+
fd = FakeDatalink(None)
|
|
1363
|
+
check('stale は管制へ出す', _NH.run(dict(_QEV), cfg={'url': 'http://x', 'token': 't'},
|
|
1364
|
+
datalink=fd), 'req-1')
|
|
1365
|
+
check('stale の記録に本文の先頭が入る', 'press enter to continue' in fd.notice_details[0], True)
|
|
1366
|
+
|
|
1367
|
+
fd = FakeDatalink(None)
|
|
1368
|
+
check('disabled は管制へ出す', _NH.run(dict(_DEV), cfg={'url': 'http://x', 'token': 't'},
|
|
1369
|
+
datalink=fd), 'req-1')
|
|
1370
|
+
|
|
1371
|
+
# (h-7) 記録の本文=伏せ字と長さの上限
|
|
1372
|
+
check('記録: 名前つきの代入は伏せる',
|
|
1373
|
+
'sk-abcdefghij' in hook_datalink.mask_secretish('API_KEY=sk-abcdefghij'), False)
|
|
1374
|
+
check('記録: 名前つきの代入は名前だけ残す',
|
|
1375
|
+
hook_datalink.mask_secretish('API_KEY=sk-abcdefghij'), 'API_KEY=***')
|
|
1376
|
+
check('記録: 認可ヘッダの値は伏せる',
|
|
1377
|
+
hook_datalink.mask_secretish('Authorization: Bearer abc.def.ghi'),
|
|
1378
|
+
'Authorization: Bearer ***')
|
|
1379
|
+
check('記録: 長い乱数の塊は伏せる',
|
|
1380
|
+
hook_datalink.mask_secretish('x ' + ('a1B2c3D4' * 5) + ' y'), 'x *** y')
|
|
1381
|
+
check('記録: 普通の英文は変えない',
|
|
1382
|
+
hook_datalink.mask_secretish('Usage limit reset / continuing automatically'),
|
|
1383
|
+
'Usage limit reset / continuing automatically')
|
|
1384
|
+
check('記録: 種別の名前は伏せない(数字を含まないので当たらない)',
|
|
1385
|
+
hook_datalink.mask_secretish('quota_auto_resume_disabled fired'),
|
|
1386
|
+
'quota_auto_resume_disabled fired')
|
|
1387
|
+
check('記録: パスは伏せない', hook_datalink.mask_secretish('/home/fshim/projects/conductor/src'),
|
|
1388
|
+
'/home/fshim/projects/conductor/src')
|
|
1389
|
+
_log_dir = tempfile.mkdtemp(prefix='conductor-notify-log-')
|
|
1390
|
+
try:
|
|
1391
|
+
_log = os.path.join(_log_dir, 'guard.log')
|
|
1392
|
+
_env = {'CONDUCTOR_GUARD_LOG': _log}
|
|
1393
|
+
hook_datalink.record_notification('quota_auto_resume_stale', True, '',
|
|
1394
|
+
env=_env, detail='Usage limit has reset / ' + ('あ' * 300))
|
|
1395
|
+
_line = open(_log, encoding='utf-8').read()
|
|
1396
|
+
check('記録: 本文の先頭が1行に入る', 'Usage limit has reset' in _line, True)
|
|
1397
|
+
check('記録: 長さの上限で切る(1行が短いまま)', len(_line) < 260, True)
|
|
1398
|
+
hook_datalink.record_notification('quota_auto_resume_fired', False, 'informational',
|
|
1399
|
+
env=dict(_env, CONDUCTOR_NOTIFY_LOG_DETAIL='0'),
|
|
1400
|
+
detail='continuing automatically')
|
|
1401
|
+
_line2 = open(_log, encoding='utf-8').read().splitlines()[-1]
|
|
1402
|
+
check('記録: 本文を残さない設定なら残さない', 'continuing automatically' in _line2, False)
|
|
1403
|
+
check('記録: その場合も種別と結果は残る',
|
|
1404
|
+
'quota_auto_resume_fired -> skipped(informational)' in _line2, True)
|
|
1405
|
+
finally:
|
|
1406
|
+
shutil.rmtree(_log_dir, ignore_errors=True)
|
|
1407
|
+
|
|
1408
|
+
# (h-8) ★空振り確認=仕分けを元に戻すと落ちる
|
|
1409
|
+
_orig_info = _NH.INFORMATIONAL_TYPES
|
|
1410
|
+
_orig_block = _NH.HUMAN_BLOCKING_TYPES
|
|
1411
|
+
try:
|
|
1412
|
+
# ① fired を「出す側」へ戻すと、カードを出さない約束が崩れる。
|
|
1413
|
+
_NH.INFORMATIONAL_TYPES = _orig_info - {_NH.QUOTA_RESUME_FIRED}
|
|
1414
|
+
check('★空振り: fired を出す側に戻すと、出さない約束が崩れる',
|
|
1415
|
+
_NH.should_forward(_NH.QUOTA_RESUME_FIRED)[0], True)
|
|
1416
|
+
_NH.INFORMATIONAL_TYPES = _orig_info
|
|
1417
|
+
# ② stale と disabled を「お知らせ」に入れると、要操作カードが出なくなる。
|
|
1418
|
+
_NH.INFORMATIONAL_TYPES = _orig_info | {_NH.QUOTA_RESUME_STALE, _NH.QUOTA_RESUME_DISABLED}
|
|
1419
|
+
check('★空振り: stale をお知らせに入れるとカードが消える',
|
|
1420
|
+
_NH.should_forward(_NH.QUOTA_RESUME_STALE)[0], False)
|
|
1421
|
+
check('★空振り: disabled をお知らせに入れるとカードが消える',
|
|
1422
|
+
_NH.should_forward(_NH.QUOTA_RESUME_DISABLED)[0], False)
|
|
1423
|
+
# ③ 書き分けの表を空にすると、要操作カードの文面が共通の文面へ戻る。
|
|
1424
|
+
_orig_guide = _NH.ACTION_GUIDE
|
|
1425
|
+
_NH.ACTION_GUIDE = {}
|
|
1426
|
+
check('★空振り: 書き分けの表を空にすると「いま止まっているもの」が消える',
|
|
1427
|
+
'いま止まっているもの' in _NH.build_payload(_QEV)['question'], False)
|
|
1428
|
+
_NH.ACTION_GUIDE = _orig_guide
|
|
1429
|
+
finally:
|
|
1430
|
+
_NH.INFORMATIONAL_TYPES = _orig_info
|
|
1431
|
+
_NH.HUMAN_BLOCKING_TYPES = _orig_block
|
|
1432
|
+
|
|
1433
|
+
# (h-9) 戻したら元どおり動く(上の細工が残っていないこと)
|
|
1434
|
+
check('★空振り: 戻すと fired は再び出さない', _NH.should_forward(_NH.QUOTA_RESUME_FIRED)[0], False)
|
|
1435
|
+
check('★空振り: 戻すと stale は再び出す', _NH.should_forward(_NH.QUOTA_RESUME_STALE)[0], True)
|
|
1436
|
+
check('★空振り: 戻すと書き分けの文面も再び出る',
|
|
1437
|
+
'いま止まっているもの' in _NH.build_payload(_QEV)['question'], True)
|
|
1438
|
+
|
|
1439
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1440
|
+
# (g) ★秘密ファイルの【読み取り】が、経路を問わず止まる(便 93b2fa03・えふさん確定①)
|
|
1441
|
+
#
|
|
1442
|
+
# 原則: 秘密として分類されたファイル・パスは、職人から【内容を取得できない】。
|
|
1443
|
+
# 書き込み禁止と読み取り禁止を【同じ秘密境界】で強制する。迂回経路も同じ判定を通す。
|
|
1444
|
+
# ★ここで使う名前はすべて【偽の置き場】(/tmp の下)。本物の秘密には1件も触れない。
|
|
1445
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1446
|
+
FAKE = '/tmp/claude-guard-test' # ★存在しなくてよい。判定は名前とパスだけで決まる。
|
|
1447
|
+
|
|
1448
|
+
# 秘密として止まるべきパス(名前・置き場の両方の型を1つずつ)。
|
|
1449
|
+
SECRET_PATHS = [
|
|
1450
|
+
f'{FAKE}/.env', # そのもの
|
|
1451
|
+
f'{FAKE}/.env.local', # .env. で始まる
|
|
1452
|
+
f'{FAKE}/.fake-conductor.env', # .env で【終わる】(2026-09-06 に塞いだ型)
|
|
1453
|
+
f'{FAKE}/.ssh/config', # 秘密のフォルダの中
|
|
1454
|
+
f'{FAKE}/id_rsa', # 鍵そのもの
|
|
1455
|
+
f'{FAKE}/server.pem', # 証明書
|
|
1456
|
+
f'{FAKE}/site.key', # 鍵
|
|
1457
|
+
f'{FAKE}/.npmrc', # 取得先の資格情報
|
|
1458
|
+
f'{FAKE}/auth.json', # 道具のログイン
|
|
1459
|
+
f'{FAKE}/.credentials.json', # 職人のログイン
|
|
1460
|
+
f'{FAKE}/some-login.json', # ブラウザのログイン状態
|
|
1461
|
+
f'{FAKE}/.config/gh/hosts.yml', # 置き場と対で秘密になるもの
|
|
1462
|
+
]
|
|
1463
|
+
|
|
1464
|
+
# 普通のファイル(★1つも止めてはいけない)。
|
|
1465
|
+
PLAIN_PATHS = [
|
|
1466
|
+
f'{FAKE}/src/relay.ts',
|
|
1467
|
+
f'{FAKE}/README.md',
|
|
1468
|
+
f'{FAKE}/.env.example', # 雛形は秘密ではない
|
|
1469
|
+
f'{FAKE}/example.env', # 前置きの雛形も秘密ではない
|
|
1470
|
+
f'{FAKE}/package.json',
|
|
1471
|
+
f'{FAKE}/docs/adr/ADR-001.md',
|
|
1472
|
+
]
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def ev(tool, tool_input, cwd=FAKE):
|
|
1476
|
+
return policy.decide_event({'tool_name': tool, 'tool_input': tool_input, 'cwd': cwd})
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
# (g-1) 直接の読み取り(Read / NotebookRead)
|
|
1480
|
+
for p in SECRET_PATHS:
|
|
1481
|
+
check(f'read deny Read {p[len(FAKE):][:44]}', ev('Read', {'file_path': p})['decision'], policy.DENY)
|
|
1482
|
+
check(f'read deny NotebookRead {p[len(FAKE):][:36]}',
|
|
1483
|
+
ev('NotebookRead', {'notebook_path': p})['decision'], policy.DENY)
|
|
1484
|
+
for p in PLAIN_PATHS:
|
|
1485
|
+
check(f'read allow Read {p[len(FAKE):][:44]}', ev('Read', {'file_path': p})['decision'], policy.ALLOW)
|
|
1486
|
+
|
|
1487
|
+
# (g-2) 探す道具(Grep / Glob)— 探す場所が秘密そのもの/探す名前が秘密を名指し
|
|
1488
|
+
for p in SECRET_PATHS:
|
|
1489
|
+
check(f'search deny Grep path {p[len(FAKE):][:38]}',
|
|
1490
|
+
ev('Grep', {'pattern': 'x', 'path': p})['decision'], policy.DENY)
|
|
1491
|
+
for g in ['**/.env', '.env*', '**/*.pem', '**/id_rsa*', '**/.npmrc', '**/auth.json']:
|
|
1492
|
+
check(f'search deny Grep glob {g:24}',
|
|
1493
|
+
ev('Grep', {'pattern': 'x', 'path': FAKE, 'glob': g})['decision'], policy.DENY)
|
|
1494
|
+
check(f'search deny Glob pattern {g:20}', ev('Glob', {'pattern': g})['decision'], policy.DENY)
|
|
1495
|
+
# ★普通の探し方は1つも止めない。
|
|
1496
|
+
for g in ['**/*.ts', 'src/**/*.tsx', '*', '**/*.md', '**/*.env.example']:
|
|
1497
|
+
check(f'search allow Grep glob {g:23}',
|
|
1498
|
+
ev('Grep', {'pattern': 'x', 'path': FAKE, 'glob': g})['decision'], policy.ALLOW)
|
|
1499
|
+
check(f'search allow Glob pattern {g:19}', ev('Glob', {'pattern': g})['decision'], policy.ALLOW)
|
|
1500
|
+
check('search allow Grep path=リポの中',
|
|
1501
|
+
ev('Grep', {'pattern': 'relayTick', 'path': f'{FAKE}/src'})['decision'], policy.ALLOW)
|
|
1502
|
+
|
|
1503
|
+
# (g-3) ★知らない道具(MCP・将来の組み込み)も同じ境界で止まる
|
|
1504
|
+
for p in SECRET_PATHS[:4]:
|
|
1505
|
+
check(f'unknown-tool deny path {p[len(FAKE):][:38]}',
|
|
1506
|
+
ev('mcp__fs__read_text_file', {'path': p})['decision'], policy.DENY)
|
|
1507
|
+
check(f'unknown-tool deny file_path {p[len(FAKE):][:33]}',
|
|
1508
|
+
ev('SomeFutureReader', {'file_path': p})['decision'], policy.DENY)
|
|
1509
|
+
check('unknown-tool deny paths[](並びの中の1つでも)',
|
|
1510
|
+
ev('SomeFutureReader', {'paths': [f'{FAKE}/README.md', f'{FAKE}/.env']})['decision'], policy.DENY)
|
|
1511
|
+
check('unknown-tool allow 普通のパス',
|
|
1512
|
+
ev('mcp__fs__read_text_file', {'path': f'{FAKE}/README.md'})['decision'], policy.ALLOW)
|
|
1513
|
+
check('unknown-tool allow パスの項目が無い', ev('WebSearch', {'query': '.env とは'})['decision'], policy.ALLOW)
|
|
1514
|
+
check('unknown-tool allow 説明文に字面があるだけ',
|
|
1515
|
+
ev('Task', {'prompt': 'あとで .env の書き方を調べる'})['decision'], policy.ALLOW)
|
|
1516
|
+
|
|
1517
|
+
# (g-4) file:// の取得は「取得の顔をした読み取り」
|
|
1518
|
+
check('webfetch deny file:// 秘密',
|
|
1519
|
+
ev('WebFetch', {'url': f'file://{FAKE}/.env'})['decision'], policy.DENY)
|
|
1520
|
+
check('webfetch allow file:// 普通',
|
|
1521
|
+
ev('WebFetch', {'url': f'file://{FAKE}/README.md'})['decision'], policy.ALLOW)
|
|
1522
|
+
check('webfetch allow 普通の取得',
|
|
1523
|
+
ev('WebFetch', {'url': 'https://example.com/docs'})['decision'], policy.ALLOW)
|
|
1524
|
+
|
|
1525
|
+
# (g-5) shell 経由は【従来どおり】止まる(退行していないことの確認)
|
|
1526
|
+
for cmd in ['cat .env', 'head -5 .env', 'tail .env', 'strings .env', 'xxd .env',
|
|
1527
|
+
'base64 .env', 'grep TOKEN .env', 'sed -n 1p .env', 'awk "{print}" .env',
|
|
1528
|
+
'cp .env /tmp/x', 'tar czf /tmp/x.tgz .env', 'bash -c "cat .env"',
|
|
1529
|
+
'python3 -c "print(open(\'.env\').read())"']:
|
|
1530
|
+
check(f'shell deny {cmd[:52]}', policy.decide_bash(cmd)['decision'], policy.DENY)
|
|
1531
|
+
# 普通の読み取りは止まらない。
|
|
1532
|
+
for cmd in ['cat README.md', 'grep -rn relayTick src', 'head -20 package.json',
|
|
1533
|
+
'cat .env.example', 'sed -n 1,20p src/relay.ts']:
|
|
1534
|
+
check(f'shell allow {cmd[:52]}', policy.decide_bash(cmd)['decision'], policy.ALLOW)
|
|
1535
|
+
|
|
1536
|
+
# (g-6) ★書き込みの禁止が1つも変わっていない(判定点を1つにしても後退しない)
|
|
1537
|
+
for p in SECRET_PATHS:
|
|
1538
|
+
check(f'write deny {p[len(FAKE):][:48]}', ev('Write', {'file_path': p})['decision'], policy.DENY)
|
|
1539
|
+
for p in PLAIN_PATHS:
|
|
1540
|
+
check(f'write allow {p[len(FAKE):][:47]}', ev('Write', {'file_path': p})['decision'], policy.ALLOW)
|
|
1541
|
+
|
|
1542
|
+
# (g-7) ★空振り確認=判定点(secret_reason)を外すと、上の deny が全部 allow に落ちる
|
|
1543
|
+
# これが落ちないなら「読み取りの禁止が secret_reason を通っていない」=写しの一覧を
|
|
1544
|
+
# どこかに作ってしまった合図。★判定が1か所であることを機械で固定する。
|
|
1545
|
+
_real_secret_reason = policy.secret_reason
|
|
1546
|
+
try:
|
|
1547
|
+
policy.secret_reason = lambda path: None # 判定点を無効化する
|
|
1548
|
+
broke = []
|
|
1549
|
+
for p in SECRET_PATHS:
|
|
1550
|
+
if ev('Read', {'file_path': p})['decision'] != policy.ALLOW:
|
|
1551
|
+
broke.append(f'Read {p}')
|
|
1552
|
+
if ev('Write', {'file_path': p})['decision'] != policy.ALLOW:
|
|
1553
|
+
broke.append(f'Write {p}')
|
|
1554
|
+
if ev('Grep', {'pattern': 'x', 'path': p})['decision'] != policy.ALLOW:
|
|
1555
|
+
broke.append(f'Grep {p}')
|
|
1556
|
+
if ev('mcp__fs__read_text_file', {'path': p})['decision'] != policy.ALLOW:
|
|
1557
|
+
broke.append(f'MCP {p}')
|
|
1558
|
+
for g in ['**/.env', '**/*.pem']:
|
|
1559
|
+
if ev('Glob', {'pattern': g})['decision'] != policy.ALLOW:
|
|
1560
|
+
broke.append(f'Glob {g}')
|
|
1561
|
+
if ev('WebFetch', {'url': f'file://{FAKE}/.env'})['decision'] != policy.ALLOW:
|
|
1562
|
+
broke.append('WebFetch file://')
|
|
1563
|
+
check('★空振り: 判定点を外すと読み書き探すが全部通る(=判定は1か所)', broke, [])
|
|
1564
|
+
finally:
|
|
1565
|
+
policy.secret_reason = _real_secret_reason
|
|
1566
|
+
|
|
1567
|
+
# (g-8) ★空振り確認=判定点を戻すと、また止まる(上の細工が残っていないこと)
|
|
1568
|
+
check('★空振り: 判定点を戻すと再び止まる',
|
|
1569
|
+
ev('Read', {'file_path': f'{FAKE}/.env'})['decision'], policy.DENY)
|
|
1570
|
+
|
|
1571
|
+
# (g-9) 実物のファイルで確かめる(★偽の秘密・偽の値。本物には触れない)
|
|
1572
|
+
_secret_dir = tempfile.mkdtemp(prefix='conductor-guard-read-')
|
|
1573
|
+
try:
|
|
1574
|
+
_fake_env = os.path.join(_secret_dir, '.env')
|
|
1575
|
+
with open(_fake_env, 'w', encoding='utf-8') as _f:
|
|
1576
|
+
_f.write('FAKE_TOKEN=this-is-not-a-real-secret\n')
|
|
1577
|
+
_plain = os.path.join(_secret_dir, 'notes.md')
|
|
1578
|
+
with open(_plain, 'w', encoding='utf-8') as _f:
|
|
1579
|
+
_f.write('ふつうの覚え書き\n')
|
|
1580
|
+
check('実物: 偽の秘密は読めない(Read)', ev('Read', {'file_path': _fake_env})['decision'], policy.DENY)
|
|
1581
|
+
check('実物: 偽の秘密は探せない(Grep)',
|
|
1582
|
+
ev('Grep', {'pattern': 'FAKE_TOKEN', 'path': _fake_env})['decision'], policy.DENY)
|
|
1583
|
+
check('実物: 偽の秘密は shell でも読めない',
|
|
1584
|
+
policy.decide_bash(f'cat {_fake_env}')['decision'], policy.DENY)
|
|
1585
|
+
check('実物: 同じ場所の普通のファイルは読める',
|
|
1586
|
+
ev('Read', {'file_path': _plain})['decision'], policy.ALLOW)
|
|
1587
|
+
finally:
|
|
1588
|
+
shutil.rmtree(_secret_dir, ignore_errors=True)
|
|
1589
|
+
|
|
1277
1590
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1278
1591
|
print('\n' + '=' * 72)
|
|
1279
1592
|
if failures:
|