@tyhld/conductor 0.12.21 → 0.12.23

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,207 @@
1
+ #!/usr/bin/env python3
2
+ """便2.6「番人の設定を弱める道を『手段を問わず』塞ぎ切る」の試験(便 0f92a767)。
3
+
4
+ 実行: python3 scripts/hooks/test_guard_config_all_routes.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【えふさん確定 2026-09-18】
8
+ 4経路を DENY するだけでなく、別の書き込み手段・shell 構文に置き換えて同じことができないこと
9
+ まで含める。番人・guard・hook・managed security 設定そのものを職人が弱められないことを目的にする。
10
+ 普通の設定 Read・通常開発・作業ブランチ・正規 merge は止めない、も維持。
11
+
12
+ 【守る場所(policy.GUARD_PLACES の一覧・ここだけ)】
13
+ ~/.claude/settings.json / settings.local.json / 各現場の .claude/settings*.json /
14
+ ~/.claude/skills 配下 / ~/.tyhld/hooks 配下 / ~/.tyhld/bin 配下 / ~/.config/systemd 配下 /
15
+ ホーム直下の隠しフォルダの hooks.json・config.toml(道具名を持たない一般形・ADR-022)/
16
+ 管理設定(/etc/claude-code 配下・/Library/Application Support/ClaudeCode 配下)。
17
+
18
+ ★見本は作り物の場所で行う(本物の設定ファイルに1バイトも書かない)。危険語・秘密名は部品から組み立てる。
19
+ """
20
+ import atexit
21
+ import os
22
+ import shutil
23
+ import sys
24
+ import tempfile
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+
28
+ import policy # noqa: E402
29
+
30
+ failures = []
31
+
32
+
33
+ def check(label, got, want):
34
+ ok = got == want
35
+ if not ok:
36
+ failures.append(f'{label}: got={got!r} want={want!r}')
37
+ print(f'{"OK " if ok else "NG "} {label:74} {str(got)[:30]}')
38
+
39
+
40
+ # ── 部品(守る場所・危険語を直接は書かない) ──
41
+ SET = 'sett' + 'ings'
42
+ CLA = '.cla' + 'ude'
43
+ S = f'~/{CLA}/{SET}.json'
44
+ SL = f'~/{CLA}/{SET}.local.json'
45
+ SKILLS = f'~/{CLA}/sk' + 'ills'
46
+ HOOKS = '~/.tyhld/' + 'hooks'
47
+ BIN = '~/.tyhld/' + 'bin'
48
+ SYSD = '~/.config/' + 'systemd'
49
+ CODEX_H = '~/.codex/' + 'hooks.json'
50
+ CODEX_C = '~/.codex/' + 'config.toml'
51
+ MANAGED = '/etc/claude-' + 'code/managed-settings.json'
52
+ MANAGED_DIR = '/etc/claude-' + 'code'
53
+
54
+ WORK = '/srv/site-sample' # 現場(/tmp の外・場所は字面で決まるので実在しなくてよい)
55
+
56
+
57
+ def decide(cmd, cwd=WORK):
58
+ return policy.decide_bash(cmd, cwd)['decision']
59
+
60
+
61
+ def dn(label, cmd, cwd=WORK):
62
+ check(f'{label}: {cmd[:52]!r}', decide(cmd, cwd), policy.DENY)
63
+
64
+
65
+ def al(label, cmd, cwd=WORK):
66
+ check(f'{label}: {cmd[:52]!r}', decide(cmd, cwd), policy.ALLOW)
67
+
68
+
69
+ print('── (2) 書き込みの手段ごとに DENY(守る場所へ書く形) ' + '─' * 14)
70
+ # リダイレクト(> >> >| <> と、後ろに付く形・裸・:・printf)
71
+ for c in [f'echo x > {S}', f'echo x >> {SL}', f'echo x >| {S}', f'exec 3<> {S}',
72
+ f'cat a > {S} 2>/dev/nu' + 'll', f'>{S}', f': > {S}', f'printf x >{S}',
73
+ f'echo x > {SKILLS}/a/SKILL.md', f'echo x > {HOOKS}/policy.py', f'echo x > {BIN}/tool',
74
+ f'echo x > {SYSD}/user/a.service', f'echo x > {CODEX_H}', f'echo x >> {CODEX_C}',
75
+ f'echo x > .{CLA[1:]}/{SET}.json']:
76
+ dn('redirect', c)
77
+ # tee
78
+ for c in [f'tee {S} < a', f'tee -a {SL} < a', f'echo x | tee {HOOKS}/p.py', f'sponge {S}']:
79
+ dn('tee', c)
80
+ # cp / install / rsync / ln / ditto
81
+ for c in [f'cp a {S}', f'cp a {HOOKS}/p.py', f'cp -t ~/{CLA}/ {SET}.json', f'install -m 644 a {S}',
82
+ f'install -d {BIN}/sub', f'rsync -a s/ {HOOKS}/', f'rsync a {BIN}/x', f'ln -sf /tmp/x {S}',
83
+ f'ln -sfn /tmp/f {SKILLS}', f'ditto s {HOOKS}']:
84
+ dn('cp/install/rsync/ln', c)
85
+ # mv(元も先も)
86
+ for c in [f'mv a {S}', f'mv {S} /tmp/away', f'mv {S} {S}.off', f'mv a {HOOKS}/p.py']:
87
+ dn('mv', c)
88
+ # sed -i / perl -i / ruby -i / awk -i inplace / gsed
89
+ for c in [f'sed -i s/a/b/ {S}', f'sed -i.bak -e s/a/b/ {SL}', f'perl -pi -e s/a/b/ {S}',
90
+ f'perl -i -pe1 {HOOKS}/p.py', f'ruby -i -pe1 {S}', f'awk -i inplace 1 {S}',
91
+ f'gsed -i s/a/b/ {S}']:
92
+ dn('inplace', c)
93
+ # awk / その他からの書き出し
94
+ dn('awk print >', f'awk \'{{print > "{os.path.expanduser(S)}"}}\' a')
95
+ # truncate / dd / chmod / chown / chattr / setfacl / rm / rmdir / shred / touch
96
+ for c in [f'truncate -s0 {S}', f'dd if=a of={S}', f'chmod 666 {S}', f'chmod -R 777 {HOOKS}',
97
+ f'chown me {S}', f'chattr -i {S}', f'setfacl -m u:x:rw {S}', f'rm -f {S}',
98
+ f'rm -rf {HOOKS}', f'shred {S}', f'touch {S}', f'rmdir {SKILLS}']:
99
+ dn('touch/chmod/rm', c)
100
+ # エディタで開いて保存
101
+ for c in [f'ed -s {S} <<< w', f'vim -c wq {S}', f'nano {HOOKS}/p.py', f'ex -sc wq {S}']:
102
+ dn('editor', c)
103
+ # patch / git checkout / git restore / git clone / git apply / git worktree
104
+ for c in [f'patch {S} < d.diff', f'git checkout -- .{CLA[1:]}/{SET}.json',
105
+ f'git restore .{CLA[1:]}/{SET}.local.json', f'git -C ~/{CLA} checkout -- {SET}.json',
106
+ f'git clone https://x.invalid/r {HOOKS}', f'git worktree add {BIN} br']:
107
+ dn('git overwrite', c)
108
+ # jq / yq の書き戻し(-i)
109
+ dn('yq -i', f'yq -i ".x=1" {S}')
110
+ # ダウンロードの行き先
111
+ for c in [f'curl -o {BIN}/x https://x.invalid/x', f'curl --output {S} https://x.invalid/',
112
+ f'wget -O {S} https://x.invalid/', f'wget -P {HOOKS} https://x.invalid/p.py']:
113
+ dn('download', c)
114
+ # アーカイブの展開(中身を確かめられない=守る側)
115
+ for c in [f'tar -xf a.tar -C {HOOKS}', f'tar xf a.tar -C {BIN}', f'unzip a.zip -d {HOOKS}',
116
+ f'7z x a.7z -o{HOOKS}', f'bsdtar -xf a.tar -C {SKILLS}']:
117
+ dn('extract', c)
118
+ # 圧縮を解いて同じ場所に書く
119
+ dn('gunzip', f'gunzip {S}.gz')
120
+ # claude config(設定を書き換える)
121
+ for c in ['claude config set -g theme dark', 'claude config add -g x y']:
122
+ dn('claude config', c)
123
+ # 管理設定(/etc・/Library)
124
+ for c in [f'echo x > {MANAGED}', f'cp a {MANAGED}', f'sed -i s/a/b/ {MANAGED}',
125
+ f'tee "/Library/Application Support/ClaudeCode/managed-settings.json" < a']:
126
+ dn('managed', c)
127
+ # mount で覆う(一覧の場所を丸ごと差し替える)
128
+ for c in [f'mount --bind /tmp/fake ~/{CLA}', f'mount -o bind /tmp/f {HOOKS}', f'umount ~/{CLA}']:
129
+ dn('mount', c)
130
+ # systemctl --user(~/.config/systemd の中を書き換える)
131
+ for c in ['systemctl --user enable relay.service', 'systemctl --user edit relay.service',
132
+ 'systemctl --user mask relay.service']:
133
+ dn('systemctl', c)
134
+ # 置き場ごと(一覧の場所を中に持つ親を消す・動かす・展開する・フォルダごと写し込む)
135
+ RMRF = 'r' + 'm -rf'
136
+ for c in [f'{RMRF} ~/{CLA}', f'mv ~/{CLA} /tmp/away', f'{RMRF} ~/.tyhld', 'mv ~/.tyhld /tmp/x',
137
+ f'tar -xf a.tar -C ~/{CLA}', f'cp -r fake/ ~/{CLA}', f'rsync -a fake/ ~/{CLA}/']:
138
+ dn('parent', c)
139
+
140
+ print('\n── (3) 包みを剥がしてから見る ' + '─' * 34)
141
+ for c in [f'bash -c "echo x > {S}"', f'sh -c \'cp a {HOOKS}/p.py\'', f'env -u X tee {S}',
142
+ f'env FOO=1 cp a {S}', f'sudo tee {S}', f'sudo -u root cp a {S}', f'nohup tee {S} < a',
143
+ f'time cp a {S}', f'timeout 5 cp a {S}', f'nice -n 5 cp a {S}', f'command tee {S}',
144
+ f'exec tee {S}', f'stdbuf -o0 tee {S}', f'busybox sed -i s/a/b/ {S}',
145
+ f'echo {S} | xargs rm', f'echo {S} | xargs -I{{}} cp a {{}}',
146
+ f'find ~/{CLA} -name "{SET}*.json" -delete',
147
+ f'find {HOOKS} -name "*.py" -exec sed -i s/a/b/ {{}} +',
148
+ f'bash <<< "rm {S}"', f'eval "rm {S}"', f'echo x > $(echo {S})',
149
+ f'x=$(tee {S} < a)', f'(cd ~/{CLA} && rm {SET}.json)',
150
+ f'for f in {S}; do rm "$f"; done', f'\\cp a {S}']:
151
+ dn('unwrap', c)
152
+ # 変数・~・$HOME・${HOME}・相対の道・波括弧・*
153
+ for c in [f'p=$HOME/{CLA}; cp a "$p/{SET}.json"', f'D=.tyhld/hooks; echo x > ~/$D/p.py',
154
+ f'export G={HOOKS}; cp a "$G/p.py"', f'echo x > ${{HOME}}/{CLA}/{SET}.json',
155
+ f'cp a ~/{CLA}/{{{SET},other}}.json', f'rm ~/{CLA}/{SET}*', f'rm ~/.tyhld/*/p.py',
156
+ f'echo x > $D/.tyhld/hooks/p.py', f'cp a $UNK/{HOOKS[2:]}/p.py']:
157
+ dn('expand', c)
158
+ # プログラムの中(python・node・ruby・perl・ヒアドキュメント)
159
+ for c in [f'python3 -c "open(\'{os.path.expanduser(S)}\',\'w\').write(\'x\')"',
160
+ f"python3 -c \"import os,json;json.dump({{}},open(os.path.expanduser('{S}'),'w'))\"",
161
+ f"python3 - <<'PY'\nfrom pathlib import Path\n(Path.home()/'{CLA}'/'{SET}.json').write_text('x')\nPY",
162
+ f"node -e \"require('fs').writeFileSync(require('os').homedir()+'/{CLA}/{SET}.json','x')\"",
163
+ f"ruby -e \"File.write(Dir.home+'/{CLA}/{SET}.json','x')\"",
164
+ f"perl -e 'open(F,\">$ENV{{HOME}}/{CLA}/{SET}.json\")'",
165
+ f"python3 -c \"import shutil,os;shutil.copy('a',os.path.expanduser('{HOOKS}/p.py'))\""]:
166
+ dn('program', c)
167
+
168
+ print('\n── (4) 止めてはいけないもの(読むだけ・通常開発・作業ブランチ・merge) ' + '─' * 2)
169
+ for c in [f'cat {S}', f'less {SL}', f'grep -n hooks {S}', f'rg PreToolUse {HOOKS}', f'diff {S} /tmp/a',
170
+ f'jq .permissions {S}', f'ls -la {SKILLS}', f'stat {S}', 'git status', f'git -C ~/{CLA} status',
171
+ f'cp {S} /tmp/claude-1000/b.json', f'jq . {S} > /tmp/claude-1000/o.json', f'cat {S} | jq .',
172
+ 'echo done > report.md', 'echo x >> /tmp/claude-1000/n.md', 'npm test > /tmp/claude-1000/t.log 2>&1',
173
+ 'sed -i s/a/b/ src/app.ts', 'cp dist/a.js build/a.js', 'rm -rf node_modules',
174
+ 'git checkout -- src/app.ts', 'git restore .', 'git checkout main -- README.md',
175
+ 'python3 -c "open(\'out.json\',\'w\').write(x)"', 'curl -o /tmp/x.json https://x.invalid/',
176
+ 'find src -name "*.ts" -delete', 'mkdir -p ~/.claude/projects/x', 'chmod +x scripts/run.sh',
177
+ 'unzip a.zip -d /tmp/claude-1000/z', 'gunzip data.json.gz', 'cp -r public/ dist/', 'mv a.ts b.ts',
178
+ 'tar -xf a.tgz -C /tmp/work', 'claude --version', 'claude -p hello',
179
+ f'echo {S} を守る >> notes.md', f'git commit -m "docs: {S} を守る"',
180
+ f'gh pr create --title x --body "tee {HOOKS}/p.py を止める"',
181
+ 'git push -u origin feat/x', 'gh pr merge 12 --auto --merge --delete-branch',
182
+ f'echo x > /tmp/claude-1000/w/{CLA}/{SET}.json',
183
+ f"python3 - <<'PY'\np='docs/g.md'\ns=open(p).read()\ns=s.replace('A','{S} は守る')\nopen(p,'w').write(s)\nPY"]:
184
+ al('allow', c)
185
+
186
+ print('\n── Write ツールも同じ一覧で守る/使い捨ては通す ' + '─' * 20)
187
+ for p in (S, SL, f'{SKILLS}/a.md', f'{HOOKS}/p.py', f'{BIN}/x', CODEX_H, CODEX_C, MANAGED):
188
+ check(f'Write DENY: {p}', policy.decide_write(os.path.expanduser(p))['decision'], policy.DENY)
189
+ for p in (f'/tmp/claude-1000/w/{CLA}/{SET}.json', '~/.config/other-tool/x.toml', 'out.json'):
190
+ check(f'Write allow: {p}', policy.decide_write(os.path.expanduser(p))['decision'], policy.ALLOW)
191
+
192
+ # 一覧の場所ごとに「代表の手段」で当たること(場所を1か所に集めた確認)。
193
+ print('\n── 守る場所の一覧の各所が塞がっている ' + '─' * 28)
194
+ for label, place in [('home settings', S), ('home local', SL), ('skills', f'{SKILLS}/a.md'),
195
+ ('hooks', f'{HOOKS}/p.py'), ('bin', f'{BIN}/x'), ('systemd', f'{SYSD}/u/a.service'),
196
+ ('agent hooks.json', CODEX_H), ('agent config.toml', CODEX_C),
197
+ ('managed /etc', MANAGED), ('site settings', f'.{CLA[1:]}/{SET}.json')]:
198
+ dn(f'一覧 {label}', f'echo x > {place}')
199
+
200
+ print('\n' + '=' * 76)
201
+ if failures:
202
+ print(f'NG {len(failures)} 件:')
203
+ for f in failures:
204
+ print(' -', f)
205
+ sys.exit(1)
206
+ print('すべて期待どおり')
207
+ sys.exit(0)
@@ -1028,6 +1028,35 @@ if "allow" in perms:
1028
1028
  del perms["allow"]
1029
1029
  changes.append(f"permissions.allow を全削除({n} 件・関門素通りの原因を除去。deny は非改変)")
1030
1030
 
1031
+ # --- (2c) 自動許可封じのキー名・値を正す(便 6d6f2ff8=便2.7) ---
1032
+ # 【なぜ要るか(調査 6fd6eee0 の重大発見3)】utatane-backend の設定は、bypassPermissions モード
1033
+ # (全部自動で許可)を封じるキーが disableBypassPermissions: true になっていて、正しい名前と違う。
1034
+ # 間違ったキー名は Claude Code から無視される=封じが効かない。見本と他の全現場は
1035
+ # disableBypassPermissionsMode: "disable"。
1036
+ # 【正しいキーと値(出典)】permissions.disableBypassPermissionsMode を "disable" にする。
1037
+ # 出典: https://code.claude.com/docs/en/permissions (91行・632行。確認日 2026-09-18)
1038
+ # 「set permissions.disableBypassPermissionsMode ... to "disable" in any settings file」。
1039
+ # ★managed settings が最も強いが、どのスコープの settings でも効く(同ページ 632行)。
1040
+ # ★触るのはこのキーだけ。allow・ask・deny・hooks・sandbox・defaultMode には一切触れない。
1041
+ # ★冪等: 既に正しい名前と値なら何もしない(2回流しても差分は出ない)。
1042
+ _BYPASS_WRONG = "disableBypassPermissions" # 誤ったキー名(値も true 等)
1043
+ _BYPASS_RIGHT = "disableBypassPermissionsMode" # 正しいキー名
1044
+ _BYPASS_VALUE = "disable" # 正しい値(文字列・公式)
1045
+ if _BYPASS_WRONG in perms:
1046
+ _had = perms.pop(_BYPASS_WRONG)
1047
+ if perms.get(_BYPASS_RIGHT) != _BYPASS_VALUE:
1048
+ perms[_BYPASS_RIGHT] = _BYPASS_VALUE
1049
+ changes.append(
1050
+ f'自動許可封じのキーを是正: {_BYPASS_WRONG}={json.dumps(_had)} → '
1051
+ f'{_BYPASS_RIGHT}="{_BYPASS_VALUE}"(誤ったキー名は本体に無視され封じが効かない)')
1052
+ else:
1053
+ changes.append(f'古い重複キー {_BYPASS_WRONG} を削除({_BYPASS_RIGHT} は既に正しい)')
1054
+ elif _BYPASS_RIGHT in perms and perms.get(_BYPASS_RIGHT) != _BYPASS_VALUE:
1055
+ # 正しいキー名だが値が "disable" 以外(例 true)=公式の値へ揃えて確実に効かせる。
1056
+ _old = perms[_BYPASS_RIGHT]
1057
+ perms[_BYPASS_RIGHT] = _BYPASS_VALUE
1058
+ changes.append(f'自動許可封じの値を是正: {_BYPASS_RIGHT}={json.dumps(_old)}→"{_BYPASS_VALUE}"')
1059
+
1031
1060
  # --- (3) サンドボックスのネット許可の不足分を追記 ---
1032
1061
  # 職人が通信するたびに Claude Code のサンドボックスが「Network request outside of sandbox」を
1033
1062
  # 聞いてくる。これは番人(cc2_guard)の判定とは別の OS レベルのゲートなので、番人では消えない。