@tyhld/conductor 0.12.6 → 0.12.8

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,1014 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # update-guard.sh — 稼働中の現場の「番人(cc2_guard.py)」と権限ルールを最新へ揃える。
4
+ #
5
+ # 【なぜ要るか】
6
+ # setup.sh は新規設置用で、既に動いている現場では
7
+ # - settings.json … 上書き確認が既定 N(顧客の設定を壊さないための保護)
8
+ # - hooks セクション … 既に在ればスキップ
9
+ # のため、番人を新しくしても稼働機には届かない。ここを埋めるのがこのスクリプト。
10
+ #
11
+ # 【やること】(すべて冪等:何度実行しても同じ結果。バージョン判定は不要)
12
+ # (a) 1台1箇所の固定パス $GUARD_HOME に、正本の番人「一式」と設定ファイルを上書き設置
13
+ # =入口3つ(PreToolUse=cc2_guard / PermissionRequest / Stop)と、その依存を全部置く。
14
+ # 従来は cc2_guard.py 単体しか置かず、カードを出す係(PermissionRequest)・終わりを
15
+ # 知らせる係(Stop)の入口が固定パスに無かった=全現場でそれらが動けなかった(今回の根治)。
16
+ # (a-2) 番人の【登録はホームの ~/.claude/settings.json 1か所だけ】にする(★ADR-013)
17
+ # ・ホームに見本のイベント(PreToolUse / PermissionRequest / Stop)を確実に登録する。
18
+ # 登録が無ければ見本どおり足し、古い場所を指していれば固定パスへ向け直す(①-e)。
19
+ # ・各現場に残っている同じ登録は【外す】(③)。ホームと現場の両方に登録されていると
20
+ # Claude Code は両方を走らせるので、本物の質問で確認カードが2枚出る(ADR-011 の残穴)。
21
+ # ★外す順序は「ホームを確立してから現場を外す」。ホームに確立できなかったイベントは
22
+ # 現場からも外さない(=守りが一瞬でも消えない・フェイルクローズ)。
23
+ # (b) 各現場の .claude/settings.json を「丸ごと上書きせず」部分マージで更新
24
+ # ・hooks は【外すだけ】。番人はホームに1本化したので現場には登録しない。外すのは
25
+ # 固定パス($GUARD_HOME)の番人を指す登録だけで、顧客が足した独自フックは残す。
26
+ # ・permissions.deny に不足している禁止ルールだけを追記(既存は消さない)
27
+ # ・permissions.allow は【全削除】する。番人の既定が allow なので不要で、allow が
28
+ # 1行でも残ると評価順(deny→ask→allow)で関門より先に素通りさせる穴になるため。
29
+ # ★deny は一切触らない・消さない・変えない。
30
+ # (b-2) ホームへ「外への通信の全許可(allowedDomains: "*")」を1か所だけ配る(①-e3・甲)
31
+ # =この問いは管制の3ボタンでは押せないので、聞かれる前に通す。持ち出しの番人は無関係
32
+ # (番人は allowedDomains を参照しない)=1ミリも緩まない。
33
+ # ・sandbox セクションが既に在る現場だけ、sandbox.allowUnsandboxedCommands を false へ
34
+ # =職人が Bash の dangerouslyDisableSandbox 引数でサンドボックスの外へ出る素通りを塞ぐ。
35
+ # sandbox が無い現場には新設しない(サンドボックス自体を有効化する事故を避ける)。
36
+ # ★ 番人以外の hooks/ask/その他のキーには一切触れない=顧客が足した設定は残る。
37
+ # ★ deny は常に allow より先に効く(Claude Code 仕様)=床(.env/.ssh/鍵/.tyhld/hooks や
38
+ # Supabase 書込3種の禁止)は allow 全削除後も当然そのまま優先される。
39
+ #
40
+ # 【使い方】
41
+ # bash scripts/update-guard.sh # 予定を表示するだけ(既定・書き込みなし)
42
+ # APPLY=1 bash scripts/update-guard.sh # 実際に適用する
43
+ # APPLY=1 bash scripts/update-guard.sh ~/projects/a ~/projects/b # 現場を明示指定
44
+ #
45
+ # 【安全】
46
+ # - 既定は DRY RUN(APPLY=1 のときだけ書き込む)
47
+ # - settings.json を書き換える前に必ずバックアップを取る(変更が要るときだけ)
48
+ # - JSON として壊れているファイルは触らずスキップして報告する
49
+ # - 削除するのは permissions.allow(意図的な全削除)と、現場に残った番人の登録だけ。
50
+ # deny は追記のみ・非改変、顧客が足した hooks・他キーは非改変
51
+ #
52
+ set -euo pipefail
53
+
54
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
55
+ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
56
+
57
+ # ★配り元(GUARD_SRC_DIR / SETTINGS_SRC / SAMPLE_SETTINGS / 鉄則)は、この下の「①-src」で決める。
58
+ # ここ(自分の置き場)を配り元にしない=作業ツリーから全現場へ配らない(便 4061dd71)。
59
+ # ★1台に1箇所だけ置く場所。現場が増えても番人は増えない=更新はここ1箇所で済む。
60
+ GUARD_HOME="$HOME/.tyhld/hooks"
61
+
62
+ APPLY="${APPLY:-0}"
63
+
64
+ # ★同期モード(SYNC_ONLY=1)— 番人の【コードと配線だけ】を正本へ揃えて終わる(ADR-012)。
65
+ # 【なぜ要るか】直しをリポジトリへ入れても、人が APPLY=1 でこのスクリプトを流すまで機体には
66
+ # 1文字も届かない。実地で2度続けて届いていなかった:
67
+ # ・2026-09-02 03:37(PR #568 の報告)… ADR-008 の判定が入った番人が配られていなかった
68
+ # ・2026-09-02 11:04(PR #573 の報告)… ADR-011 の直し(03:56 マージ済み)も届いていなかった
69
+ # どちらもホームの ~/.claude/settings.json が古い写し($HOME/.tyhld/hookset)を指したままで、
70
+ # その写しには判定そのものが無い=どんな報告でも確認カードを出していた。
71
+ # 【直し方】人が忘れても届くように、この同期モードを systemd --user タイマーで1時間ごとに
72
+ # 回す(①-f で設置)。権限(allow/ask/deny)・掃除・鉄則スキルには触れないので、無人で
73
+ # 回しても settings.json の中身を作り変えない=「番人のコードだけ新しく保つ」に徹する。
74
+ # ★同期モードが触る「配線」は2つ:(1)ホームへの登録を確立する(①-e)
75
+ # (2)現場に残った同じ登録を外す(③)。どちらも hooks だけで、権限には触れない。
76
+ # =すでに配ってしまった現場の二重登録も、人が何もしなくても1時間以内にそろう(ADR-013)。
77
+ SYNC_ONLY="${SYNC_ONLY:-0}"
78
+
79
+ info() { echo "[update-guard] $*"; }
80
+ warn() { echo "[update-guard] 注意: $*" >&2; }
81
+ fail() { echo "[update-guard] エラー: $*" >&2; exit 1; }
82
+
83
+ # ─── ①-src 配り元を決める(★公開済みの正規版だけ・便 4061dd71) ──────────────
84
+ # 【なぜ要るか(実測 2026-09-16・開発履歴 6b405ae6)】
85
+ # 1時間ごとの自動同期の本体が /home/fshim/projects/conductor/scripts/update-guard.sh
86
+ # =★職人の【作業ツリー】を指していた。職人が番人を直しかけのブランチに居るあいだに同期が走ると、
87
+ # 作りかけの番人がその機の【全現場】へ配られる。当日は main で変更なしだったため無事だったが、
88
+ # 無事だったのは偶然である。
89
+ #
90
+ # 【えふさん確定(2026-09-16)】
91
+ # 「① 番人の1時間自動同期 承認 29612ff1後に実施。ただし『公開済みの正規版』を唯一の配布元にする。
92
+ # 作業treeから全現場へ配らない」
93
+ #
94
+ # 【どう直したか】★「作業ツリーからは配らないと言って止まる」ではなく【配り元を差し替える】を採った。
95
+ # えふさんの手は今までどおり APPLY=1 bash scripts/update-guard.sh のままでよく、
96
+ # 打った台本の置き場に関わらず、★配られる中身は必ずこの機に入っている公開済みの正規版になる。
97
+ # 「止まる」を採ると、今までどおり打ったのに何も起きない形になり、手順を覚え直すことになる。
98
+ #
99
+ # 【正規版でないときは黙って進まない】
100
+ # 入っていない/版が読めない/公開 npm に聞けない/版が公開 npm の今の版と違う のどれでも、
101
+ # ★1バイトも配らずに終わる。失敗は控えに残し、便 29612ff1 で作った知らせ方(確認カード)に載せる。
102
+ . "$SCRIPT_DIR/dist-source.sh"
103
+
104
+ DIST_STATE="$GUARD_HOME/dist-source.state" # 最後に【確かめられた】配り元
105
+ DIST_LAST="$GUARD_HOME/dist-source.last" # 直近の【試み】の結果
106
+ DIST_NOTIFY="$SCRIPT_DIR/hooks/skill_sync_notify.py"
107
+
108
+ # 直近の試みを1行残す。★確かめられたときだけ .state も更新する(成功と偽らない)。
109
+ # ★確認のみ(APPLY!=1)のときは1バイトも書かない。「見ただけ」で控えが動くと、
110
+ # 控えが「実際に配った記録」でなくなり、外から見て何が起きたのか分からなくなる。
111
+ dist_record() {
112
+ [[ "$APPLY" == "1" ]] || return 0
113
+ local status="$1" reason="$2" line
114
+ line="$(date '+%Y-%m-%dT%H:%M:%S%z') status=${status} reason=${reason} what=dist-source version=${DIST_VERSION:-unknown} published=${DIST_PUBLISHED:-unknown} root=${DIST_ROOT:-none}"
115
+ mkdir -p "$GUARD_HOME" 2>/dev/null || true
116
+ printf '%s\n' "$line" > "$DIST_LAST" 2>/dev/null || true
117
+ if [[ "$status" == "ok" ]]; then
118
+ printf '%s\n' "$line" > "$DIST_STATE" 2>/dev/null || true
119
+ fi
120
+ }
121
+
122
+ # 配り損ねを管制へ知らせる(★便 29612ff1 と同じ口・届かなくても止まらない)。
123
+ # ★確認のみのときは知らせない(見ただけでお客さまの画面にカードを出さない)。
124
+ dist_notify_failure() {
125
+ [[ "$APPLY" == "1" ]] || return 0
126
+ [[ -f "$DIST_NOTIFY" ]] || return 0
127
+ python3 "$DIST_NOTIFY" "$DIST_LAST" >/dev/null 2>&1 || true
128
+ }
129
+
130
+ info "①-src 配り元の確認(公開済みの正規版だけを配ります)"
131
+ if ! dist_source_resolve; then
132
+ warn "配り元を確かめられません(${DIST_REASON})。★1バイトも配りません。"
133
+ case "$DIST_REASON" in
134
+ not-installed) warn " この機に @tyhld/conductor が入っていません。" ;;
135
+ no-version) warn " 入っている配布物の版が読めません。" ;;
136
+ unverifiable) warn " 公開 npm のいまの版に問い合わせられません(回線・取得先)。" ;;
137
+ version-drift) warn " 入っている版 ${DIST_VERSION} が、公開 npm のいまの版 ${DIST_PUBLISHED} と違います。" ;;
138
+ esac
139
+ warn " 人の手番: npm install -g @tyhld/conductor --@tyhld:registry=https://registry.npmjs.org"
140
+ dist_record failed "$DIST_REASON"
141
+ dist_notify_failure
142
+ exit 0
143
+ fi
144
+ if ! dist_source_complete "$DIST_ROOT"; then
145
+ warn "配り元の中身が欠けています: $DIST_ROOT(★1バイトも配りません)"
146
+ DIST_REASON='incomplete'
147
+ dist_record failed incomplete
148
+ dist_notify_failure
149
+ exit 0
150
+ fi
151
+ info " 配り元: $DIST_ROOT(版 $DIST_VERSION =公開 npm のいまの版)"
152
+ if [[ "$DIST_ROOT" != "$REPO_ROOT" ]]; then
153
+ info " ★この台本の置き場($REPO_ROOT)からは配りません。"
154
+ fi
155
+ dist_record ok verified
156
+
157
+ # 配る中身は、すべて上で確かめた配り元から取る。
158
+ GUARD_SRC_DIR="$DIST_ROOT/scripts/hooks" # 番人の正本
159
+ SETTINGS_SRC="$DIST_ROOT/sales-template/settings.json" # 権限ルールの正本(deny の供給元)
160
+ SAMPLE_SETTINGS="$GUARD_SRC_DIR/settings.sample.json" # 取り寄せ先の宣言の正本(ADR-004)
161
+
162
+ [[ -f "$GUARD_SRC_DIR/cc2_guard.py" ]] || fail "番人の正本が見つかりません: $GUARD_SRC_DIR/cc2_guard.py"
163
+ [[ -f "$SETTINGS_SRC" ]] || fail "権限ルールの正本が見つかりません: $SETTINGS_SRC"
164
+
165
+ if [[ "$APPLY" != "1" ]]; then
166
+ info "モード: 確認のみ(書き込みません)。実行するには APPLY=1 を付けてください。"
167
+ else
168
+ info "モード: 適用"
169
+ fi
170
+
171
+ # ─── 同期モードの安全条件(★無人で回るので、ここだけは厳しくする) ─────────────
172
+ # 作業中の壊れた番人を全現場へ配ると、3関門が同時に止まる/素通りする(ADR-002 の分離の要)。
173
+ # ★2026-09-16(便 4061dd71)に、ここの守り方を【git の状態を見る】から【配り元そのものを
174
+ # 公開済みの正規版に限る】へ移した(①-src)。
175
+ # ・旧=「この作業ツリーに未コミットの変更が無ければ、この作業ツリーから配ってよい」。
176
+ # これは「コミットさえすれば作りかけでも全現場へ配ってよい」という意味でもあり、
177
+ # ★そもそも作業ツリーを配り元にしている点は直っていなかった(開発履歴 6b405ae6)。
178
+ # ・新=配り元は「この機に入っている配布物で、版が公開 npm のいまの版と一致するもの」だけ。
179
+ # 作業ツリーの中身は、コミット済みかどうかに関わらず1バイトも配られない。
180
+ # =git の作業場所でない機(お客さまの機)でも同じ判定で通る。
181
+ if [[ "$SYNC_ONLY" == "1" ]]; then
182
+ info "モード: 同期のみ(番人のコードと配線だけ・権限や掃除には触れません)"
183
+ fi
184
+
185
+ # ─── ① 番人一式を固定パスへ設置(上書き=常に最新) ───────────────────────────
186
+ # ★見本(settings.sample.json)が呼ぶ入口3つ(PreToolUse=cc2_guard / PermissionRequest /
187
+ # Stop)と、そこから import で辿られる依存を【全部】固定パス $GUARD_HOME に置く。1つでも
188
+ # 欠けると差し替えた現場の番人が読めず止まる(勤務用コピーの考え方=ADR-002)。
189
+ # ★従来は cc2_guard.py 単体しか置かず、PermissionRequest / Stop の入口が固定パスに無かった
190
+ # =カードを出す係・終わりを知らせる係が全現場で動けなかった。ここで一式に広げて根治する。
191
+ INSTALL_FILES=(
192
+ cc2_guard.py # PreToolUse … 危険を止める/関門を人へ渡す
193
+ permission_request_hook.py # PermissionRequest … 確認を管制へ送り答えを待つ
194
+ stop_hook.py # Stop … 手が止まったことを管制へ知らせる
195
+ notification_hook.py # Notification … 端末でしか押せない問いを管制へ知らせる(ADR-016)
196
+ policy.py # cc2_guard / permission_request_hook が使う判定本体
197
+ summary_ja.py # permission_request_hook / stop_hook が使う日本語の文言
198
+ hook_datalink.py # 各フックが使う管制との連絡線
199
+ )
200
+ INSTALL_CONFIGS=(safe_verbs.json network_allow.json)
201
+ ENTRYPOINTS=(cc2_guard.py permission_request_hook.py stop_hook.py notification_hook.py)
202
+
203
+ # ①-0 見本と「配る中身」がズレていないか機械照合する(今回と同じ欠落事故の再発防止=根治)。
204
+ # ・見本が参照する入口スクリプトが、正本(scripts/hooks/)に在るか
205
+ # ・その入口が INSTALL_FILES(=$GUARD_HOME に置かれる一式)に入っているか
206
+ # 1つでも外れていれば「配っても現場で動かない/欠落配布」になるので、ここで止める。
207
+ # ★見本が無い環境(npm 配布物など settings.sample.json が同梱されない)は rc=7 で
208
+ # 「hooks 配布はスキップ・権限整理だけ進む」を意味し、止めない(壊さない)。
209
+ info "①-0 見本と配布内容の照合(ドリフト検査)"
210
+ drift_out="$(INSTALL_FILES="${INSTALL_FILES[*]}" python3 - "$SAMPLE_SETTINGS" "$GUARD_SRC_DIR" <<'PYEOF'
211
+ import json, os, sys
212
+
213
+ sample_path, src_dir = sys.argv[1], sys.argv[2]
214
+ install = set(os.environ.get("INSTALL_FILES", "").split())
215
+
216
+ try:
217
+ sample = json.load(open(sample_path, encoding="utf-8"))
218
+ except FileNotFoundError:
219
+ print("SKIP 見本が無い環境(npm 配布物など)。hooks 配布はスキップし、権限整理だけ進みます。")
220
+ sys.exit(7)
221
+ except Exception as e:
222
+ print("FAIL 見本が JSON として読めません: %s" % e)
223
+ sys.exit(1)
224
+
225
+ hooks = sample.get("hooks", {})
226
+ if not isinstance(hooks, dict) or not hooks:
227
+ print("FAIL 見本に hooks がありません(配る対象が決められない)")
228
+ sys.exit(1)
229
+
230
+ refs = set()
231
+ for ev, groups in hooks.items():
232
+ for g in (groups or []):
233
+ for h in g.get("hooks", []):
234
+ cmd = h.get("command", "")
235
+ if cmd:
236
+ refs.add(os.path.basename(cmd.split()[-1]))
237
+
238
+ missing_src = sorted(b for b in refs if not os.path.isfile(os.path.join(src_dir, b)))
239
+ not_installed = sorted(b for b in refs if b not in install)
240
+ if missing_src:
241
+ print("FAIL 見本が参照するスクリプトが正本にありません: %s" % ", ".join(missing_src))
242
+ sys.exit(1)
243
+ if not_installed:
244
+ print("FAIL 見本が参照するスクリプトが設置一覧(INSTALL_FILES)にありません: %s" % ", ".join(not_installed))
245
+ print(" → ①で GUARD_HOME に置かれないため、配っても現場で動きません。INSTALL_FILES に足してください。")
246
+ sys.exit(1)
247
+ print("OK 見本の %d イベント(%s)を配布。参照スクリプト %d 件はすべて設置対象です。"
248
+ % (len(hooks), ", ".join(hooks.keys()), len(refs)))
249
+ PYEOF
250
+ )" && drift_rc=0 || drift_rc=$?
251
+ echo "$drift_out" | sed 's/^/[update-guard] /'
252
+ if [[ "${drift_rc:-1}" == "1" ]]; then
253
+ fail "見本と配布内容がズレています(上記)。見本または INSTALL_FILES を直してから再実行してください。"
254
+ fi
255
+ # rc=0: 見本どおり登録する/ rc=7: 見本が無いので hooks は触らない(権限整理は進む)。
256
+ # ★rc=7 のときは①-eも見本を読めず HOME_WIRED_EVENTS が空になる=③は何も外さない。
257
+ # =見本が読めない環境では、現場に既にある登録をそのまま残す(守りを後退させない)。
258
+
259
+ info "① 番人一式を設置: $GUARD_HOME"
260
+ if [[ "$APPLY" == "1" ]]; then
261
+ # 置く前に構文を確認する。壊れた Python を固定パスへ置くと全現場が同時に止まるため。
262
+ if ! python3 -m py_compile "${INSTALL_FILES[@]/#/$GUARD_SRC_DIR/}"; then
263
+ fail "番人一式の構文確認に失敗しました。固定パスは変更していません。"
264
+ fi
265
+ mkdir -p "$GUARD_HOME"
266
+ for f in "${INSTALL_FILES[@]}"; do
267
+ cp -f "$GUARD_SRC_DIR/$f" "$GUARD_HOME/$f"
268
+ done
269
+ for e in "${ENTRYPOINTS[@]}"; do
270
+ chmod +x "$GUARD_HOME/$e"
271
+ done
272
+ for conf in "${INSTALL_CONFIGS[@]}"; do
273
+ if [[ -f "$GUARD_SRC_DIR/$conf" ]]; then
274
+ # 番人は「自分と同じフォルダ」の設定を読むので、必ず一緒に置く。
275
+ cp -f "$GUARD_SRC_DIR/$conf" "$GUARD_HOME/$conf"
276
+ info " 設定も設置: $GUARD_HOME/$conf"
277
+ fi
278
+ done
279
+ info " 完了(入口 ${#ENTRYPOINTS[@]} 件・一式 ${#INSTALL_FILES[@]} 件を設置)"
280
+ else
281
+ info " (予定)入口 ${#ENTRYPOINTS[@]} 件を含む一式 ${#INSTALL_FILES[@]} 件を上書き設置"
282
+ fi
283
+
284
+ # ─── ①-d 職人の鉄則スキルを最新へ揃える(機体1回・★同期モードでも行う) ─────────
285
+ # 【なぜ要るか】setup.sh ⑤ は新規設置のときだけ ~/.claude/skills/ へ鉄則を置く。
286
+ # 稼働中の機体では、リポジトリの正本を直しても職人が読むのは古い写しのままだった。
287
+ # =鉄則を直しても現場の職人の動きは変わらない(2026-09-01、鉄則8のPlaywright作法を
288
+ # 直した便で判明。ADR-010)。ここで setup.sh ⑤ と同じ配置を「更新側」にも用意する。
289
+ # ★番人と同じく「1台に1箇所」($HOME/.claude/skills)。現場ごとの写しは作らない。
290
+ #
291
+ # 【★2026-09-16 に同期モードへ入れた理由(えふさん確定・便 29612ff1)】
292
+ # 1時間ごとの自動同期は SYNC_ONLY=1 で走り、ここを飛ばしていた。そのため
293
+ # 「番人のコードは1時間で届くのに、鉄則だけは人が APPLY=1 を打つまで届かない」状態が
294
+ # 毎時再生産されていた(便 4a5bbb9f で実測。鉄則7を直したのに現場の職人は古い文のまま)。
295
+ # 鉄則は安全境界ではなく作業規律だが、★古いまま気づけないことが事故の元なので自動で配る。
296
+ #
297
+ # 【★配り損ねたら黙って進まない】
298
+ # 正本が無い・中身が空・書けない のどれでも、
299
+ # ① 最後の成功の控え(.state)は【書き換えない】=成功と偽らない
300
+ # ② 直近の試みの結果(.last)に失敗として残す
301
+ # ③ 管制へ知らせる(skill_sync_notify.py)
302
+ # =「古い鉄則のまま動いている」ことが、機体の外から分かる。
303
+ CRAFTSMAN_SKILL_SRC="$DIST_ROOT/skills/conductor-craftsman/SKILL.md"
304
+ CRAFTSMAN_SKILL_DST="$HOME/.claude/skills/conductor-craftsman/SKILL.md"
305
+ # ★控えは番人の置き場に置く(職人は $HOME/.tyhld へ書けない=あとから書き換えられない)。
306
+ CRAFTSMAN_STATE="$GUARD_HOME/craftsman-skill.state" # 最後に【成功した】配布
307
+ CRAFTSMAN_LAST="$GUARD_HOME/craftsman-skill.last" # 直近の【試み】の結果
308
+ CRAFTSMAN_NOTIFY="$SCRIPT_DIR/hooks/skill_sync_notify.py"
309
+
310
+ # 版=この正本が入っている配布物の版(package.json)。無ければ unknown。
311
+ craftsman_skill_version() {
312
+ local pj="$DIST_ROOT/package.json"
313
+ [[ -f "$pj" ]] || { printf 'unknown'; return; }
314
+ python3 -c 'import json,sys
315
+ try:
316
+ print(json.load(open(sys.argv[1], encoding="utf-8")).get("version") or "unknown")
317
+ except Exception:
318
+ print("unknown")' "$pj" 2>/dev/null || printf 'unknown'
319
+ }
320
+
321
+ # 中身の見分け(先頭12文字)。★版が同じでも中身が違えば分かる。
322
+ craftsman_skill_sha() {
323
+ [[ -f "$1" ]] || { printf 'none'; return; }
324
+ sha256sum "$1" 2>/dev/null | cut -c1-12 || printf 'none'
325
+ }
326
+
327
+ # 直近の試みの結果を1行残す。★成功のときだけ .state も更新する。
328
+ craftsman_record() {
329
+ local status="$1" reason="$2" ver="$3" sha="$4"
330
+ local line
331
+ line="$(date '+%Y-%m-%dT%H:%M:%S%z') status=${status} reason=${reason} version=${ver} sha=${sha} src=${CRAFTSMAN_SKILL_SRC} dst=${CRAFTSMAN_SKILL_DST}"
332
+ mkdir -p "$GUARD_HOME" 2>/dev/null || true
333
+ printf '%s\n' "$line" > "$CRAFTSMAN_LAST" 2>/dev/null || true
334
+ if [[ "$status" == "ok" ]]; then
335
+ printf '%s\n' "$line" > "$CRAFTSMAN_STATE" 2>/dev/null || true
336
+ fi
337
+ }
338
+
339
+ # 配り損ねを管制へ知らせる(★届かなくても同期は止めない)。
340
+ craftsman_notify_failure() {
341
+ [[ -f "$CRAFTSMAN_NOTIFY" ]] || return 0
342
+ python3 "$CRAFTSMAN_NOTIFY" "$CRAFTSMAN_LAST" >/dev/null 2>&1 || true
343
+ }
344
+
345
+ info "①-d 職人の鉄則スキル: $CRAFTSMAN_SKILL_DST"
346
+ CRAFTSMAN_VER="$(craftsman_skill_version)"
347
+ CRAFTSMAN_SHA="$(craftsman_skill_sha "$CRAFTSMAN_SKILL_SRC")"
348
+ if [[ ! -f "$CRAFTSMAN_SKILL_SRC" ]]; then
349
+ warn "鉄則の正本が見つかりません: $CRAFTSMAN_SKILL_SRC(★古い鉄則のまま動きます)"
350
+ craftsman_record failed src-missing "$CRAFTSMAN_VER" none
351
+ craftsman_notify_failure
352
+ elif [[ ! -s "$CRAFTSMAN_SKILL_SRC" ]]; then
353
+ warn "鉄則の正本が空です: $CRAFTSMAN_SKILL_SRC(★古い鉄則のまま動きます)"
354
+ craftsman_record failed src-empty "$CRAFTSMAN_VER" none
355
+ craftsman_notify_failure
356
+ elif cmp -s "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null; then
357
+ info " 最新です(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA})"
358
+ craftsman_record ok already-current "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
359
+ elif [[ "$APPLY" == "1" ]]; then
360
+ if mkdir -p "$(dirname "$CRAFTSMAN_SKILL_DST")" 2>/dev/null \
361
+ && cp -f "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null \
362
+ && cmp -s "$CRAFTSMAN_SKILL_SRC" "$CRAFTSMAN_SKILL_DST" 2>/dev/null; then
363
+ info " 更新しました(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA}・この1台の全現場が共有します)"
364
+ craftsman_record ok applied "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
365
+ else
366
+ warn "鉄則を配れませんでした: $CRAFTSMAN_SKILL_DST(★古い鉄則のまま動きます)"
367
+ craftsman_record failed write-failed "$CRAFTSMAN_VER" "$CRAFTSMAN_SHA"
368
+ craftsman_notify_failure
369
+ fi
370
+ else
371
+ info " (予定)正本の内容へ揃えます(版 ${CRAFTSMAN_VER} / 中身 ${CRAFTSMAN_SHA})"
372
+ fi
373
+
374
+ # ★同期モードは ①-b・①-c・①-d2 を飛ばす(掃除・タイマー・pnpm 点検は「番人のコード」ではない)。
375
+ # ★鉄則スキル(①-d)は上で【同期モードでも】行う(便 29612ff1)。
376
+ # 飛ばす分は、人が APPLY=1 で通常どおり流したときに従来どおり行われる。
377
+ if [[ "$SYNC_ONLY" == "1" ]]; then
378
+ info "①-b・①-c・①-d2 は省略(同期モード)"
379
+ else
380
+
381
+ # ─── ①-b paste-cache の掃除(機体1回・現場ループの外) ──────────────────────────
382
+ # 画面貼り付け由来の一時ファイルが ~/.claude/paste-cache に平文で溜まる(秘密を含み得る)。
383
+ # 古い分だけ消す。今日ぶん(未送信・参照中かもしれない分)は mtime で残す=全消しはしない。
384
+ # ★触るのは ~/.claude/paste-cache だけ。件数のみ報告し、中身・ファイル名は出さない。
385
+ PASTE_CACHE_DIR="$HOME/.claude/paste-cache"
386
+ PASTE_CACHE_KEEP_DAYS="${PASTE_CACHE_KEEP_DAYS:-1}"
387
+ info "①-b paste-cache 掃除: $PASTE_CACHE_DIR($PASTE_CACHE_KEEP_DAYS 日より古い分)"
388
+ if [[ -d "$PASTE_CACHE_DIR" ]]; then
389
+ # 件数のみ数える(-mindepth/-maxdepth 1 でこのフォルダ直下のファイルに限定)。
390
+ pc_count="$(find "$PASTE_CACHE_DIR" -mindepth 1 -maxdepth 1 -type f -mtime +"$PASTE_CACHE_KEEP_DAYS" 2>/dev/null | wc -l)"
391
+ if [[ "$APPLY" == "1" ]]; then
392
+ find "$PASTE_CACHE_DIR" -mindepth 1 -maxdepth 1 -type f -mtime +"$PASTE_CACHE_KEEP_DAYS" -delete 2>/dev/null || true
393
+ info " $pc_count 件を削除しました"
394
+ else
395
+ info " (予定)$pc_count 件を削除します(今は消しません)"
396
+ fi
397
+ else
398
+ info " paste-cache は未作成のためスキップ"
399
+ fi
400
+
401
+ # ─── ①-c 毎日自動掃除タイマーを設置(顧客機は手動で回さないため必須) ──────────
402
+ # 恒久策の本体。setup.sh(新規機)と同じ installer を APPLY 時に呼ぶ(冪等)。
403
+ PRUNE_INSTALL="$DIST_ROOT/sales-template/systemd/paste-cache-prune-install.sh"
404
+ info "①-c 毎日自動掃除タイマー: tyhld-paste-cache-prune.timer"
405
+ if [[ "$APPLY" == "1" ]]; then
406
+ if [[ -f "$PRUNE_INSTALL" ]]; then
407
+ bash "$PRUNE_INSTALL" || warn "自動掃除タイマーの設置に失敗しました(掃除自体は上の①-bで実施済み)"
408
+ else
409
+ warn "installer が見つかりません: $PRUNE_INSTALL(タイマー設置をスキップ)"
410
+ fi
411
+ else
412
+ info " (予定)systemd --user タイマーを enable --now します"
413
+ fi
414
+
415
+ # ─── ①-d2 pnpm の版そろえ(corepack)を確かめる(機体1回・現場ループの外・ADR-015) ────
416
+ # 【なぜ要るか】実機 2026-09-02: TYHLD-platform で pnpm を使うと
417
+ # [ERR_PNPM_UNSUPPORTED_ENGINE] Expected version: >=10.0.0 <11.0.0 / Got: 11.0.9
418
+ # で弾かれ、毎回 `npx --yes pnpm@10 …` と打っていた(毎回ダウンロード=遅い・打ち間違う)。
419
+ # 仕組みとしては corepack が package.json の packageManager を読んで正しい版を使うので、
420
+ # ★corepack のシムさえ在れば「pnpm と打つだけ」で正しい版になる(実測: 現場の中では 10.33.4)。
421
+ # 逆に【シムが無い機体】では pnpm 自体が無い/別物になり、同じ事故が起きる。そこを点検する。
422
+ # 【やること】
423
+ # ・corepack の pnpm シムが在るか点検し、無ければ APPLY のときだけ `corepack enable` で作る
424
+ # ・pnpm を使うのに packageManager の指定が無い現場を【一覧で報告するだけ】(勝手に足さない。
425
+ # どの版が正かは、そのリポジトリの持ち主が決めることなので AI は書き換えない)
426
+ # ★ここは作業道具の話であって安全境界ではないので、失敗しても中断しない(警告して進む)。
427
+ info "①-d2 pnpm の版そろえ(corepack)"
428
+ if ! command -v corepack >/dev/null 2>&1; then
429
+ warn "corepack が見つかりません(Node に同梱。Node を入れ直すと直ります)"
430
+ elif command -v pnpm >/dev/null 2>&1; then
431
+ info " pnpm は使えます($(command -v pnpm))"
432
+ elif [[ "$APPLY" == "1" ]]; then
433
+ if corepack enable >/dev/null 2>&1; then
434
+ info " corepack enable を実行しました(これで pnpm と打つだけで正しい版になります)"
435
+ else
436
+ warn "corepack enable に失敗しました(人の手番: corepack enable。sudo が要る機体もあります)"
437
+ fi
438
+ else
439
+ info " (予定)corepack enable を実行します"
440
+ fi
441
+ # pnpm を使うのに版の指定が無い現場を挙げる(読むだけ・書き換えない)。
442
+ PNPM_NO_PIN="$(python3 - "$HOME/projects" <<'PYEOF'
443
+ import json, os, sys
444
+
445
+ root = sys.argv[1]
446
+ out = []
447
+ if os.path.isdir(root):
448
+ for name in sorted(os.listdir(root)):
449
+ proj = os.path.join(root, name)
450
+ pkg = os.path.join(proj, "package.json")
451
+ if not os.path.isfile(pkg):
452
+ continue
453
+ uses_pnpm = any(os.path.exists(os.path.join(proj, f))
454
+ for f in ("pnpm-lock.yaml", "pnpm-workspace.yaml"))
455
+ if not uses_pnpm:
456
+ continue
457
+ try:
458
+ with open(pkg, encoding="utf-8") as f:
459
+ if not (json.load(f).get("packageManager") or "").strip():
460
+ out.append(name)
461
+ except Exception: # noqa: BLE001
462
+ continue
463
+ print(" ".join(out))
464
+ PYEOF
465
+ )"
466
+ if [[ -n "$PNPM_NO_PIN" ]]; then
467
+ warn "pnpm を使うのに packageManager の指定が無い現場: $PNPM_NO_PIN"
468
+ info " → その現場では既定の新しい pnpm が動きます。持ち主が package.json へ"
469
+ info " \"packageManager\": \"pnpm@<いま使っている版>\" を足してください(ここでは書き換えません)"
470
+ else
471
+ info " pnpm を使う現場はすべて版が指定されています"
472
+ fi
473
+
474
+ fi # ← ①-b〜①-d2 ここまで(同期モードでは丸ごと省略)
475
+
476
+ # ─── ①-e ホームの設定が「古い番人」を指していないか直す(機体1回・現場ループの外) ────
477
+ # 【なぜ要るか(★今回の根本原因・ADR-011)】
478
+ # 番人は現場ごとの .claude/settings.json だけでなく、ホームの ~/.claude/settings.json にも
479
+ # 登録できる。Claude Code は【両方】のフックを走らせる。ところがこのスクリプトは②で
480
+ # ~/projects 配下の現場しか見ていなかったため、ホーム側が別の場所(実機では
481
+ # $HOME/.tyhld/hookset/)を指したまま置き去りになっていた。
482
+ # その写しは 2026-08-23 のもので、確認カードを出すかどうかの判定(ADR-008)が【入っていない】。
483
+ # =正しい判定を入れた新しい番人と、判定の無い古い番人が同居し、古い方がカードを出していた。
484
+ # 実機 2026-09-02 03:37「PR #568 を作成し…CI の完了を待っています。」というただの報告で
485
+ # 「担当者から質問があります」のカードが出た事故は、これが原因(ADR-011 に調査の記録)。
486
+ #
487
+ # 【やること(★ADR-013 で拡張)】ホームを番人の【唯一の登録場所】として確立する。
488
+ # ・見本のイベントの登録が無ければ、見本どおり足す(登録場所が1か所になるので、ここが
489
+ # 欠けると守りが丸ごと消える。従来は「あるものを向け直す」だけで、足りない分は放置だった)
490
+ # ・登録があれば、コマンドのパスだけ固定パス $GUARD_HOME へ向け直す
491
+ # ★permissions(allow/ask/deny)にも、他のイベントにも、他のキーにも一切触れない
492
+ # (現場向けの④とは別扱い=ホームの設定は全現場に効くので最小限にする)。
493
+ # ★これで「古い写しが動き続ける」を構造的に断つ。写し自体は消さない(人が確認してから消せる)。
494
+ # ★ここで確立できたイベントだけを、③で現場から外す(フェイルクローズ=守りを一瞬も空けない)。
495
+ HOME_SETTINGS="$HOME/.claude/settings.json"
496
+ HOME_WIRED_FILE="$(mktemp)"
497
+ trap 'rm -f "$HOME_WIRED_FILE"' EXIT
498
+ info "①-e ホームの番人の配線(★ここだけが登録場所): $HOME_SETTINGS"
499
+ if [[ ! -f "$HOME_SETTINGS" ]]; then
500
+ warn "ホームの設定がありません: $HOME_SETTINGS(現場の登録は外しません=守りを空けない)"
501
+ else
502
+ APPLY="$APPLY" GUARD_HOME="$GUARD_HOME" HOME_WIRED_FILE="$HOME_WIRED_FILE" \
503
+ python3 - "$HOME_SETTINGS" "$SAMPLE_SETTINGS" <<'PYEOF'
504
+ import json, os, shutil, sys, time
505
+
506
+ dst_path, sample_path = sys.argv[1], sys.argv[2]
507
+ apply_mode = os.environ.get("APPLY") == "1"
508
+ guard_home = os.environ["GUARD_HOME"]
509
+ wired_file = os.environ.get("HOME_WIRED_FILE", "")
510
+
511
+ def load(p):
512
+ with open(p, encoding="utf-8") as f:
513
+ return json.load(f)
514
+
515
+ try:
516
+ dst = load(dst_path)
517
+ except Exception as e:
518
+ print(f"[update-guard] 注意: JSON として読めないので触りません: {dst_path} ({e})", file=sys.stderr)
519
+ sys.exit(0)
520
+
521
+ # 登録するイベントは見本が正(④と同じ考え方)。見本が無い環境では何もしない。
522
+ try:
523
+ sample_hooks = load(sample_path).get("hooks") or {}
524
+ except Exception:
525
+ sample_hooks = {}
526
+ if not sample_hooks:
527
+ print("[update-guard] 見本が読めないため配線の点検はスキップします")
528
+ sys.exit(0)
529
+
530
+ def wired_ok():
531
+ """ここまでで確立できたイベント名を③へ渡す(★これが無い=現場から外さない)。"""
532
+ if wired_file:
533
+ with open(wired_file, "w", encoding="utf-8") as f:
534
+ f.write(" ".join(sorted(sample_hooks)))
535
+
536
+ def to_fixed_path(cmd):
537
+ # "python3 /どこか/xxx.py" → "python3 <GUARD_HOME>/xxx.py"
538
+ parts = cmd.split()
539
+ parts[-1] = os.path.join(guard_home, os.path.basename(parts[-1]))
540
+ return " ".join(parts)
541
+
542
+ hooks = dst.get("hooks")
543
+ if not isinstance(hooks, dict):
544
+ hooks = {}
545
+
546
+ changed = []
547
+ for ev in sorted(sample_hooks):
548
+ groups = hooks.get(ev)
549
+ has_cmd = isinstance(groups, list) and any(
550
+ isinstance(h, dict) and isinstance(h.get("command"), str) and h["command"].strip()
551
+ for g in groups if isinstance(g, dict) for h in (g.get("hooks") or []))
552
+ if not has_cmd:
553
+ # ★登録が無い/空。ホームが唯一の登録場所なので、ここで見本どおり足す。
554
+ new_groups = []
555
+ for g in (sample_hooks[ev] or []):
556
+ ng = dict(g)
557
+ ng["hooks"] = [
558
+ {**h, "command": to_fixed_path(h["command"])}
559
+ if isinstance(h, dict) and isinstance(h.get("command"), str) and h["command"].strip() else h
560
+ for h in (g.get("hooks") or [])
561
+ ]
562
+ new_groups.append(ng)
563
+ hooks[ev] = new_groups
564
+ changed.append(f"{ev}: 登録が無いので見本どおり追加(→ {guard_home})")
565
+ continue
566
+ for group in groups:
567
+ for h in (group.get("hooks") or []):
568
+ cmd = h.get("command")
569
+ if not isinstance(cmd, str) or not cmd.strip():
570
+ continue
571
+ parts = cmd.split()
572
+ old = parts[-1]
573
+ new = os.path.join(guard_home, os.path.basename(old))
574
+ if old == new:
575
+ continue
576
+ parts[-1] = new
577
+ h["command"] = " ".join(parts)
578
+ changed.append(f"{ev}: {os.path.dirname(old) or '(相対)'} → {guard_home}")
579
+ dst["hooks"] = hooks
580
+
581
+ if not changed:
582
+ print("[update-guard] 最新です(見本の全イベントが固定パスを指しています)")
583
+ wired_ok()
584
+ sys.exit(0)
585
+
586
+ print("[update-guard] ★ホームの配線を直します(ここが番人の唯一の登録場所)")
587
+ for c in changed:
588
+ print(f"[update-guard] - {c}")
589
+ if not apply_mode:
590
+ wired_ok() # 予定として③に渡す(この実行では何も書かない)
591
+ sys.exit(0)
592
+
593
+ backup = f"{dst_path}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
594
+ shutil.copy2(dst_path, backup)
595
+ tmp = dst_path + ".tmp"
596
+ with open(tmp, "w", encoding="utf-8") as f:
597
+ json.dump(dst, f, indent=2, ensure_ascii=False)
598
+ f.write("\n")
599
+ os.replace(tmp, dst_path)
600
+ print(f"[update-guard] 直しました(バックアップ: {backup})")
601
+ wired_ok() # ★書き込みが成功したときだけ確立とみなす
602
+ PYEOF
603
+ fi
604
+ HOME_WIRED_EVENTS="$(cat "$HOME_WIRED_FILE" 2>/dev/null || true)"
605
+ if [[ -z "$HOME_WIRED_EVENTS" ]]; then
606
+ warn "ホームに番人を確立できませんでした。現場の登録は外しません(守りを空けない)"
607
+ fi
608
+
609
+ # ─── ①-e2 Codex の職人にも番人を登録する(★ADR-022) ────────────────────────
610
+ # 【なぜ要るか】Codex を職人にした現場では、登録先が Claude と別(~/.codex/hooks.json)。
611
+ # ここを配らないと「番人の効かない職人」が動く。ADR-021 で種類を足した時点の残穴。
612
+ # 【安全の要(実測 2026-09-02・codex 0.152.1)】
613
+ # ・PreToolUse は Claude とまったく同じ形の JSON で deny を返せる(実機で遮断を確認)
614
+ # ・PermissionRequest / Stop も同じ項目名で呼ばれる
615
+ # ・★Notification は存在しない=ADR-016 の「端末でしか押せない問い」は Codex では出せない
616
+ # ★Claude 側(①-e)には一切触れない。Codex を使っていない機体では何もしない。
617
+ info "①-e2 Codex の職人への番人登録"
618
+ CODEX_HOOK_INSTALL="$SCRIPT_DIR/hooks/install-codex-hooks.sh"
619
+ if [[ -f "$CODEX_HOOK_INSTALL" ]]; then
620
+ APPLY="$APPLY" GUARD_HOME="$GUARD_HOME" bash "$CODEX_HOOK_INSTALL" \
621
+ || warn "Codex への番人登録に失敗しました(Claude 側の設置は完了しています)"
622
+ else
623
+ warn "Codex 用の登録スクリプトが見つかりません: $CODEX_HOOK_INSTALL(スキップ)"
624
+ fi
625
+
626
+ # ─── ①-e3 外への通信の全許可をホームへ配る(★えふさん決定 2026-09-03=甲) ──────
627
+ # 【なぜホームなのか】サンドボックスの許可ホスト一覧は「ホーム・現場・フラグ」の全設定源を
628
+ # 合算して作られる(実測 2026-09-03: ホームにだけ "*" を置いた状態で、sandbox セクションを
629
+ # 持たない現場から通信できた)。④の現場向け追記は「sandbox セクションが既に在る現場」しか
630
+ # 対象にしないので、conductor のように sandbox を持たない現場には届かない。
631
+ # ★ホーム1か所に置けば、現場が増えても全部に効く(番人の登録と同じ「1台1箇所」の形)。
632
+ #
633
+ # 【なぜ全部通すのか】通信許可の問い(Network request outside of sandbox)は★管制の3ボタンでは
634
+ # 押せない。Claude Code 2.1.258 本体でこの問いは PermissionRequest フックに繋がっておらず、
635
+ # 答えられるのは端末のダイアログか Anthropic 公式のリモート操作だけ(実測 2026-09-03・便
636
+ # 622e5497)。=人の目が無い現場では職人が止まったままになる。止めても止めなくても最後は人が
637
+ # PR の結果を見て直すので、途中で止める意味が無い(えふさん決定)。
638
+ #
639
+ # 【★緩むのは「電話をかける」だけ】秘密の持ち出しを止めるのは番人(cc2_guard.py + policy.py)の
640
+ # 「対象と作用」判定で、通信の設定とは別の仕組み。番人は allowedDomains を1文字も参照して
641
+ # いない(test/network-always-allow.test.ts が独立を機械で固定)。deny の一覧も一切触らない。
642
+ # deniedDomains は allowedDomains より先に判定されるので、本番DB直結の禁止も効いたまま。
643
+ #
644
+ # ★触るのは sandbox.network.allowedDomains ただ1つ。hooks・permissions・他のキーには触れない。
645
+ # ★追加のみ(既存の宣言は消さない)・冪等(2回流しても差分は出ない)。
646
+ # ★同期モード(無人・1時間ごと)では行わない。許可の範囲を広げる変更は、人が見ているときだけ
647
+ # にする(同期モードの約束=番人のコードと配線だけを揃え、設定の中身は作り変えない)。
648
+ info "①-e3 外への通信の全許可をホームへ配る: $HOME_SETTINGS"
649
+ if [[ "$SYNC_ONLY" == "1" ]]; then
650
+ info " 省略(同期モード。人が APPLY=1 で流したときだけ配ります)"
651
+ elif [[ ! -f "$HOME_SETTINGS" ]]; then
652
+ warn "ホームの設定がありません: $HOME_SETTINGS(通信の全許可は配れません)"
653
+ else
654
+ APPLY="$APPLY" python3 - "$HOME_SETTINGS" "$SAMPLE_SETTINGS" <<'PYEOF'
655
+ import json, os, shutil, sys, time
656
+
657
+ dst_path, sample_path = sys.argv[1], sys.argv[2]
658
+ apply_mode = os.environ.get("APPLY") == "1"
659
+
660
+ def load(p):
661
+ with open(p, encoding="utf-8") as f:
662
+ return json.load(f)
663
+
664
+ try:
665
+ dst = load(dst_path)
666
+ except Exception as e:
667
+ print(f"[update-guard] 注意: JSON として読めないので触りません: {dst_path} ({e})", file=sys.stderr)
668
+ sys.exit(0)
669
+
670
+ # 配る中身は見本(settings.sample.json)が正。見本が読めない環境では何もしない(壊さない)。
671
+ try:
672
+ want = load(sample_path).get("sandbox", {}).get("network", {}).get("allowedDomains", [])
673
+ except Exception:
674
+ want = []
675
+ want = [x for x in want if isinstance(x, str)]
676
+ if not want:
677
+ print("[update-guard] 見本が読めない/宣言が空のためスキップします")
678
+ sys.exit(0)
679
+
680
+ net = dst.setdefault("sandbox", {}).setdefault("network", {})
681
+ cur = net.get("allowedDomains")
682
+ if not isinstance(cur, list):
683
+ cur = []
684
+ missing = [x for x in want if x not in cur]
685
+ if not missing:
686
+ print("[update-guard] 最新です(変更なし)")
687
+ sys.exit(0)
688
+
689
+ net["allowedDomains"] = cur + missing
690
+ print(f"[update-guard] ★通信の宣言を {len(missing)} 件追記します: {', '.join(missing)}")
691
+ if "*" in missing:
692
+ print("[update-guard] (\"*\" =外への通信は全部通す。秘密の持ち出しは番人が別に止めます)")
693
+ if not apply_mode:
694
+ sys.exit(0)
695
+
696
+ backup = f"{dst_path}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
697
+ shutil.copy2(dst_path, backup)
698
+ tmp = dst_path + ".tmp"
699
+ with open(tmp, "w", encoding="utf-8") as f:
700
+ json.dump(dst, f, indent=2, ensure_ascii=False)
701
+ f.write("\n")
702
+ os.replace(tmp, dst_path)
703
+ print(f"[update-guard] 追記しました(バックアップ: {backup})")
704
+ PYEOF
705
+ fi
706
+
707
+ # ─── ①-f 「直したのに届かない」を無くす自動同期タイマー(★ADR-012 の本体) ──────
708
+ # 【なぜ要るか】ここまでの ① と ①-e は、人がこのスクリプトを流したときにしか動かない。
709
+ # 実地では2度続けて流されず、直した番人が機体に1文字も届かないまま何時間も誤ったカードが
710
+ # 出続けた(2026-09-02 03:37 と 11:04)。人が忘れる前提で設計し直す。
711
+ # 【何をするか】SYNC_ONLY=1 APPLY=1 でこのスクリプトを1時間ごとに回す systemd --user
712
+ # タイマーを設置する。番人のコードと配線だけが自動で最新になる(権限も掃除も触らない)。
713
+ # ★①-c(paste-cache の掃除タイマー)と同じ流儀。APPLY のときだけ設置する。
714
+ GUARD_SYNC_INSTALL="$DIST_ROOT/systemd/guard-sync-install.sh"
715
+ info "①-f 番人の自動同期タイマー: tyhld-guard-sync.timer"
716
+ if [[ "$APPLY" == "1" ]]; then
717
+ if [[ -f "$GUARD_SYNC_INSTALL" ]]; then
718
+ bash "$GUARD_SYNC_INSTALL" || warn "自動同期タイマーの設置に失敗しました(手で流せば従来どおり最新になります)"
719
+ else
720
+ warn "installer が見つかりません: $GUARD_SYNC_INSTALL(タイマー設置をスキップ)"
721
+ fi
722
+ else
723
+ info " (予定)1時間ごとに番人のコードと配線だけを揃えるタイマーを enable --now します"
724
+ fi
725
+
726
+ # ─── ② 対象の現場を決める ───────────────────────────────────────────────────
727
+ # ★同期モードでもここまで来る。現場の二重登録を外す(③)のは「番人の配線」であって
728
+ # 権限ルールではないため(ADR-013)。権限の整理は④=同期モードでは行わない。
729
+ targets=()
730
+ if [[ $# -gt 0 ]]; then
731
+ targets=("$@")
732
+ else
733
+ # 引数が無ければ ~/projects/*/.claude/settings.json を持つ現場を自動で探す。
734
+ while IFS= read -r s; do
735
+ targets+=("$(cd "$(dirname "$(dirname "$s")")" && pwd)")
736
+ done < <(find "$HOME/projects" -maxdepth 3 -path '*/.claude/settings.json' -type f 2>/dev/null | sort)
737
+ fi
738
+
739
+ if [[ ${#targets[@]} -eq 0 ]]; then
740
+ info "② 対象の現場が見つかりませんでした(settings.json を持つ現場なし)。番人の設置だけ行いました。"
741
+ exit 0
742
+ fi
743
+ info "② 対象の現場: ${#targets[@]} 件"
744
+
745
+ # ─── ③ 現場に残った番人の登録を外す(★ADR-013・hooks だけ・同期モードでも行う) ────
746
+ # 【なぜ要るか】番人はホームと現場の【両方】に登録でき、Claude Code は両方を走らせる。
747
+ # これまでは片方(ホーム)が古い写しを指して壊れていたため結果的にカードは1枚だったが、
748
+ # ADR-012 で両方が直った今は、本物の質問で同じカードが2枚出る(ADR-011 の残穴)。
749
+ # 【決定】登録はホーム1か所だけにする。新しい現場が増えても自動で効き、「片方だけ古い」
750
+ # 事故も起きない。現場ごとに番人を変える運用予定は無い(えふさん決定 2026-09-02)。
751
+ # 【安全】外すのは①-eでホームに確立できたイベントだけ。確立できていなければ1つも外さない。
752
+ # また外すのは番人の入口スクリプトを呼ぶ登録だけで、顧客が足した独自フックは残す。
753
+ # ★触るのは hooks だけ。permissions(deny/ask/allow)には一切触れない=守りは1ミリも動かない。
754
+ info "③ 現場の二重登録を外す(登録はホーム1か所だけにする)"
755
+ if [[ -z "$HOME_WIRED_EVENTS" ]]; then
756
+ info " 見送りました(ホームに番人を確立できていないため・上の①-e を参照)"
757
+ else
758
+ info " 対象イベント: $HOME_WIRED_EVENTS"
759
+ for proj in "${targets[@]}"; do
760
+ dst="$proj/.claude/settings.json"
761
+ [[ -f "$dst" ]] || continue
762
+ APPLY="$APPLY" HOME_WIRED_EVENTS="$HOME_WIRED_EVENTS" \
763
+ ENTRYPOINTS="${ENTRYPOINTS[*]}" python3 - "$dst" <<'PYEOF'
764
+ import json, os, shutil, sys, time
765
+
766
+ dst_path = sys.argv[1]
767
+ apply_mode = os.environ.get("APPLY") == "1"
768
+ events = os.environ.get("HOME_WIRED_EVENTS", "").split()
769
+ entrypoints = set(os.environ.get("ENTRYPOINTS", "").split())
770
+
771
+ try:
772
+ with open(dst_path, encoding="utf-8") as f:
773
+ dst = json.load(f)
774
+ except Exception as e:
775
+ print(f"[update-guard] 注意: JSON として読めないので触りません: {dst_path} ({e})", file=sys.stderr)
776
+ sys.exit(0)
777
+
778
+ hooks = dst.get("hooks")
779
+ if not isinstance(hooks, dict) or not hooks:
780
+ sys.exit(0)
781
+
782
+ def is_ours(entry):
783
+ """番人の入口スクリプト(cc2_guard/permission_request_hook/stop_hook)を呼ぶ登録か。
784
+ ★場所は問わない。古い写し($HOME/.tyhld/hookset など)を指す登録も同じ二重登録なので
785
+ まとめて外す。顧客が足した独自フックは名前が違うので残る。"""
786
+ if not isinstance(entry, dict):
787
+ return False
788
+ cmd = entry.get("command")
789
+ if not isinstance(cmd, str) or not cmd.strip():
790
+ return False
791
+ return os.path.basename(cmd.split()[-1]) in entrypoints
792
+
793
+ removed = []
794
+ for ev in events:
795
+ groups = hooks.get(ev)
796
+ if not isinstance(groups, list):
797
+ continue
798
+ kept_groups = []
799
+ for g in groups:
800
+ if not isinstance(g, dict):
801
+ kept_groups.append(g)
802
+ continue
803
+ entries = g.get("hooks") or []
804
+ keep = [h for h in entries if not is_ours(h)]
805
+ if len(keep) != len(entries):
806
+ removed.append(ev)
807
+ if keep:
808
+ ng = dict(g)
809
+ ng["hooks"] = keep
810
+ kept_groups.append(ng)
811
+ elif not entries:
812
+ kept_groups.append(g)
813
+ if kept_groups:
814
+ hooks[ev] = kept_groups
815
+ else:
816
+ del hooks[ev]
817
+
818
+ if not removed:
819
+ sys.exit(0)
820
+ if hooks:
821
+ dst["hooks"] = hooks
822
+ else:
823
+ dst.pop("hooks", None) # 空の hooks は残さない
824
+
825
+ print(f"[update-guard] 二重登録あり: {dst_path}")
826
+ print(f"[update-guard] - 外す: {', '.join(sorted(set(removed)))}(ホーム側だけを残す)")
827
+ if not apply_mode:
828
+ sys.exit(0)
829
+
830
+ backup = f"{dst_path}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
831
+ shutil.copy2(dst_path, backup)
832
+ tmp = dst_path + ".tmp"
833
+ with open(tmp, "w", encoding="utf-8") as f:
834
+ json.dump(dst, f, indent=2, ensure_ascii=False)
835
+ f.write("\n")
836
+ os.replace(tmp, dst_path)
837
+ print(f"[update-guard] 外しました(バックアップ: {backup})")
838
+ PYEOF
839
+ done
840
+ fi
841
+
842
+ # ★同期モードはここで終わり(この先は権限ルールの整理=人が見ている時だけ行う)。
843
+ if [[ "$SYNC_ONLY" == "1" ]]; then
844
+ echo
845
+ info "同期のみ完了しました(番人のコードと配線を正本へ揃えました)。"
846
+ exit 0
847
+ fi
848
+
849
+ # ─── ④ 各現場の権限ルールを部分マージで更新 ─────────────────────────────────
850
+ for proj in "${targets[@]}"; do
851
+ dst="$proj/.claude/settings.json"
852
+ if [[ ! -f "$dst" ]]; then
853
+ warn " スキップ(settings.json なし): $proj"
854
+ continue
855
+ fi
856
+ APPLY="$APPLY" \
857
+ python3 - "$dst" "$SETTINGS_SRC" "$SAMPLE_SETTINGS" <<'PYEOF'
858
+ import json, os, shutil, sys, time
859
+
860
+ dst_path, src_path, sample_path = sys.argv[1], sys.argv[2], sys.argv[3]
861
+ apply_mode = os.environ.get("APPLY") == "1"
862
+
863
+ def load(p):
864
+ with open(p, encoding="utf-8") as f:
865
+ return json.load(f)
866
+
867
+ try:
868
+ dst = load(dst_path)
869
+ except Exception as e:
870
+ print(f"[update-guard] 注意: JSON として読めないので触りません: {dst_path} ({e})", file=sys.stderr)
871
+ sys.exit(0)
872
+ src = load(src_path)
873
+
874
+ # 見本(settings.sample.json)を1回だけ読む。無い環境(npm 配布物など)では None にして壊さない。
875
+ _sample = None
876
+ try:
877
+ _sample = load(sample_path)
878
+ except Exception:
879
+ _sample = None
880
+
881
+ # 信頼できる部品取り寄せ先(settings.sample.json が正の一覧)。
882
+ sample_trusted_domains = []
883
+ if isinstance(_sample, dict):
884
+ _sd = _sample.get("sandbox", {}).get("network", {}).get("allowedDomains", [])
885
+ if isinstance(_sd, list):
886
+ sample_trusted_domains = [x for x in _sd if isinstance(x, str)]
887
+
888
+ changes = []
889
+
890
+ # --- (1) hooks には触れない(★ADR-013) ---
891
+ # 番人の登録はホーム1か所だけ(①-e)。現場に残っていた登録は③で外すので、ここでは
892
+ # hooks を一切読み書きしない。顧客が足した独自フックもそのまま残る。
893
+
894
+ # --- (2) permissions.deny の不足分を追記(追加のみ・削除しない) ---
895
+ perms = dst.setdefault("permissions", {})
896
+ cur = perms.get("deny")
897
+ if not isinstance(cur, list):
898
+ cur = []
899
+ want = src.get("permissions", {}).get("deny", [])
900
+ missing = [r for r in want if r not in cur]
901
+ if missing:
902
+ perms["deny"] = cur + missing
903
+ changes.append(f"禁止ルールを {len(missing)} 件追記")
904
+
905
+ # --- (2b) permissions.allow を「全削除」する(★deny は一切触らない) ---
906
+ # 【なぜ消すのか】番人(cc2_guard)の既定が allow(危険と関門だけを deny/ask に載せ、それ以外は
907
+ # 委譲=素通り)になったので、settings 側に allow を持つ必要がもう無い。むしろ allow が
908
+ # 1行でも残っていると、Claude Code の評価順(deny → ask → allow)で、その行に当たった操作は
909
+ # 番人の判定より先に allow で確定してしまい、関門(管制のカード)に載る前に素通りする。
910
+ # =allow は「関門を素通りさせる穴」になっている。だから現場から allow を丸ごと外す。
911
+ # ★deny は絶対に触らない・消さない・変えない(.env/.ssh/鍵/.tyhld/settings.json/hooks や
912
+ # Supabase の書込3種などの禁止は allow 削除後も当然そのまま効き続ける)。
913
+ # ★冪等: allow キーが無ければ何もしない(2回流しても差分は出ない)。
914
+ if "allow" in perms:
915
+ n = len(perms["allow"]) if isinstance(perms.get("allow"), list) else "?"
916
+ del perms["allow"]
917
+ changes.append(f"permissions.allow を全削除({n} 件・関門素通りの原因を除去。deny は非改変)")
918
+
919
+ # --- (3) サンドボックスのネット許可の不足分を追記 ---
920
+ # 職人が通信するたびに Claude Code のサンドボックスが「Network request outside of sandbox」を
921
+ # 聞いてくる。これは番人(cc2_guard)の判定とは別の OS レベルのゲートなので、番人では消えない。
922
+ # ★2026-09-03(甲)以降、見本の宣言は "*"(外への通信は全部通す)。この問いは管制の3ボタンでは
923
+ # 押せず、人の目が無いと職人が止まったままになるため(実測・便 622e5497)。
924
+ # ★本命の配布先はホーム(①-e3)。ここは sandbox セクションを持つ現場への念のための追記。
925
+ # ★deniedDomains(本番DB直結の禁止など)は allowedDomains より先に判定されるので、"*" を
926
+ # 入れても効いたまま。ここでは deniedDomains に一切触れない。
927
+ # (3-a) 自社インフラ(ty-hld.com / *.workers.dev)… sales-template から
928
+ # (3-b) 見本の宣言(現在は "*") … settings.sample.json から(宣言の正・ADR-004)
929
+ # ★どちらも「追加のみ・既存は消さない・重複を作らない」=permissions.deny 追記と同じ作法。
930
+ #
931
+ # ★sandbox セクションが無い現場には作らない。
932
+ # setup.sh は Supabase 不使用の現場で sandbox セクションごと削除する作りなので、
933
+ # 無い=意図的に外している可能性がある。そこへ新設するとサンドボックス自体を
934
+ # 有効化してしまい、挙動を大きく変える事故になる。無い場合は触らず報告に留める。
935
+ dst_sandbox = dst.get("sandbox")
936
+ if isinstance(dst_sandbox, dict):
937
+ net = dst_sandbox.setdefault("network", {})
938
+ cur_domains = net.get("allowedDomains")
939
+ if not isinstance(cur_domains, list):
940
+ cur_domains = []
941
+ # (3-a) 自社インフラだけを配る。顧客固有の値(<SUPABASE_PROJECT_ID> や各自の supabase ホスト)は配らない。
942
+ src_domains = src.get("sandbox", {}).get("network", {}).get("allowedDomains", [])
943
+ ours = [x for x in src_domains
944
+ if x.endswith("ty-hld.com") or x.endswith(".workers.dev")]
945
+ missing_infra = [x for x in ours if x not in cur_domains]
946
+ # (3-b) 信頼できる部品取り寄せ先(settings.sample.json の一覧)。★ここに無い接続先は勝手に足さない。
947
+ missing_trusted = [x for x in sample_trusted_domains
948
+ if x not in cur_domains and x not in missing_infra]
949
+ to_add = missing_infra + missing_trusted
950
+ if to_add:
951
+ net["allowedDomains"] = cur_domains + to_add
952
+ if missing_infra:
953
+ changes.append(f"自社インフラへの接続許可を {len(missing_infra)} 件追記")
954
+ if missing_trusted:
955
+ changes.append(f"部品取り寄せ先(npm/GitHub)への接続許可を {len(missing_trusted)} 件追記")
956
+ elif dst_sandbox is None:
957
+ print(f"[update-guard] ※ sandbox 設定が無いため接続許可は追記しません(この現場は対象外)")
958
+
959
+ # --- (3b) サンドボックスの素通り穴を塞ぐ(allowUnsandboxedCommands: false) ---
960
+ # 【穴】Bash ツールには dangerouslyDisableSandbox という引数があり、職人がこれを付けると
961
+ # コマンドがサンドボックスの外(ネット遮断も書込制限も無し)で走る。Claude Code は本来
962
+ # 「サンドボックスの外で走らせてよいか」を確認へ格上げするが、settings の allow ルールで
963
+ # 許可済みのコマンド(例 Bash(npm install:*) / Bash(git push:*))は格上げが抑止されるため、
964
+ # 確認カードも出ないまま素通りする(調査 1e4546eb / eea74fdc。実測 466 件)。
965
+ # 【栓】sandbox.allowUnsandboxedCommands を false にすると、本体は
966
+ # dangerouslyDisableSandbox 引数を完全に無視し、必ずサンドボックス内で実行する
967
+ # (Claude Code の設定スキーマ記述より。既定は true)。
968
+ #
969
+ # ★sandbox セクションが「既に在る現場」だけを対象にする。無い現場に新設しないのは (3) と
970
+ # 同じ理由(サンドボックス自体を有効化して挙動を大きく変える事故を起こさない)。
971
+ # ★触るのはこのキー1つだけ。enabled / failIfUnavailable / autoAllowBashIfSandboxed /
972
+ # allowedDomains / deniedDomains は一切変えない。
973
+ # ★true → false の是正は、このスクリプトで唯一の「値の変更」(他は追記のみ)なので、
974
+ # 何を変えたかを必ずログに出す。
975
+ if isinstance(dst_sandbox, dict):
976
+ cur_unsandboxed = dst_sandbox.get("allowUnsandboxedCommands")
977
+ if cur_unsandboxed is None:
978
+ dst_sandbox["allowUnsandboxedCommands"] = False
979
+ changes.append("sandbox.allowUnsandboxedCommands: false を追記(サンドボックス外実行の素通りを塞ぐ)")
980
+ elif cur_unsandboxed is not False:
981
+ dst_sandbox["allowUnsandboxedCommands"] = False
982
+ changes.append(f"sandbox.allowUnsandboxedCommands: {json.dumps(cur_unsandboxed)}→false 是正(サンドボックス外実行の素通りを塞ぐ)")
983
+ # 既に false なら何もしない=2回流しても差分は出ない(冪等)。
984
+
985
+ # --- 反映 ---
986
+ if not changes:
987
+ print(f"[update-guard] 最新です(変更なし): {dst_path}")
988
+ sys.exit(0)
989
+
990
+ print(f"[update-guard] 要更新: {dst_path}")
991
+ for c in changes:
992
+ print(f"[update-guard] - {c}")
993
+
994
+ if not apply_mode:
995
+ sys.exit(0)
996
+
997
+ # ★書き換える前に必ずバックアップ(変更が要るときだけ作る=バックアップが溜まらない)
998
+ backup = f"{dst_path}.bak.{time.strftime('%Y%m%d-%H%M%S')}"
999
+ shutil.copy2(dst_path, backup)
1000
+ tmp = dst_path + ".tmp"
1001
+ with open(tmp, "w", encoding="utf-8") as f:
1002
+ json.dump(dst, f, indent=2, ensure_ascii=False)
1003
+ f.write("\n")
1004
+ os.replace(tmp, dst_path) # 置換は原子的=途中で壊れた settings.json を残さない
1005
+ print(f"[update-guard] 更新しました(バックアップ: {backup})")
1006
+ PYEOF
1007
+ done
1008
+
1009
+ echo
1010
+ if [[ "$APPLY" == "1" ]]; then
1011
+ info "完了しました。職人(Claude Code)を再起動すると新しい設定で動きます。"
1012
+ else
1013
+ info "確認のみで終了しました。適用するには APPLY=1 を付けて再実行してください。"
1014
+ fi