@tyhld/conductor 0.3.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +301 -19
- package/dist/cli.js +143 -18
- package/dist/ear-routing.js +57 -0
- package/dist/ear.js +57 -0
- package/dist/env-file-perm.js +67 -0
- package/dist/nudge.js +172 -0
- package/dist/realtime-parse.js +116 -0
- package/dist/realtime.js +251 -0
- package/dist/relay-runner.js +65 -0
- package/dist/relay.js +911 -134
- package/dist/websocket-transport.js +66 -0
- package/launchd/ear-install.sh +96 -0
- package/package.json +30 -1
- package/sales-template/README.md +185 -25
- package/sales-template/install.sh +890 -0
- package/sales-template/launchd/install.sh +151 -0
- package/sales-template/settings.json +26 -97
- package/sales-template/setup.sh +754 -117
- package/sales-template/systemd/README.md +28 -4
- package/sales-template/systemd/install.sh +54 -5
- package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
- package/sales-template/uninstall.sh +245 -0
- package/scripts/hooks/README.md +246 -0
- package/scripts/hooks/cc2_guard.py +145 -0
- package/scripts/hooks/codex-hooks.sample.json +58 -0
- package/scripts/hooks/hook_datalink.py +440 -0
- package/scripts/hooks/install-codex-hooks.sh +127 -0
- package/scripts/hooks/notification_hook.py +167 -0
- package/scripts/hooks/permission_request_hook.py +207 -0
- package/scripts/hooks/policy.py +759 -0
- package/scripts/hooks/settings.sample.json +142 -0
- package/scripts/hooks/stop_hook.py +275 -0
- package/scripts/hooks/summary_ja.py +155 -0
- package/scripts/hooks/test_hook_datalink.py +282 -0
- package/scripts/hooks/test_policy.py +1241 -0
- package/skills/conductor-craftsman/SKILL.md +40 -0
- package/systemd/conductor-ear.service +63 -0
- package/systemd/conductor@.service +62 -0
- package/systemd/ear-install.sh +131 -0
- package/systemd/guard-sync-install.sh +94 -0
- package/systemd/tyhld-guard-sync.service +28 -0
- package/systemd/tyhld-guard-sync.timer +25 -0
- package/sales-template/cc2_guard.py +0 -395
- package/sales-template/systemd/conductor@.service +0 -47
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""policy.py — 番人の判定そのもの(純粋関数だけ・段2の作り直し)。
|
|
3
|
+
|
|
4
|
+
【なぜ作り直したか(棚卸し 26d6fe68 の根治)】
|
|
5
|
+
旧番人は「安全な動詞を列挙し、載っていないものは全部確認」だった。運用方針は逆で
|
|
6
|
+
「人間の関門は3つだけ、他は自走してよい」=【止める側が有限集合】。向きが逆だったため、
|
|
7
|
+
新しいコマンド(docker / make / go / cargo / gh workflow / git worktree / journalctl …)が
|
|
8
|
+
出るたびに自動的に【間違った側=確認】へ落ち、人が許可リストへ1行ずつ足して埋めていた
|
|
9
|
+
(実測 200行超)。その許可リストが逆に番人の境界を迂回する、という悪循環になっていた。
|
|
10
|
+
|
|
11
|
+
【この版の判定(上から順に1つだけ当たる)】
|
|
12
|
+
1. 危険 → deny … 本番破壊・force push・データ消失・認証情報の露出/持ち出し
|
|
13
|
+
2. 3関門 → gate … main直変更 / 本番確認 / DB実行(人が必ず判断する)
|
|
14
|
+
3. それ以外 → allow … 自走してよい。★新しいコマンドは必ずここへ落ちる
|
|
15
|
+
|
|
16
|
+
★PRのマージ(`gh pr merge`)は関門ではない(決定・案B完全自動化)。詳しくは _gate_merge の説明。
|
|
17
|
+
|
|
18
|
+
【危険は「動詞」ではなく「対象と作用」で見る】
|
|
19
|
+
旧番人は動詞を白に入れた瞬間に対象を見なくなり、`curl -d @.env`・`cp .env /tmp`・
|
|
20
|
+
`sed -i` で任意パス書換、が全部素通りしていた(実測)。ここでは
|
|
21
|
+
・秘密(.env / 鍵 / 認証情報 / 秘密ディレクトリ)に触れるなら、動詞が何であれ deny
|
|
22
|
+
・システム領域へ「書く」作用があるなら deny
|
|
23
|
+
とし、Bash でもファイル編集ツールでも同じ語彙で判定する=経路による強度差を無くす。
|
|
24
|
+
|
|
25
|
+
★このモジュールは標準ライブラリだけで動き、製品固有の名前を持たない(切り出し可能)。
|
|
26
|
+
"""
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shlex
|
|
30
|
+
|
|
31
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
# 判定の返り値
|
|
33
|
+
# 'deny' … 実行させない
|
|
34
|
+
# 'gate' … 人間の関門(呼び出し側が人へ渡す)
|
|
35
|
+
# 'allow' … 自走してよい
|
|
36
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
DENY, GATE, ALLOW = 'deny', 'gate', 'allow'
|
|
38
|
+
|
|
39
|
+
# 関門の種類(表示・集計用)。
|
|
40
|
+
# ★GATE_MERGE の中身は「PRのマージ」から「main への直接 push」へ縮んだ(_gate_merge 参照)。
|
|
41
|
+
# 種別の字面 'merge' は管制の表示・集計が使っているので変えない。
|
|
42
|
+
GATE_MERGE = 'merge' # 関門1 本番ブランチの直接変更
|
|
43
|
+
GATE_RELEASE = 'release' # 関門2 本番確認
|
|
44
|
+
GATE_DB = 'db' # 関門3 DB実行
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _r(decision, reason, gate=None):
|
|
48
|
+
return {'decision': decision, 'reason': reason, 'gate': gate}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
52
|
+
# 秘密・システム領域の語彙(decide_write と Bash で共用する=経路差を無くす本体)
|
|
53
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
|
|
55
|
+
# 常に触らせないファイル名(読み取りも含む=「露出」を止める)。
|
|
56
|
+
_SECRET_FILE_RE = re.compile(
|
|
57
|
+
r'^(id_(rsa|dsa|ecdsa|ed25519).*|authorized_keys|known_hosts|.*\.pem|.*\.key|'
|
|
58
|
+
r'\.netrc|\.npmrc|\.pypirc|credentials)$'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# 雛形(テンプレート)の接尾辞。この2つで終わる名前は秘密ファイル判定から外す。
|
|
62
|
+
# 例: .env.example / .env.local.example / config.sample。値の入っていない見本のため。
|
|
63
|
+
_TEMPLATE_SUFFIX_RE = re.compile(r'\.(example|sample)$', re.IGNORECASE)
|
|
64
|
+
|
|
65
|
+
# 常に触らせないディレクトリ名(階層のどこかに出てきたら該当)。
|
|
66
|
+
_SECRET_DIRS = frozenset({'.ssh', '.gnupg', '.aws', '.gcloud'})
|
|
67
|
+
|
|
68
|
+
# 書き込みを常に禁じるシステム領域(絶対パスの先頭成分)。
|
|
69
|
+
_SYSTEM_TOPS = frozenset({
|
|
70
|
+
'etc', 'usr', 'bin', 'sbin', 'boot', 'lib', 'lib64', 'dev', 'proc', 'sys', 'root', 'var',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
# 秘密を含む環境変数の名前(この字面が出てきたら露出とみなす)。
|
|
74
|
+
_SECRET_ENV_RE = re.compile(
|
|
75
|
+
r'\$\{?(DATABASE_URL|DIRECT_URL|[A-Z0-9_]*(SECRET|TOKEN|PASSWORD|APIKEY|API_KEY|PRIVATE_KEY))\}?'
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_secret_name(name: str) -> bool:
|
|
80
|
+
"""ファイル名が「秘密そのもの」か(.env / .env.* / 鍵 / 認証情報)。
|
|
81
|
+
|
|
82
|
+
★雛形(.example / .sample)は除外する。`.env.example` は「どの項目を書くか」を示す
|
|
83
|
+
見本で、値は空=秘密を含まない。それでも `.env.` で始まるため秘密扱いになり、
|
|
84
|
+
職人が雛形の記述を直すだけの作業まで止まっていた(実地で2回発生)。
|
|
85
|
+
除外はこの2つの接尾辞だけ。`.env` 本体・`.env.local`・`.env.production` などの
|
|
86
|
+
保護は一切緩めない。
|
|
87
|
+
"""
|
|
88
|
+
if _TEMPLATE_SUFFIX_RE.search(name):
|
|
89
|
+
return False
|
|
90
|
+
if name == '.env' or name.startswith('.env.'):
|
|
91
|
+
return True
|
|
92
|
+
return bool(_SECRET_FILE_RE.match(name))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def secret_reason(path: str):
|
|
96
|
+
"""パスが秘密に触れるなら理由、そうでなければ None。読み書きを問わない。"""
|
|
97
|
+
comps = [c for c in path.replace('\\', '/').split('/') if c]
|
|
98
|
+
if not comps:
|
|
99
|
+
return None
|
|
100
|
+
name = comps[-1]
|
|
101
|
+
if is_secret_name(name):
|
|
102
|
+
return f'secret file ({name})'
|
|
103
|
+
for c in comps[:-1]:
|
|
104
|
+
if c in _SECRET_DIRS:
|
|
105
|
+
return f'secret directory ({c})'
|
|
106
|
+
if name in _SECRET_DIRS:
|
|
107
|
+
return f'secret directory ({name})'
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# システム領域だが書いても無害な特殊ファイル(捨て先・標準出力など)。
|
|
112
|
+
# ★ここを除外しないと `... > /dev/null` という日常操作が止まる(実際に止まった)。
|
|
113
|
+
_HARMLESS_DEVICES = frozenset({
|
|
114
|
+
'/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty', '/dev/zero', '/dev/urandom',
|
|
115
|
+
'/dev/random', '/dev/fd',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
# 使い捨ての作業場(OSの一時領域)の根。★ここ【だけ】が .claude 保護の例外になる。
|
|
119
|
+
# 実機 2026-09-02 12:07: 職人が検証のために作った
|
|
120
|
+
# /tmp/claude-.../scratchpad/b/home/projects/s/.claude
|
|
121
|
+
# が「.claude という名前」だけで本物の設定と同じ扱いになり、ターミナルが止まった。
|
|
122
|
+
# 名前だけで決めず、場所も見る(ADR-014)。
|
|
123
|
+
_THROWAWAY_TOPS = ('/tmp', '/var/tmp', '/dev/shm', '/private/tmp', '/private/var/tmp')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _throwaway_roots():
|
|
127
|
+
"""使い捨ての作業場の根の一覧。$TMPDIR が別の場所を指す機体にも合わせる。"""
|
|
128
|
+
roots = list(_THROWAWAY_TOPS)
|
|
129
|
+
tmpdir = os.environ.get('TMPDIR') or ''
|
|
130
|
+
tmpdir = tmpdir.rstrip('/')
|
|
131
|
+
if tmpdir.startswith('/'):
|
|
132
|
+
roots.append(os.path.normpath(tmpdir))
|
|
133
|
+
return roots
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _under(path: str, root: str) -> bool:
|
|
137
|
+
"""path が root の中にあるか(root 自身は含めない・成分単位で比べる)。"""
|
|
138
|
+
return path.startswith(root.rstrip('/') + '/')
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _named_guard_settings(path: str) -> bool:
|
|
142
|
+
"""字面が「.claude/settings…」の形か(名前だけの判定・単体では使わない)。"""
|
|
143
|
+
comps = [c for c in path.replace('\\', '/').split('/') if c]
|
|
144
|
+
return len(comps) >= 2 and comps[-2] == '.claude' and comps[-1].startswith('settings')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_guard_settings_path(path: str) -> bool:
|
|
148
|
+
"""番人自身の設定ファイルを指すか。字面と、近道(symlink)を解いた実体の両方を見る。
|
|
149
|
+
|
|
150
|
+
★別名の近道(例 /tmp/shortcut → ~/.claude)から本物へ書くのを塞ぐため、
|
|
151
|
+
実体側の形も見る。★ここは「該当するか」だけを答え、守るか通すかは
|
|
152
|
+
is_throwaway_path(場所の条件)と組で決める。
|
|
153
|
+
"""
|
|
154
|
+
if _named_guard_settings(path):
|
|
155
|
+
return True
|
|
156
|
+
try:
|
|
157
|
+
return _named_guard_settings(os.path.realpath(path))
|
|
158
|
+
except Exception: # noqa: BLE001
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_throwaway_path(path: str) -> bool:
|
|
163
|
+
"""絶対パスが「使い捨ての作業場の下」か。
|
|
164
|
+
|
|
165
|
+
★安全の要(ここを間違えると番人が効かなくなる):
|
|
166
|
+
・既定は False(=守る側)。使い捨てだと【確実に言える】ときだけ True。
|
|
167
|
+
・字面のパスと、シンボリックリンクを解いた実体の【両方】が一時領域の中にある
|
|
168
|
+
ことを求める。`/tmp/x -> ~/.claude` のような近道で本物へ書くのを塞ぐ。
|
|
169
|
+
・ホームの ~/.claude 配下は、たとえ HOME が一時領域にあっても常に守る。
|
|
170
|
+
"""
|
|
171
|
+
if not path.startswith('/'):
|
|
172
|
+
return False
|
|
173
|
+
home_claude = os.path.join(os.path.expanduser('~'), '.claude')
|
|
174
|
+
if path == home_claude or _under(path, home_claude):
|
|
175
|
+
return False
|
|
176
|
+
roots = _throwaway_roots()
|
|
177
|
+
if not any(_under(path, r) for r in roots):
|
|
178
|
+
return False
|
|
179
|
+
try:
|
|
180
|
+
real = os.path.realpath(path)
|
|
181
|
+
except Exception: # noqa: BLE001
|
|
182
|
+
return False
|
|
183
|
+
return any(_under(real, r) for r in roots)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def system_write_reason(path: str):
|
|
187
|
+
"""絶対パスがシステム領域なら理由、そうでなければ None(書き込み作用のときだけ使う)。"""
|
|
188
|
+
if not path.startswith('/'):
|
|
189
|
+
return None
|
|
190
|
+
if path in _HARMLESS_DEVICES or path.startswith('/dev/fd/'):
|
|
191
|
+
return None
|
|
192
|
+
comps = [c for c in path.split('/') if c]
|
|
193
|
+
if comps and comps[0] in _SYSTEM_TOPS:
|
|
194
|
+
return f'system path (/{comps[0]})'
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
199
|
+
# Bash コマンドの分解
|
|
200
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
201
|
+
|
|
202
|
+
# 制御演算子(複合コマンドの分割)。
|
|
203
|
+
_SPLIT_RE = re.compile(r'&&|\|\||[;|&\n]')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def split_segments(command: str):
|
|
207
|
+
"""複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。"""
|
|
208
|
+
return [s.strip() for s in _SPLIT_RE.split(command) if s.strip()]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def tokens_of(segment: str):
|
|
212
|
+
"""1セグメントを語に割る。クォートが壊れていても落ちない(安全側で素朴分割へ)。"""
|
|
213
|
+
try:
|
|
214
|
+
return shlex.split(segment, comments=False, posix=True)
|
|
215
|
+
except ValueError:
|
|
216
|
+
return segment.split()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# 先頭に付く環境設定・ラッパ(実体の動詞まで読み進める)。
|
|
220
|
+
_WRAPPERS = frozenset({
|
|
221
|
+
'env', 'nohup', 'time', 'stdbuf', 'setsid', 'timeout', 'nice', 'ionice',
|
|
222
|
+
'command', 'builtin', 'sudo', 'xargs',
|
|
223
|
+
})
|
|
224
|
+
_ASSIGN_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def verb_of(tokens):
|
|
228
|
+
"""セグメントの実行動詞(ラッパ・変数代入を飛ばした先頭語)。無ければ ''。"""
|
|
229
|
+
i = 0
|
|
230
|
+
while i < len(tokens):
|
|
231
|
+
t = tokens[i]
|
|
232
|
+
if _ASSIGN_RE.match(t):
|
|
233
|
+
i += 1
|
|
234
|
+
continue
|
|
235
|
+
base = t.split('/')[-1]
|
|
236
|
+
if base in _WRAPPERS:
|
|
237
|
+
i += 1
|
|
238
|
+
# ラッパのオプションを飛ばす(timeout 600 のような値も1つ飛ばす)。
|
|
239
|
+
while i < len(tokens) and tokens[i].startswith('-'):
|
|
240
|
+
i += 1
|
|
241
|
+
if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
|
|
242
|
+
and not tokens[i].startswith('-'):
|
|
243
|
+
i += 1
|
|
244
|
+
continue
|
|
245
|
+
return base
|
|
246
|
+
return ''
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# 「書き込む」作用を持つ動詞(対象パスに書く/消す/複製する)。
|
|
250
|
+
WRITE_VERBS = frozenset({
|
|
251
|
+
'tee', 'dd', 'install', 'truncate', 'chmod', 'chown', 'chgrp', 'ln',
|
|
252
|
+
'cp', 'mv', 'rm', 'touch', 'mkdir', 'rmdir', 'shred', 'patch',
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
# 「外へ送る/持ち出す」作用を持つ動詞。
|
|
256
|
+
EXFIL_VERBS = frozenset({
|
|
257
|
+
'curl', 'wget', 'scp', 'rsync', 'sftp', 'ftp', 'nc', 'ncat', 'netcat',
|
|
258
|
+
'ssh', 'mail', 'sendmail', 'tar', 'zip', 'gzip', 'base64', 'openssl', 'gpg',
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
# 「中身を読み出す」動詞(秘密に対して使われたら露出)。
|
|
262
|
+
READ_VERBS = frozenset({
|
|
263
|
+
'cat', 'head', 'tail', 'less', 'more', 'strings', 'xxd', 'od', 'grep', 'awk', 'sed',
|
|
264
|
+
'cp', 'mv', 'tar', 'zip', 'base64', 'curl', 'wget', 'scp', 'rsync',
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# リダイレクト先の字面から剥がす記号。
|
|
269
|
+
# ★シェルの飾りであって【パスの一部ではない】ものだけを並べる。
|
|
270
|
+
# ( ) … コマンド置換 `$( … )` の閉じ括弧・プロセス置換
|
|
271
|
+
# ` ' " … クォート・バッククォート
|
|
272
|
+
# これを剥がさないと、コマンド置換の中の `2>/dev/null` が `/dev/null)` として
|
|
273
|
+
# 取り出され、捨て先の例外(_HARMLESS_DEVICES)に当たらず「/dev への書き込み」で
|
|
274
|
+
# 止まっていた(実機 2026-09-03 再現。`true 2>/dev/null` は通るのに
|
|
275
|
+
# `p=$(command -v node 2>/dev/null)` だけ止まる、という取りこぼし)。
|
|
276
|
+
_REDIRECT_TRIM = '()`\'"'
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def normalize_redirect_target(raw: str) -> str:
|
|
280
|
+
"""リダイレクト先の字面から、シェルの飾りだけを剥がして素のパスにする。
|
|
281
|
+
|
|
282
|
+
★判定を1か所へ寄せるための関門。ここを通した文字列だけを secret_reason /
|
|
283
|
+
system_write_reason に渡すので、★コマンド置換の中でも外でも同じ判定になる。
|
|
284
|
+
★剥がすのは飾りだけ。パスそのものは1文字も変えない(緩める方向の変更はしない)。
|
|
285
|
+
むしろ `>"/etc/hosts"` のようにクォート付きで書かれた書き込みも、これで素の
|
|
286
|
+
`/etc/hosts` として判定に載る=今までより厳しくなる。
|
|
287
|
+
"""
|
|
288
|
+
return raw.strip().strip(_REDIRECT_TRIM)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def redirect_targets(segment: str):
|
|
292
|
+
"""`> path` / `>> path` の書き込み先を拾う(飾りを剥がした素のパスで返す)。
|
|
293
|
+
|
|
294
|
+
★`2>&1` のようなファイル記述子の複製は拾わない(& を除いてあるので該当しない)。
|
|
295
|
+
★飾りを剥がした結果が空になったものは落とす(`>(cmd)` のプロセス置換など、
|
|
296
|
+
パスを指していない形)。
|
|
297
|
+
"""
|
|
298
|
+
raw = re.findall(r'>>?\s*([^\s;|&]+)', segment)
|
|
299
|
+
return [t for t in (normalize_redirect_target(r) for r in raw) if t]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
303
|
+
# ラッパの中身(bash -c "…" / python3 -c "…" / ヒアドキュメント)
|
|
304
|
+
# ★包むだけで秘密が読めていた穴を塞ぐ。`cat .env` は止まるのに
|
|
305
|
+
# `bash -c "cat .env"` は素通りしていた(調査 d7a1c150 で実測)。
|
|
306
|
+
# 中身を取り出して【同じ物差し】を当て直す。検知を足すだけで、緩める変更は無い。
|
|
307
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
308
|
+
|
|
309
|
+
# 「-c の中身がシェルコマンド」の道具。中身はもう一度コマンドとして測る。
|
|
310
|
+
_SHELL_RUNNERS = frozenset({'bash', 'sh', 'zsh', 'dash', 'ksh'})
|
|
311
|
+
|
|
312
|
+
# 「-c / -e の中身がプログラム」の道具。中身は文字列として秘密の名前を探す。
|
|
313
|
+
_CODE_RUNNERS = frozenset({
|
|
314
|
+
'python', 'python2', 'python3', 'node', 'nodejs', 'perl', 'ruby', 'php', 'deno', 'bun',
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
# 中身を渡すための旗。
|
|
318
|
+
_CODE_FLAGS = frozenset({'-c', '-e', '--eval', '--command'})
|
|
319
|
+
|
|
320
|
+
# ヒアドキュメント(<<EOF … EOF / <<'PY' … PY)の本体を取り出す。
|
|
321
|
+
_HEREDOC_RE = re.compile(
|
|
322
|
+
r'<<-?\s*[\'"]?([A-Za-z_][A-Za-z0-9_]*)[\'"]?[^\n]*\n(.*?)\n\s*\1\s*(?:\n|$)',
|
|
323
|
+
re.DOTALL,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# プログラムの中の文字列リテラル(open('.env') の '.env' を拾う)。
|
|
327
|
+
_QUOTED_RE = re.compile(r'''['"]([^'"\n]{1,300})['"]''')
|
|
328
|
+
|
|
329
|
+
# 入れ子の上限。`bash -c "bash -c ..."` の2段まで見れば実用上足りる(無限再帰の防止)。
|
|
330
|
+
_UNWRAP_MAX_DEPTH = 2
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _code_secret_reason(text: str):
|
|
334
|
+
"""プログラム文字列の中に秘密ファイルの名前が出てくるなら理由を返す。
|
|
335
|
+
|
|
336
|
+
★完全な構文解析はしない。`open('.env')` のように名前が文字列として現れることを
|
|
337
|
+
見れば足りる。雛形(.example / .sample)は secret_reason 側の除外がそのまま効く。
|
|
338
|
+
"""
|
|
339
|
+
for m in _QUOTED_RE.finditer(text or ''):
|
|
340
|
+
cand = m.group(1).strip()
|
|
341
|
+
if not cand:
|
|
342
|
+
continue
|
|
343
|
+
sr = secret_reason(cand)
|
|
344
|
+
if sr:
|
|
345
|
+
return sr
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _inner_payloads(command: str):
|
|
350
|
+
"""ラッパが抱えている「中身」を (種別, 呼び出した道具, 中身) の一覧で返す。
|
|
351
|
+
|
|
352
|
+
種別は 'shell'(もう一度コマンドとして測る)か 'code'(文字列として名前を探す)。
|
|
353
|
+
"""
|
|
354
|
+
out = []
|
|
355
|
+
|
|
356
|
+
# ① `<runner> -c "…"` / `<runner> -e "…"` の形。
|
|
357
|
+
# ★全文と各セグメントの両方を見る。中身に ; や | があるとセグメント分割で
|
|
358
|
+
# 千切れるため、全文からの取り出しが要る。
|
|
359
|
+
for text in [command] + split_segments(command):
|
|
360
|
+
toks = tokens_of(text)
|
|
361
|
+
for i, t in enumerate(toks):
|
|
362
|
+
if t not in _CODE_FLAGS or i + 1 >= len(toks):
|
|
363
|
+
continue
|
|
364
|
+
runner = verb_of(toks[:i]) if i else ''
|
|
365
|
+
if runner in _SHELL_RUNNERS:
|
|
366
|
+
out.append(('shell', runner, toks[i + 1]))
|
|
367
|
+
elif runner in _CODE_RUNNERS:
|
|
368
|
+
out.append(('code', runner, toks[i + 1]))
|
|
369
|
+
|
|
370
|
+
# ② ヒアドキュメント(`python3 - <<EOF … EOF` / `bash <<'SH' … SH`)。
|
|
371
|
+
for m in _HEREDOC_RE.finditer(command or ''):
|
|
372
|
+
head = command[:m.start()].rsplit('\n', 1)[-1]
|
|
373
|
+
runner = verb_of(tokens_of(head))
|
|
374
|
+
kind = 'shell' if runner in _SHELL_RUNNERS else 'code'
|
|
375
|
+
out.append((kind, runner or 'heredoc', m.group(2)))
|
|
376
|
+
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _wrapper_danger(command: str, depth: int = 0):
|
|
381
|
+
"""ラッパの中身に同じ判定を当てる。危険なら理由を返す。"""
|
|
382
|
+
if depth >= _UNWRAP_MAX_DEPTH:
|
|
383
|
+
return None
|
|
384
|
+
for kind, runner, inner in _inner_payloads(command):
|
|
385
|
+
if not inner or not inner.strip():
|
|
386
|
+
continue
|
|
387
|
+
where = f'inside {runner}' if runner else 'inside wrapper'
|
|
388
|
+
if kind == 'code':
|
|
389
|
+
sr = _code_secret_reason(inner)
|
|
390
|
+
if sr:
|
|
391
|
+
return f'{sr} referenced {where}'
|
|
392
|
+
continue
|
|
393
|
+
# shell: 中身をコマンドとして測り直す(秘密・システム領域・危険な rm)。
|
|
394
|
+
hit = _rm_danger(inner)
|
|
395
|
+
if hit:
|
|
396
|
+
return f'{hit} ({where})'
|
|
397
|
+
for seg in split_segments(inner):
|
|
398
|
+
toks = tokens_of(seg)
|
|
399
|
+
hit = _secret_danger(seg, verb_of(toks), toks)
|
|
400
|
+
if hit:
|
|
401
|
+
return f'{hit} ({where})'
|
|
402
|
+
hit = _wrapper_danger(inner, depth + 1)
|
|
403
|
+
if hit:
|
|
404
|
+
return hit
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def is_inplace_edit(verb: str, tokens) -> bool:
|
|
409
|
+
"""sed -i / perl -i のような「その場書き換え」か。"""
|
|
410
|
+
if verb in ('sed', 'perl', 'ruby'):
|
|
411
|
+
return any(t == '-i' or t.startswith('-i') or t.startswith('--in-place') for t in tokens[1:])
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
416
|
+
# 1) 危険(deny)
|
|
417
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
418
|
+
|
|
419
|
+
def _danger_patterns(low: str):
|
|
420
|
+
"""全文(小文字)に対する無条件の危険。変数へ直書きされたものも文字列として拾う。"""
|
|
421
|
+
if re.search(r'git\s+push\b[^;&|\n]*?(--force(-with-lease)?\b|(?:^|\s)-f(?:\s|$)|\s\+\S)', low):
|
|
422
|
+
return 'force push'
|
|
423
|
+
if re.search(r'git\s+reset\b[^;&|\n]*--hard\b', low):
|
|
424
|
+
return 'git reset --hard'
|
|
425
|
+
for m in re.finditer(r'git\s+clean\b([^;&|\n]*)', low):
|
|
426
|
+
fl = ''.join(re.findall(r'-([a-z]+)', m.group(1)))
|
|
427
|
+
if 'f' in fl and 'd' in fl:
|
|
428
|
+
return 'git clean -fd'
|
|
429
|
+
if re.search(r'\bdrop\s+table\b', low):
|
|
430
|
+
return 'drop table'
|
|
431
|
+
if re.search(r'\bdrop\s+database\b', low):
|
|
432
|
+
return 'drop database'
|
|
433
|
+
if re.search(r'\bsupabase\s+db\s+reset\b', low):
|
|
434
|
+
return 'supabase db reset'
|
|
435
|
+
if re.search(r'\bgh\s+secret\b', low):
|
|
436
|
+
return 'gh secret'
|
|
437
|
+
if re.search(r'\bsudo\s+rm\b', low):
|
|
438
|
+
return 'sudo rm'
|
|
439
|
+
if re.search(r'\bprintenv\b', low):
|
|
440
|
+
return 'printenv (環境変数の一括露出)'
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# 危険でない作業フォルダの絶対パス先頭成分(/tmp/xxx は限定削除として通す)。
|
|
445
|
+
_SAFE_ABS_TOP = frozenset({'tmp'})
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def rm_path_risk(path: str) -> str:
|
|
449
|
+
"""rm の対象パスの危険度: 'dangerous' / 'safe' / 'unknown'。"""
|
|
450
|
+
p = path.strip().strip('"').strip("'")
|
|
451
|
+
if p == '':
|
|
452
|
+
return 'unknown'
|
|
453
|
+
if p in ('$HOME', '${HOME}', '$HOME/', '${HOME}/'):
|
|
454
|
+
return 'dangerous'
|
|
455
|
+
if p.startswith('$HOME/') or p.startswith('${HOME}/'):
|
|
456
|
+
rest = p.split('/', 1)[1] if '/' in p else ''
|
|
457
|
+
return 'safe' if rest.strip() else 'dangerous'
|
|
458
|
+
if any(c in p for c in ('$', '`', '*', '?', '{', '[')):
|
|
459
|
+
return 'unknown'
|
|
460
|
+
if p in ('/', '~', '~/'):
|
|
461
|
+
return 'dangerous'
|
|
462
|
+
if p.startswith('~'):
|
|
463
|
+
return 'dangerous' if p[1:].lstrip('/') == '' else 'safe'
|
|
464
|
+
if p.startswith('/'):
|
|
465
|
+
comps = [c for c in p.split('/') if c]
|
|
466
|
+
if not comps:
|
|
467
|
+
return 'dangerous'
|
|
468
|
+
if comps[0] in _SAFE_ABS_TOP and len(comps) >= 2:
|
|
469
|
+
return 'safe'
|
|
470
|
+
return 'dangerous'
|
|
471
|
+
return 'safe'
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _rm_danger(command: str):
|
|
475
|
+
"""再帰的 rm の対象パスを全部見て、危険なものがあれば理由を返す。"""
|
|
476
|
+
for m in re.finditer(r'\brm\b', command):
|
|
477
|
+
seg = _SPLIT_RE.split(command[m.end():], maxsplit=1)[0]
|
|
478
|
+
toks = seg.split()
|
|
479
|
+
flags = [t for t in toks if t.startswith('-')]
|
|
480
|
+
recursive = any(('r' in f.lstrip('-').lower()) or f in ('--recursive',) for f in flags)
|
|
481
|
+
paths = [t for t in toks if not t.startswith('-')]
|
|
482
|
+
for p in paths:
|
|
483
|
+
risk = rm_path_risk(p)
|
|
484
|
+
if risk == 'dangerous':
|
|
485
|
+
return f'rm on dangerous path ({p})'
|
|
486
|
+
if risk == 'unknown' and recursive:
|
|
487
|
+
return f'recursive rm with variable/glob path ({p})'
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _secret_danger(segment: str, verb: str, tokens):
|
|
492
|
+
"""このセグメントが秘密に触れる/システム領域へ書くなら理由を返す。
|
|
493
|
+
|
|
494
|
+
★ここが「対象と作用」判定の本体。動詞が何であれ、対象が秘密なら止める。
|
|
495
|
+
"""
|
|
496
|
+
# 秘密を含む環境変数の露出(echo $DATABASE_URL、curl -d "$TOKEN" など)。
|
|
497
|
+
if _SECRET_ENV_RE.search(segment):
|
|
498
|
+
return 'secret environment variable exposure'
|
|
499
|
+
|
|
500
|
+
args = tokens[1:] if tokens else []
|
|
501
|
+
for raw in args:
|
|
502
|
+
# 値の書き方は色々ある: `.env` / `@.env`(curl -d @file)/ `--post-file=.env`。
|
|
503
|
+
# オプション名に埋まった値まで見ないと、持ち出しを1つ取りこぼす。
|
|
504
|
+
candidates = [raw.lstrip('@')]
|
|
505
|
+
if raw.startswith('-') and '=' in raw:
|
|
506
|
+
candidates.append(raw.split('=', 1)[1].lstrip('@'))
|
|
507
|
+
a = next((c for c in candidates if c and not c.startswith('-')), '')
|
|
508
|
+
if not a:
|
|
509
|
+
continue
|
|
510
|
+
sr = secret_reason(a)
|
|
511
|
+
if sr:
|
|
512
|
+
# 秘密に触れる動詞なら止める(読み・書き・持ち出しのいずれでも露出になる)。
|
|
513
|
+
#
|
|
514
|
+
# ★動詞を限定するのが要点。「引数に .env という字面があれば止める」にすると、
|
|
515
|
+
# コミットメッセージや説明文に .env と書いただけで止まる(実際に止まった)。
|
|
516
|
+
# その代わり、未知の道具に秘密を渡す形(`mytool .env`)は素通りする=
|
|
517
|
+
# 限界として README に明記してある。
|
|
518
|
+
if verb in READ_VERBS or verb in WRITE_VERBS or verb in EXFIL_VERBS:
|
|
519
|
+
return f'{sr} touched by {verb}'
|
|
520
|
+
if verb in WRITE_VERBS or is_inplace_edit(verb, tokens):
|
|
521
|
+
syr = system_write_reason(a)
|
|
522
|
+
if syr:
|
|
523
|
+
return f'write to {syr}'
|
|
524
|
+
|
|
525
|
+
# リダイレクト先(> / >>)も「書き込む作用」。
|
|
526
|
+
for t in redirect_targets(segment):
|
|
527
|
+
sr = secret_reason(t)
|
|
528
|
+
if sr:
|
|
529
|
+
return f'redirect into {sr}'
|
|
530
|
+
syr = system_write_reason(t)
|
|
531
|
+
if syr:
|
|
532
|
+
return f'redirect into {syr}'
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
537
|
+
# 2) 3関門(gate)
|
|
538
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
539
|
+
|
|
540
|
+
# 本番として扱うブランチ名(ここへ直接 push するのはマージと同じ重み)。
|
|
541
|
+
PROTECTED_BRANCHES = frozenset({'main', 'master', 'production', 'prod', 'release'})
|
|
542
|
+
|
|
543
|
+
# DDL / 権限 / RLS を含む SQL の字面(DB実行の関門)。
|
|
544
|
+
_SQL_GATE_RE = re.compile(
|
|
545
|
+
r'\b(create|alter|drop)\s+(table|index|policy|database|schema|role|function|trigger|view)\b'
|
|
546
|
+
r'|\bgrant\b|\brevoke\b|\brow\s+level\s+security\b|\btruncate\b',
|
|
547
|
+
re.IGNORECASE,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# 「引数にSQLらしい字面があっても、実際にはSQLを実行しない」動詞。
|
|
551
|
+
# ★SQL文字列だけを根拠にした関門は、これらの動詞では出さない(=誤検知を消す)。
|
|
552
|
+
# 棚卸し前は `grep "alter table" f` / `git commit -m "grant …"` / `echo "create table"` /
|
|
553
|
+
# `truncate -s 0 build.log`(シェルの truncate はファイル操作でSQLではない)まで
|
|
554
|
+
# 関門に落ちて、えふさんを何度も呼び止めていた(報告 09c70615・調査 29fb9909)。
|
|
555
|
+
# ★安全側は崩さない: 本物のDB実行(psql / mysql / supabase / prisma / drizzle-kit 等)は
|
|
556
|
+
# この集合に入れない=従来どおり別ルートで確実に関門へ落ちる。破壊的な drop table /
|
|
557
|
+
# drop database は、そもそも gate ではなく deny(_danger_patterns)で止まる。
|
|
558
|
+
_SQL_TEXT_ONLY_VERBS = frozenset({
|
|
559
|
+
'echo', 'printf', 'cat', 'head', 'tail', 'less', 'more', 'strings',
|
|
560
|
+
'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'cut', 'tr',
|
|
561
|
+
'sort', 'uniq', 'wc', 'comm', 'diff', 'tee', 'jq', 'yq', 'xargs',
|
|
562
|
+
'git', 'gh', 'ls', 'find', 'readlink', 'realpath', 'basename', 'dirname',
|
|
563
|
+
'test', 'true', 'false', 'printenv', 'export', 'cd', 'pwd',
|
|
564
|
+
'truncate', # シェルの truncate は「ファイルを縮める」道具。SQLの TRUNCATE ではない。
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _gate_merge(low: str, verb: str, tokens):
|
|
569
|
+
"""関門1: 本番ブランチ(main 等)を【PRを通さずに】直接書き換える操作。
|
|
570
|
+
|
|
571
|
+
★PRのマージ(`gh pr merge` / `gh api …/pulls/N/merge`)は関門から外した(案B完全自動化)。
|
|
572
|
+
理由: マージを関門にすると、職人は最後の1手で必ず人を待つことになり、
|
|
573
|
+
「指示を出したら終わりまで進む」が成立しなかった。マージはPRという記録が残り、
|
|
574
|
+
レビューとCIの結果が付いた上での操作なので、人が毎回押す価値より止まる損の方が大きい。
|
|
575
|
+
★外したのはマージだけ。main への【直接 push】は関門のまま残す。こちらはPRもレビューも
|
|
576
|
+
CIも通らずに本番の元が変わるため、性質がまったく違う(外すと歯止めが無くなる)。
|
|
577
|
+
"""
|
|
578
|
+
if verb == 'git' and len(tokens) >= 2 and tokens[1] == 'push':
|
|
579
|
+
for t in tokens[2:]:
|
|
580
|
+
if t.startswith('-'):
|
|
581
|
+
continue
|
|
582
|
+
ref = t.split(':')[-1].strip()
|
|
583
|
+
if ref in PROTECTED_BRANCHES:
|
|
584
|
+
return f'git push to protected branch ({ref})'
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _gate_release(low: str, verb: str, tokens):
|
|
589
|
+
if verb == 'vercel' or (verb == 'npx' and 'vercel' in tokens[:3]):
|
|
590
|
+
if '--prod' in tokens or '--production' in tokens:
|
|
591
|
+
return 'vercel --prod'
|
|
592
|
+
for t in tokens:
|
|
593
|
+
if t in ('promote', 'alias', 'rollback'):
|
|
594
|
+
return f'vercel {t}'
|
|
595
|
+
if verb in ('npm', 'pnpm', 'yarn') and 'publish' in tokens[1:3]:
|
|
596
|
+
return f'{verb} publish'
|
|
597
|
+
if re.search(r'\bsupabase\s+functions\s+deploy\b', low):
|
|
598
|
+
return 'supabase functions deploy'
|
|
599
|
+
# 「deploy っぽい語」+「本番の指定」の組み合わせ(新しい道具にも効く一般形)。
|
|
600
|
+
if re.search(r'\bdeploy\b', low) and re.search(r'--prod(uction)?\b', low):
|
|
601
|
+
return 'deploy --prod'
|
|
602
|
+
return None
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _gate_db(low: str, verb: str, tokens, segment: str):
|
|
606
|
+
if verb in ('psql', 'mysql', 'mongosh', 'dropdb', 'createdb', 'pg_restore', 'pg_dump'):
|
|
607
|
+
return f'database client ({verb})'
|
|
608
|
+
if re.search(r'\bprisma\s+(migrate|db)\b', low):
|
|
609
|
+
return 'prisma migrate/db'
|
|
610
|
+
if re.search(r'\bsupabase\s+(db\s+(push|reset|query|execute)|migration\s+up)\b', low):
|
|
611
|
+
return 'supabase db/migration'
|
|
612
|
+
if re.search(r'\bdrizzle-kit\s+(push|migrate)\b', low):
|
|
613
|
+
return 'drizzle-kit push/migrate'
|
|
614
|
+
# SQL 文字列(DDL / GRANT / RLS)を根拠にした関門。
|
|
615
|
+
# ★「実際にSQLを実行しうる入口」に限る。文字列を扱うだけの動詞(echo / grep / git …)は
|
|
616
|
+
# 引数にSQLの字面があっても実行しないので関門にしない(=誤検知を消す・緩める変更)。
|
|
617
|
+
# 一方、psql/mysql への `| psql` は分割後に psql セグメントが別途上で関門に当たり、
|
|
618
|
+
# run-sql / apply のような未知のSQL実行入口は safe 集合に無いのでここで関門に落ちる。
|
|
619
|
+
if verb not in _SQL_TEXT_ONLY_VERBS and _SQL_GATE_RE.search(segment):
|
|
620
|
+
return 'SQL DDL/GRANT/RLS'
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
625
|
+
# 入口: Bash
|
|
626
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
627
|
+
|
|
628
|
+
def decide_bash(command: str):
|
|
629
|
+
"""Bash コマンド1件の判定。既定は allow。"""
|
|
630
|
+
if not command or not command.strip():
|
|
631
|
+
return _r(ALLOW, 'empty command')
|
|
632
|
+
low = command.lower()
|
|
633
|
+
|
|
634
|
+
# ---- 1) 危険(全文) ----
|
|
635
|
+
hit = _danger_patterns(low)
|
|
636
|
+
if hit:
|
|
637
|
+
return _r(DENY, f'danger: {hit}')
|
|
638
|
+
hit = _rm_danger(command)
|
|
639
|
+
if hit:
|
|
640
|
+
return _r(DENY, f'danger: {hit}')
|
|
641
|
+
|
|
642
|
+
segments = split_segments(command)
|
|
643
|
+
|
|
644
|
+
# ---- 1) 危険(セグメントごと・対象と作用) ----
|
|
645
|
+
for seg in segments:
|
|
646
|
+
toks = tokens_of(seg)
|
|
647
|
+
v = verb_of(toks)
|
|
648
|
+
hit = _secret_danger(seg, v, toks)
|
|
649
|
+
if hit:
|
|
650
|
+
return _r(DENY, f'danger: {hit}')
|
|
651
|
+
|
|
652
|
+
# ---- 1c) 危険(ラッパの中身にも同じ物差しを当てる) ----
|
|
653
|
+
# `bash -c "cat .env"` / `python3 -c "open('.env')"` / ヒアドキュメント経由。
|
|
654
|
+
hit = _wrapper_danger(command)
|
|
655
|
+
if hit:
|
|
656
|
+
return _r(DENY, f'danger: {hit}')
|
|
657
|
+
|
|
658
|
+
# ---- 2) 3関門 ----
|
|
659
|
+
for seg in segments:
|
|
660
|
+
toks = tokens_of(seg)
|
|
661
|
+
v = verb_of(toks)
|
|
662
|
+
seg_low = seg.lower()
|
|
663
|
+
hit = _gate_merge(seg_low, v, toks)
|
|
664
|
+
if hit:
|
|
665
|
+
return _r(GATE, f'gate(merge): {hit}', GATE_MERGE)
|
|
666
|
+
hit = _gate_release(seg_low, v, toks)
|
|
667
|
+
if hit:
|
|
668
|
+
return _r(GATE, f'gate(release): {hit}', GATE_RELEASE)
|
|
669
|
+
hit = _gate_db(seg_low, v, toks, seg)
|
|
670
|
+
if hit:
|
|
671
|
+
return _r(GATE, f'gate(db): {hit}', GATE_DB)
|
|
672
|
+
|
|
673
|
+
# ---- 3) それ以外は自走 ----
|
|
674
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
678
|
+
# 入口: ファイル編集ツール(Write / Edit / MultiEdit / NotebookEdit)
|
|
679
|
+
# ★Bash と同じ語彙で判定する=経路による強度差を無くす。
|
|
680
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
681
|
+
|
|
682
|
+
def norm_path(file_path: str, cwd=None) -> str:
|
|
683
|
+
p = os.path.expanduser(file_path.strip())
|
|
684
|
+
if not os.path.isabs(p):
|
|
685
|
+
base = cwd if (cwd and os.path.isabs(cwd)) else os.getcwd()
|
|
686
|
+
p = os.path.join(base, p)
|
|
687
|
+
return os.path.normpath(p)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def decide_write(file_path: str, cwd=None):
|
|
691
|
+
if not file_path or not isinstance(file_path, str) or not file_path.strip():
|
|
692
|
+
return _r(ALLOW, 'empty file_path')
|
|
693
|
+
try:
|
|
694
|
+
path = norm_path(file_path, cwd)
|
|
695
|
+
except Exception: # noqa: BLE001
|
|
696
|
+
return _r(DENY, 'danger: unnormalizable path')
|
|
697
|
+
|
|
698
|
+
sr = secret_reason(path)
|
|
699
|
+
if sr:
|
|
700
|
+
return _r(DENY, f'danger: write to {sr}')
|
|
701
|
+
syr = system_write_reason(path)
|
|
702
|
+
if syr:
|
|
703
|
+
return _r(DENY, f'danger: write to {syr}')
|
|
704
|
+
# 番人自身の設定は書かせない(安全境界の自己書き換えを防ぐ)。
|
|
705
|
+
# ★名前だけで決めない(ADR-014)。守るのは本物の場所(~/.claude と 各現場の .claude)で、
|
|
706
|
+
# 使い捨ての作業場(/tmp などの下)に職人が作った同じ名前は止めない。
|
|
707
|
+
# 既定は守る側=使い捨てだと確実に言えるときだけ通す(本物の場所は1つも漏れない)。
|
|
708
|
+
# ★字面だけでなく、近道(symlink)を解いた実体も見る。別名の近道
|
|
709
|
+
# (例 /tmp/shortcut → ~/.claude)から本物へ書き込むのを塞ぐ。
|
|
710
|
+
if is_guard_settings_path(path) and not is_throwaway_path(path):
|
|
711
|
+
return _r(DENY, 'danger: write to guard settings (.claude/settings*.json)')
|
|
712
|
+
comps = [c for c in path.split('/') if c]
|
|
713
|
+
if '.tyhld' in comps:
|
|
714
|
+
return _r(DENY, 'danger: write to guard directory (.tyhld)')
|
|
715
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
719
|
+
# 入口: ネットワーク(WebFetch)
|
|
720
|
+
# ★既定 allow。取得そのものは自走してよい。止めるのは秘密の持ち出しだが、
|
|
721
|
+
# WebFetch は URL しか持たないので、URL に秘密が載っている場合だけ deny。
|
|
722
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
723
|
+
|
|
724
|
+
def decide_network(url: str):
|
|
725
|
+
if not url or not isinstance(url, str) or not url.strip():
|
|
726
|
+
return _r(ALLOW, 'empty url')
|
|
727
|
+
if _SECRET_ENV_RE.search(url):
|
|
728
|
+
return _r(DENY, 'danger: secret in url')
|
|
729
|
+
if re.search(r'[?&](token|secret|password|api_?key)=', url, re.IGNORECASE):
|
|
730
|
+
return _r(DENY, 'danger: credential in url query')
|
|
731
|
+
return _r(ALLOW, 'no gate, no danger')
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
735
|
+
# ツール種別のディスパッチ
|
|
736
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
737
|
+
WRITE_TOOLS = frozenset({'Write', 'Edit', 'MultiEdit', 'NotebookEdit'})
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def decide_event(data):
|
|
741
|
+
"""PreToolUse の入力(dict) 1件を判定する。判断材料が無いものは allow(既定)。"""
|
|
742
|
+
if not isinstance(data, dict):
|
|
743
|
+
return _r(ALLOW, 'malformed hook input')
|
|
744
|
+
tool = data.get('tool_name')
|
|
745
|
+
tin = data.get('tool_input')
|
|
746
|
+
if not isinstance(tin, dict):
|
|
747
|
+
tin = {}
|
|
748
|
+
|
|
749
|
+
if tool == 'Bash':
|
|
750
|
+
return decide_bash(tin.get('command', ''))
|
|
751
|
+
if tool == 'WebFetch':
|
|
752
|
+
return decide_network(tin.get('url', ''))
|
|
753
|
+
if tool in WRITE_TOOLS:
|
|
754
|
+
fp = tin.get('file_path', '')
|
|
755
|
+
if not fp:
|
|
756
|
+
return _r(ALLOW, 'no file_path')
|
|
757
|
+
return decide_write(fp, data.get('cwd'))
|
|
758
|
+
# WebSearch / MCP / その他 … 調査や道具の呼び出しは自走してよい(既定 allow)。
|
|
759
|
+
return _r(ALLOW, 'no gate, no danger')
|