@tyhld/conductor 0.12.18 → 0.12.20
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/scripts/hooks/README.md +63 -9
- package/scripts/hooks/policy.py +669 -63
- package/scripts/hooks/summary_ja.py +2 -1
- package/scripts/hooks/test_autorun.py +33 -13
- package/scripts/hooks/test_bypass_deny.py +299 -0
- package/scripts/hooks/test_gate_real_commands.py +202 -0
- package/scripts/hooks/test_gate_wait.py +3 -1
- package/scripts/hooks/test_policy.py +23 -31
package/scripts/hooks/policy.py
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
【この版の判定(上から順に1つだけ当たる)】
|
|
12
12
|
1. 危険 → deny … 本番破壊・force push・データ消失・認証情報の露出/持ち出し
|
|
13
|
-
|
|
13
|
+
★抜け道(便 fc22bc34): main 直 push・保護の迂回と変更・秘密の値の設定・
|
|
14
|
+
リポの削除・検査の省略・本番 DB の直接変更(_bypass_scan / _prod_db_change)
|
|
15
|
+
2. 関門 → gate … 本番確認 / DB実行(人が必ず判断する)
|
|
14
16
|
3. それ以外 → allow … 自走してよい。★新しいコマンドは必ずここへ落ちる
|
|
15
17
|
|
|
16
|
-
★PRのマージ(`gh pr merge`)は関門ではない(決定・案B
|
|
17
|
-
ただし保護を迂回する `gh pr merge --admin`
|
|
18
|
+
★PRのマージ(`gh pr merge`)は関門ではない(決定・案B完全自動化)。
|
|
19
|
+
ただし保護を迂回する `gh pr merge --admin` と、API での合流(pulls/N/merge)は危険(deny)。
|
|
20
|
+
★main など保護ブランチへの直接 push は、関門から危険(deny)へ移した(えふさん確定 2026-09-18①)。
|
|
21
|
+
通常は PR 経由に統一する。関門の種別の字面 'merge' は管制の表示・集計のために残してある。
|
|
18
22
|
|
|
19
23
|
【危険は「動詞」ではなく「対象と作用」で見る】
|
|
20
24
|
旧番人は動詞を白に入れた瞬間に対象を見なくなり、`curl -d @.env`・`cp .env /tmp`・
|
|
@@ -38,9 +42,10 @@ import shlex
|
|
|
38
42
|
DENY, GATE, ALLOW = 'deny', 'gate', 'allow'
|
|
39
43
|
|
|
40
44
|
# 関門の種類(表示・集計用)。
|
|
41
|
-
# ★GATE_MERGE
|
|
42
|
-
#
|
|
43
|
-
|
|
45
|
+
# ★GATE_MERGE は「PRのマージ」→「main への直接 push」→(便 fc22bc34)関門でなくなった
|
|
46
|
+
# (main 直 push は deny・_main_push_danger)。種別の字面 'merge' は管制の表示・集計が
|
|
47
|
+
# 使っているので、定数は残す(policy からはもう返さない)。
|
|
48
|
+
GATE_MERGE = 'merge' # 旧 関門1 本番ブランチの直接変更(いまは deny)
|
|
44
49
|
GATE_RELEASE = 'release' # 関門2 本番確認
|
|
45
50
|
GATE_DB = 'db' # 関門3 DB実行
|
|
46
51
|
|
|
@@ -471,10 +476,108 @@ _SPLIT_RE = re.compile(r'&&|\|\||[;|&\n]')
|
|
|
471
476
|
|
|
472
477
|
|
|
473
478
|
def split_segments(command: str):
|
|
474
|
-
"""複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。
|
|
479
|
+
"""複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。
|
|
480
|
+
|
|
481
|
+
★機械的に割る(引用符・ヒアドキュメントの中でも割る)。危険(deny)の判定はこちらを使う
|
|
482
|
+
=割り方を変えても止める側は1件も減らない(便 ad6b5db9)。関門は split_commands を使う。
|
|
483
|
+
"""
|
|
475
484
|
return [s.strip() for s in _SPLIT_RE.split(command) if s.strip()]
|
|
476
485
|
|
|
477
486
|
|
|
487
|
+
# ヒアドキュメントの始まり(<<EOF / <<-EOF / <<'EOF' / <<"EOF")。<<< は文字列を渡す別物。
|
|
488
|
+
_HEREDOC_START_RE = re.compile(r'<<(-?)[ \t]*([\'"]?)([A-Za-z_][A-Za-z0-9_]*)\2')
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def split_commands(command: str):
|
|
492
|
+
"""★本物の区切りだけで割る(便 ad6b5db9・関門の判定に使う)。
|
|
493
|
+
|
|
494
|
+
【なぜ要るか(便 df4accf8 の重大発見1・便 30879701)】split_segments は引用符や
|
|
495
|
+
ヒアドキュメントの中でも割るので、コミットの説明・PR の本文・検索の文字・手順書の中の
|
|
496
|
+
「create table」「vercel --prod」が、ひとつのコマンドとして関門に当たっていた(7日で90件)。
|
|
497
|
+
【どう割るか】
|
|
498
|
+
・引用符(' " `)の中は割らない。\\ の後ろの1文字もそのまま。
|
|
499
|
+
・ヒアドキュメントの本文は、行の終わりで【その時点の最後のコマンド】に付けてひとかたまりにする
|
|
500
|
+
(`cat <<EOF > f` なら cat に、`cat <<EOF | psql` なら流れ込む先の psql に付く)。
|
|
501
|
+
・# から行末までの注釈は捨てる(語の頭にあるときだけ)。
|
|
502
|
+
・2>&1 / &> / >& の & は区切りではない。
|
|
503
|
+
"""
|
|
504
|
+
out, cur = [], []
|
|
505
|
+
pending = [] # 本文を待っているヒアドキュメント [(区切りの語, 字下げを許すか)]
|
|
506
|
+
quote = None
|
|
507
|
+
i, n = 0, len(command or '')
|
|
508
|
+
|
|
509
|
+
def flush():
|
|
510
|
+
s = ''.join(cur).strip()
|
|
511
|
+
if s:
|
|
512
|
+
out.append(s)
|
|
513
|
+
cur.clear()
|
|
514
|
+
|
|
515
|
+
while i < n:
|
|
516
|
+
c = command[i]
|
|
517
|
+
if quote:
|
|
518
|
+
cur.append(c)
|
|
519
|
+
if c == '\\' and quote != "'" and i + 1 < n:
|
|
520
|
+
cur.append(command[i + 1])
|
|
521
|
+
i += 2
|
|
522
|
+
continue
|
|
523
|
+
if c == quote:
|
|
524
|
+
quote = None
|
|
525
|
+
i += 1
|
|
526
|
+
continue
|
|
527
|
+
if c == '\\' and i + 1 < n:
|
|
528
|
+
cur.append(command[i:i + 2])
|
|
529
|
+
i += 2
|
|
530
|
+
continue
|
|
531
|
+
if c in ('"', "'", '`'):
|
|
532
|
+
quote = c
|
|
533
|
+
cur.append(c)
|
|
534
|
+
i += 1
|
|
535
|
+
continue
|
|
536
|
+
if c == '#' and (i == 0 or command[i - 1] in ' \t\n;&|('):
|
|
537
|
+
j = command.find('\n', i)
|
|
538
|
+
i = n if j < 0 else j
|
|
539
|
+
continue
|
|
540
|
+
if command.startswith('<<', i) and not command.startswith('<<<', i):
|
|
541
|
+
m = _HEREDOC_START_RE.match(command, i)
|
|
542
|
+
if m:
|
|
543
|
+
pending.append((m.group(3), m.group(1) == '-'))
|
|
544
|
+
cur.append(m.group(0))
|
|
545
|
+
i = m.end()
|
|
546
|
+
continue
|
|
547
|
+
if c == '\n':
|
|
548
|
+
i += 1
|
|
549
|
+
if pending:
|
|
550
|
+
for delim, strip_tabs in pending:
|
|
551
|
+
body = []
|
|
552
|
+
while i < n:
|
|
553
|
+
j = command.find('\n', i)
|
|
554
|
+
line = command[i:n if j < 0 else j]
|
|
555
|
+
i = n if j < 0 else j + 1
|
|
556
|
+
if (line.lstrip('\t') if strip_tabs else line).strip() == delim:
|
|
557
|
+
break
|
|
558
|
+
body.append(line)
|
|
559
|
+
cur.append('\n' + '\n'.join(body))
|
|
560
|
+
pending = []
|
|
561
|
+
flush()
|
|
562
|
+
continue
|
|
563
|
+
if command.startswith('&&', i) or command.startswith('||', i):
|
|
564
|
+
flush()
|
|
565
|
+
i += 2
|
|
566
|
+
continue
|
|
567
|
+
if c == '&' and ((i > 0 and command[i - 1] == '>') or command.startswith('&>', i)):
|
|
568
|
+
cur.append(c)
|
|
569
|
+
i += 1
|
|
570
|
+
continue
|
|
571
|
+
if c in ';|&':
|
|
572
|
+
flush()
|
|
573
|
+
i += 1
|
|
574
|
+
continue
|
|
575
|
+
cur.append(c)
|
|
576
|
+
i += 1
|
|
577
|
+
flush()
|
|
578
|
+
return out
|
|
579
|
+
|
|
580
|
+
|
|
478
581
|
def tokens_of(segment: str):
|
|
479
582
|
"""1セグメントを語に割る。クォートが壊れていても落ちない(安全側で素朴分割へ)。"""
|
|
480
583
|
try:
|
|
@@ -706,6 +809,10 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
|
|
|
706
809
|
or _recursive_read_danger(v, toks, cwd) or _admin_merge_danger(v, toks))
|
|
707
810
|
if hit:
|
|
708
811
|
return f'{hit} ({where})'
|
|
812
|
+
# ★抜け道も包みの中で測り直す(`bash -c "git push origin main"` を素通りさせない)。
|
|
813
|
+
hit = _bypass_scan(inner, cwd)
|
|
814
|
+
if hit:
|
|
815
|
+
return f'{hit} ({where})'
|
|
709
816
|
hit = _wrapper_danger(inner, depth + 1, cwd)
|
|
710
817
|
if hit:
|
|
711
818
|
return hit
|
|
@@ -1068,6 +1175,442 @@ def _admin_merge_danger(verb: str, tokens):
|
|
|
1068
1175
|
return None
|
|
1069
1176
|
|
|
1070
1177
|
|
|
1178
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1179
|
+
# 抜け道(DENY・便 fc22bc34・えふさん確定 2026-09-18)
|
|
1180
|
+
#
|
|
1181
|
+
# 【考え方】ALLOW=普通の開発・練習環境/ASK=正当だが実世界に影響する操作/
|
|
1182
|
+
# DENY=職人が行う必要が無い抜け道(main 直 push・本番 DB の直接変更・秘密の値の操作・
|
|
1183
|
+
# 保護の変更・検査の省略・迂回による保護回避)。ここは DENY の側。
|
|
1184
|
+
# 【字面だけでは止めない】どれも「実際に動く道具の語の並び」で見る。コミットの説明や PR の本文に
|
|
1185
|
+
# 同じ語が書かれているだけの操作は止めない(動詞と、その後ろの語の並びで決める)。
|
|
1186
|
+
# ★例外は [skip ci] 類: コミットの説明に書かれた時点で GitHub が CI を飛ばす(本物の省略)。
|
|
1187
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
1188
|
+
|
|
1189
|
+
# npx / pnpm dlx などの「道具を呼ぶための前置き」。取り除いて道具の語の並びを見る。
|
|
1190
|
+
_TOOL_RUNNERS = frozenset({'npx', 'pnpx', 'bunx'})
|
|
1191
|
+
_PKG_MANAGERS = frozenset({'npm', 'pnpm', 'yarn'})
|
|
1192
|
+
_PKG_EXEC_WORDS = frozenset({'exec', 'dlx', 'x'})
|
|
1193
|
+
# 前置きの後ろに直接書かれる道具(`pnpm vercel env add` の形)。
|
|
1194
|
+
_KNOWN_TOOLS = frozenset({
|
|
1195
|
+
'vercel', 'supabase', 'wrangler', 'prisma', 'drizzle-kit', 'fly', 'flyctl', 'heroku',
|
|
1196
|
+
'netlify', 'gh', 'psql', 'dropdb', 'claude',
|
|
1197
|
+
})
|
|
1198
|
+
# 値を1つ取る旗(語の並びから値ごと外す)。
|
|
1199
|
+
_VALUE_FLAGS = frozenset({
|
|
1200
|
+
'--scope', '-S', '--token', '-t', '--cwd', '--project-ref', '--app', '-a', '--config',
|
|
1201
|
+
'-c', '-C', '--account-id', '--site', '--env', '-e', '--repo', '-R', '--schema',
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
# 前置き(npm / pnpm / yarn / npx)の、値を1つ取る旗(`--filter <部品>` など)。
|
|
1205
|
+
_RUNNER_VALUE_FLAGS = frozenset({
|
|
1206
|
+
'--filter', '-F', '--dir', '-C', '--workspace', '-w', '--prefix', '--package', '-p',
|
|
1207
|
+
})
|
|
1208
|
+
|
|
1209
|
+
# 作業場所が変数などで決まり、確かめられないことを表す印(None=「指定なし=今の場所」とは別)。
|
|
1210
|
+
_UNKNOWN_DIR = object()
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _tool_words(tokens):
|
|
1214
|
+
"""道具の語の並び(前置き・変数代入・旗を外し、小文字にしたもの)。無ければ []。"""
|
|
1215
|
+
i = 0
|
|
1216
|
+
while i < len(tokens):
|
|
1217
|
+
t = tokens[i]
|
|
1218
|
+
if _ASSIGN_RE.match(t):
|
|
1219
|
+
i += 1
|
|
1220
|
+
continue
|
|
1221
|
+
base = t.split('/')[-1]
|
|
1222
|
+
if base in _WRAPPERS:
|
|
1223
|
+
i += 1
|
|
1224
|
+
while i < len(tokens) and tokens[i].startswith('-'):
|
|
1225
|
+
i += 1
|
|
1226
|
+
if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
|
|
1227
|
+
and not tokens[i].startswith('-'):
|
|
1228
|
+
i += 1
|
|
1229
|
+
continue
|
|
1230
|
+
break
|
|
1231
|
+
rest = tokens[i:]
|
|
1232
|
+
if not rest:
|
|
1233
|
+
return []
|
|
1234
|
+
head = rest[0].split('/')[-1].lower()
|
|
1235
|
+
if head in _TOOL_RUNNERS or head in _PKG_MANAGERS:
|
|
1236
|
+
# ★`pnpm --filter <部品> exec <道具>` / `npm -w <部品> exec -- <道具>` / `npx -p <部品> <道具>` /
|
|
1237
|
+
# `yarn workspace <部品> <道具>` の前置きを剥がす(便 ad6b5db9・便 30879701 の実機)。
|
|
1238
|
+
j, saw_exec = 1, head in _TOOL_RUNNERS
|
|
1239
|
+
while j < len(rest):
|
|
1240
|
+
t = rest[j]
|
|
1241
|
+
if t in _RUNNER_VALUE_FLAGS:
|
|
1242
|
+
j += 2
|
|
1243
|
+
continue
|
|
1244
|
+
if t.startswith('-'):
|
|
1245
|
+
j += 1
|
|
1246
|
+
continue
|
|
1247
|
+
if t.lower() in _PKG_EXEC_WORDS:
|
|
1248
|
+
saw_exec = True
|
|
1249
|
+
j += 1
|
|
1250
|
+
continue
|
|
1251
|
+
if head == 'yarn' and t.lower() == 'workspace':
|
|
1252
|
+
j += 2
|
|
1253
|
+
continue
|
|
1254
|
+
break
|
|
1255
|
+
nxt = _strip_version(rest[j].split('/')[-1].lower()) if j < len(rest) else ''
|
|
1256
|
+
if saw_exec or nxt in _KNOWN_TOOLS:
|
|
1257
|
+
rest = rest[j:]
|
|
1258
|
+
if not rest:
|
|
1259
|
+
return [] # 前置きだけで道具が無い(`npx` だけ等)。★例外で落とさない。
|
|
1260
|
+
# 前置きの旗(-p・-w 等)は、頭が npm / pnpm / yarn のまま残ったときだけ値ごと外す
|
|
1261
|
+
# (`mkdir -p` や `grep -w` の後ろの語を消さないため)。
|
|
1262
|
+
value_flags = _VALUE_FLAGS | (_RUNNER_VALUE_FLAGS
|
|
1263
|
+
if rest[0].split('/')[-1].lower() in _PKG_MANAGERS else frozenset())
|
|
1264
|
+
words = []
|
|
1265
|
+
skip = False
|
|
1266
|
+
for k, t in enumerate(rest):
|
|
1267
|
+
if skip:
|
|
1268
|
+
skip = False
|
|
1269
|
+
continue
|
|
1270
|
+
if k > 0 and t.startswith('-'):
|
|
1271
|
+
if t in value_flags:
|
|
1272
|
+
skip = True
|
|
1273
|
+
continue
|
|
1274
|
+
words.append(_strip_version(t.split('/')[-1].lower()) if k == 0 else t.lower())
|
|
1275
|
+
return words
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _strip_version(name: str) -> str:
|
|
1279
|
+
"""`wrangler@3` → `wrangler`(版の指定を外す)。`@scope` の頭の @ は残す。"""
|
|
1280
|
+
return name.split('@', 1)[0] if '@' in name[1:] else name
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _current_branch_targets(cwd):
|
|
1284
|
+
"""cwd のリポで、引数なしの push が書き込む先のブランチ名の集合。確かめられなければ None。
|
|
1285
|
+
|
|
1286
|
+
★今のブランチ(rev-parse --abbrev-ref HEAD)と、設定された push 先(@{push})の両方を見る。
|
|
1287
|
+
切り離された HEAD・git が無い・遅い(5秒)・cwd が分からない、は確かめられない=None。
|
|
1288
|
+
"""
|
|
1289
|
+
try:
|
|
1290
|
+
import subprocess
|
|
1291
|
+
def out(args):
|
|
1292
|
+
r = subprocess.run(['git'] + args, cwd=cwd or None, capture_output=True,
|
|
1293
|
+
text=True, timeout=5)
|
|
1294
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
1295
|
+
head = out(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
1296
|
+
if not head or head == 'HEAD':
|
|
1297
|
+
return None
|
|
1298
|
+
targets = {head}
|
|
1299
|
+
push = out(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}'])
|
|
1300
|
+
if push and '/' in push:
|
|
1301
|
+
targets.add(push.split('/', 1)[1])
|
|
1302
|
+
return targets
|
|
1303
|
+
except Exception: # noqa: BLE001
|
|
1304
|
+
return None
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _main_push_danger(tokens, cwd):
|
|
1308
|
+
"""main など保護ブランチへの直接 push(★便 fc22bc34 で関門から DENY へ・えふさん確定①)。
|
|
1309
|
+
|
|
1310
|
+
★通常は PR 経由に統一する。職人が main へ直接 push する必要は無い。
|
|
1311
|
+
★書き込む先を書かない形(`git push` / `git push origin` / `git push origin HEAD`)は、
|
|
1312
|
+
今のブランチを git で確かめる。確かめられないとき(切り離された HEAD・git が答えない・
|
|
1313
|
+
作業場所が変数で決まる)は DENY に倒す=main へ書く可能性を消せないものは通さない。
|
|
1314
|
+
★`--all` / `--mirror` / `--branches` は全部のブランチを書くので DENY。
|
|
1315
|
+
"""
|
|
1316
|
+
words = _tool_words(tokens)
|
|
1317
|
+
if not words or words[0] != 'git':
|
|
1318
|
+
return None
|
|
1319
|
+
try:
|
|
1320
|
+
start = [t.split('/')[-1] for t in tokens].index('git')
|
|
1321
|
+
except ValueError:
|
|
1322
|
+
return None
|
|
1323
|
+
# git の前置きの旗(-C 場所 / -c 設定)を読む。
|
|
1324
|
+
where = cwd
|
|
1325
|
+
args = list(tokens[start + 1:])
|
|
1326
|
+
while args and args[0].startswith('-'):
|
|
1327
|
+
flag = args.pop(0)
|
|
1328
|
+
if flag == '-C' and args:
|
|
1329
|
+
p = args.pop(0)
|
|
1330
|
+
if any(c in p for c in '$`') or where is _UNKNOWN_DIR:
|
|
1331
|
+
where = _UNKNOWN_DIR
|
|
1332
|
+
else:
|
|
1333
|
+
p = os.path.expanduser(p)
|
|
1334
|
+
where = p if os.path.isabs(p) else os.path.join(where or os.getcwd(), p)
|
|
1335
|
+
elif flag == '-c' and args:
|
|
1336
|
+
args.pop(0)
|
|
1337
|
+
if not args or args[0] != 'push':
|
|
1338
|
+
return None
|
|
1339
|
+
push_args = args[1:]
|
|
1340
|
+
positional = []
|
|
1341
|
+
it = iter(push_args)
|
|
1342
|
+
for t in it:
|
|
1343
|
+
if t in ('--all', '--mirror', '--branches'):
|
|
1344
|
+
return f'git push {t} (全部のブランチ=保護ブランチも書く)'
|
|
1345
|
+
if t in ('--repo', '-o', '--push-option', '--receive-pack', '--exec'):
|
|
1346
|
+
next(it, None)
|
|
1347
|
+
continue
|
|
1348
|
+
if t.startswith('-'):
|
|
1349
|
+
continue
|
|
1350
|
+
positional.append(t)
|
|
1351
|
+
refspecs = positional[1:]
|
|
1352
|
+
needs_head = not refspecs
|
|
1353
|
+
for spec in refspecs:
|
|
1354
|
+
spec = spec.lstrip('+')
|
|
1355
|
+
src, _, dst = spec.partition(':')
|
|
1356
|
+
target = dst if dst else src
|
|
1357
|
+
if target in ('HEAD', '@'):
|
|
1358
|
+
needs_head = True
|
|
1359
|
+
continue
|
|
1360
|
+
name = target.removeprefix('refs/heads/')
|
|
1361
|
+
if name in PROTECTED_BRANCHES:
|
|
1362
|
+
return f'git push to protected branch ({name})'
|
|
1363
|
+
if needs_head:
|
|
1364
|
+
if where is _UNKNOWN_DIR:
|
|
1365
|
+
return 'git push (書き込む先のブランチを確かめられない=作業場所が変数)'
|
|
1366
|
+
targets = _current_branch_targets(where)
|
|
1367
|
+
if targets is None:
|
|
1368
|
+
return 'git push (書き込む先のブランチを確かめられない)'
|
|
1369
|
+
hit = sorted(targets & PROTECTED_BRANCHES)
|
|
1370
|
+
if hit:
|
|
1371
|
+
return f'git push to protected branch ({hit[0]}・今のブランチ)'
|
|
1372
|
+
return None
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
# GitHub の API の道筋(書き込み)。
|
|
1376
|
+
_API_MERGE_RE = re.compile(r'(^|/)pulls/[^/]+/merge/?$')
|
|
1377
|
+
_API_PROTECTION_RE = re.compile(
|
|
1378
|
+
r'/branches/[^/]+/protection(/|$)|(^|/)rulesets(/|$)|/environments/[^/]+/?$'
|
|
1379
|
+
r'|/deployment-branch-policies(/|$)|/deployment_protection_rules(/|$)')
|
|
1380
|
+
_API_SECRET_RE = re.compile(
|
|
1381
|
+
r'/(actions|dependabot|codespaces|agents)/(secrets|variables)(/|$)'
|
|
1382
|
+
r'|/environments/[^/]+/(secrets|variables)(/|$)')
|
|
1383
|
+
_API_REPO_RE = re.compile(r'^repos/[^/]+/[^/]+/?$')
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _github_api_write(endpoint: str, method: str, body_low: str):
|
|
1387
|
+
"""GitHub の API への書き込み1件が抜け道なら理由を返す(読み取りは通す)。"""
|
|
1388
|
+
ep = re.sub(r'^https?://api\.github\.com/', '', endpoint.strip()).lstrip('/').split('?')[0]
|
|
1389
|
+
if method in ('GET', 'HEAD'):
|
|
1390
|
+
return None
|
|
1391
|
+
if _API_MERGE_RE.search(ep) and method in ('PUT', 'POST'):
|
|
1392
|
+
return 'API での合流 (pulls/N/merge・保護を迂回する合流)'
|
|
1393
|
+
if ep.rstrip('/') == 'graphql' and 'mergepullrequest' in body_low:
|
|
1394
|
+
return 'GraphQL での合流 (mergePullRequest・保護を迂回する合流)'
|
|
1395
|
+
if _API_PROTECTION_RE.search(ep):
|
|
1396
|
+
return f'保護の変更 ({method} {ep})'
|
|
1397
|
+
if _API_SECRET_RE.search(ep):
|
|
1398
|
+
return f'秘密の値・変数の変更 ({method} {ep})'
|
|
1399
|
+
if _API_REPO_RE.match(ep) and method == 'DELETE':
|
|
1400
|
+
return 'リポの削除 (API)'
|
|
1401
|
+
return None
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
_GH_API_VALUE_FLAGS = frozenset({
|
|
1405
|
+
'-X', '--method', '-H', '--header', '-f', '--raw-field', '-F', '--field', '--input',
|
|
1406
|
+
'-q', '--jq', '-t', '--template', '--hostname', '-p', '--preview', '--cache',
|
|
1407
|
+
})
|
|
1408
|
+
_GH_API_BODY_FLAGS = frozenset({'-f', '--raw-field', '-F', '--field', '--input'})
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _gh_api_danger(words, tokens, seg_low):
|
|
1412
|
+
if words[:2] != ['gh', 'api']:
|
|
1413
|
+
return None
|
|
1414
|
+
try:
|
|
1415
|
+
start = [t.split('/')[-1].lower() for t in tokens].index('gh')
|
|
1416
|
+
except ValueError:
|
|
1417
|
+
return None
|
|
1418
|
+
args = tokens[start + 2:]
|
|
1419
|
+
method, endpoint, has_body = None, None, False
|
|
1420
|
+
k = 0
|
|
1421
|
+
while k < len(args):
|
|
1422
|
+
t = args[k]
|
|
1423
|
+
if t.startswith('-X') and len(t) > 2:
|
|
1424
|
+
method = t[2:]
|
|
1425
|
+
elif t.startswith('--method='):
|
|
1426
|
+
method = t.split('=', 1)[1]
|
|
1427
|
+
elif t in _GH_API_VALUE_FLAGS:
|
|
1428
|
+
if t in ('-X', '--method') and k + 1 < len(args):
|
|
1429
|
+
method = args[k + 1]
|
|
1430
|
+
if t in _GH_API_BODY_FLAGS:
|
|
1431
|
+
has_body = True
|
|
1432
|
+
k += 2
|
|
1433
|
+
continue
|
|
1434
|
+
elif any(t.startswith(f + '=') for f in ('--raw-field', '--field', '--input')):
|
|
1435
|
+
has_body = True
|
|
1436
|
+
elif not t.startswith('-') and endpoint is None:
|
|
1437
|
+
endpoint = t
|
|
1438
|
+
k += 1
|
|
1439
|
+
if endpoint is None:
|
|
1440
|
+
return None
|
|
1441
|
+
method = (method or ('POST' if has_body else 'GET')).upper()
|
|
1442
|
+
return _github_api_write(endpoint, method, seg_low)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
_HTTP_VERBS = frozenset({'curl', 'wget', 'http', 'https', 'xh'})
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _http_github_danger(words, tokens, seg_low):
|
|
1449
|
+
"""curl などで GitHub の API へ直接書き込む形(gh を通さない抜け道)。"""
|
|
1450
|
+
if not words or words[0] not in _HTTP_VERBS:
|
|
1451
|
+
return None
|
|
1452
|
+
urls = [t for t in tokens if 'api.github.com/' in t.lower()]
|
|
1453
|
+
if not urls:
|
|
1454
|
+
return None
|
|
1455
|
+
method = None
|
|
1456
|
+
for k, t in enumerate(tokens):
|
|
1457
|
+
if t in ('-X', '--request', '--method') and k + 1 < len(tokens):
|
|
1458
|
+
method = tokens[k + 1]
|
|
1459
|
+
elif t.startswith('-X') and len(t) > 2:
|
|
1460
|
+
method = t[2:]
|
|
1461
|
+
elif t.startswith('--method='):
|
|
1462
|
+
method = t.split('=', 1)[1]
|
|
1463
|
+
if method is None:
|
|
1464
|
+
has_body = any(t in ('-d', '--data', '--data-raw', '--data-binary', '--json', '-F', '--form')
|
|
1465
|
+
or t.startswith('--data') for t in tokens)
|
|
1466
|
+
method = 'POST' if has_body else 'GET'
|
|
1467
|
+
if words[0] in ('http', 'https', 'xh') and len(words) > 1 \
|
|
1468
|
+
and words[1].upper() in ('PUT', 'POST', 'PATCH', 'DELETE'):
|
|
1469
|
+
method = words[1]
|
|
1470
|
+
for u in urls:
|
|
1471
|
+
hit = _github_api_write(u.split('api.github.com/', 1)[1], method.upper(), seg_low)
|
|
1472
|
+
if hit:
|
|
1473
|
+
return hit
|
|
1474
|
+
return None
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
_SECRET_SET_RE = re.compile(
|
|
1478
|
+
r'^(vercel env (add|rm|remove|update)'
|
|
1479
|
+
r'|supabase secrets (set|unset)'
|
|
1480
|
+
r'|wrangler (versions )?secret (put|delete|bulk)'
|
|
1481
|
+
r'|gh variable (set|delete)'
|
|
1482
|
+
r'|(fly|flyctl) secrets (set|unset|import)'
|
|
1483
|
+
r'|heroku config:(set|unset)'
|
|
1484
|
+
r'|netlify env:(set|unset|import))( |$)')
|
|
1485
|
+
|
|
1486
|
+
# GitHub が「CI を飛ばす」と読む書き方(コミットの説明に入った時点で効く)。
|
|
1487
|
+
_SKIP_CI_RE = re.compile(
|
|
1488
|
+
r'\[\s*(skip ci|ci skip|no ci|skip actions|actions skip)\s*\]|skip-checks\s*:\s*true',
|
|
1489
|
+
re.IGNORECASE)
|
|
1490
|
+
|
|
1491
|
+
_SKIP_PERMISSION_FLAGS = frozenset({
|
|
1492
|
+
'--dangerously-skip-permissions', '--allow-dangerously-skip-permissions',
|
|
1493
|
+
})
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _bypass_segment_danger(seg, tokens, cwd, command):
|
|
1497
|
+
"""1セグメントの抜け道(main 直 push 以外)。"""
|
|
1498
|
+
words = _tool_words(tokens)
|
|
1499
|
+
if not words:
|
|
1500
|
+
return None
|
|
1501
|
+
seg_low = seg.lower()
|
|
1502
|
+
hit = _gh_api_danger(words, tokens, seg_low) or _http_github_danger(words, tokens, seg_low)
|
|
1503
|
+
if hit:
|
|
1504
|
+
return hit
|
|
1505
|
+
joined = ' '.join(words)
|
|
1506
|
+
if _SECRET_SET_RE.match(joined):
|
|
1507
|
+
return f'秘密の値の設定・削除 ({" ".join(words[:3])})'
|
|
1508
|
+
if words[:3] == ['gh', 'repo', 'delete']:
|
|
1509
|
+
return 'リポの削除 (gh repo delete)'
|
|
1510
|
+
if words[0] == 'git':
|
|
1511
|
+
if '--no-verify' in tokens:
|
|
1512
|
+
return 'git --no-verify (手元の検査の省略)'
|
|
1513
|
+
sub = next((w for w in words[1:] if w != 'git'), '')
|
|
1514
|
+
if sub == 'commit' and '-n' in tokens:
|
|
1515
|
+
return 'git commit -n (手元の検査の省略)'
|
|
1516
|
+
if any(t.lower().startswith('core.hookspath=') for t in tokens):
|
|
1517
|
+
return 'git -c core.hooksPath (手元の検査の差し替え)'
|
|
1518
|
+
if sub == 'commit' and _SKIP_CI_RE.search(command):
|
|
1519
|
+
return 'コミットの説明に [skip ci] 類 (CI の省略)'
|
|
1520
|
+
if words[:3] == ['gh', 'pr', 'merge'] and _SKIP_CI_RE.search(command):
|
|
1521
|
+
return '合流の説明に [skip ci] 類 (CI の省略)'
|
|
1522
|
+
if words[0] in ('claude', 'claude-code'):
|
|
1523
|
+
if any(t in _SKIP_PERMISSION_FLAGS for t in tokens):
|
|
1524
|
+
return 'claude --dangerously-skip-permissions (安全設定の省略)'
|
|
1525
|
+
for k, t in enumerate(tokens):
|
|
1526
|
+
v = t.split('=', 1)[1] if t.startswith('--permission-mode=') else (
|
|
1527
|
+
tokens[k + 1] if t == '--permission-mode' and k + 1 < len(tokens) else None)
|
|
1528
|
+
if v and v.lower() == 'bypasspermissions':
|
|
1529
|
+
return 'claude --permission-mode bypassPermissions (安全設定の省略)'
|
|
1530
|
+
return None
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _bypass_scan(command: str, cwd=None):
|
|
1534
|
+
"""コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。"""
|
|
1535
|
+
here = cwd
|
|
1536
|
+
for seg in split_segments(command):
|
|
1537
|
+
toks = tokens_of(seg)
|
|
1538
|
+
v = verb_of(toks)
|
|
1539
|
+
if v == 'cd':
|
|
1540
|
+
after = toks[toks.index('cd') + 1:] if 'cd' in toks else []
|
|
1541
|
+
dest = next((t for t in after if t != '--'), '~')
|
|
1542
|
+
p = os.path.expanduser(dest)
|
|
1543
|
+
if dest == '-' or any(c in dest for c in '$`*?') or here is _UNKNOWN_DIR:
|
|
1544
|
+
here = _UNKNOWN_DIR
|
|
1545
|
+
elif os.path.isabs(p):
|
|
1546
|
+
here = p
|
|
1547
|
+
else:
|
|
1548
|
+
here = os.path.join(here or os.getcwd(), p)
|
|
1549
|
+
continue
|
|
1550
|
+
hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
|
|
1551
|
+
if hit:
|
|
1552
|
+
return hit
|
|
1553
|
+
return None
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1557
|
+
# 本番 DB の直接変更(★えふさん確定② 2026-09-18)
|
|
1558
|
+
#
|
|
1559
|
+
# 【いまは DENY】判定は _prod_db_change の1か所、決定は PROD_DB_CHANGE_DECISION の1か所。
|
|
1560
|
+
# 対象は、今の設定の deny(ホーム+全20現場に共通)と同じ道具に揃えた。
|
|
1561
|
+
# 【あとで ASK へ移すとき】「職人が本番の鍵を持たない+人が承認した専用の経路」が完成したら、
|
|
1562
|
+
# PROD_DB_CHANGE_DECISION を GATE へ差し替える(1行)。判定の中身はそのまま使える。
|
|
1563
|
+
# GATE にすると関門 db(管制の2択)として返る(試験 test_policy の (p2) で固定)。
|
|
1564
|
+
# ★差し替えるときは、設定の deny(ホーム・各現場)も人の手番で外す。外さないと、番人が
|
|
1565
|
+
# 「はい」を受けても設定の deny で止まる(便 df4accf8 の重大発見2)。
|
|
1566
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1567
|
+
PROD_DB_CHANGE_DECISION = DENY
|
|
1568
|
+
|
|
1569
|
+
# ★頭の語は道具の名前と【完全に一致】するときだけ当てる(`psql"` のような文字列の切れ端を拾わない)。
|
|
1570
|
+
_PROD_DB_CHANGE_RE = re.compile(
|
|
1571
|
+
r'^(psql|dropdb)( |$)'
|
|
1572
|
+
r'|^prisma (db push|migrate (deploy|reset))( |$)'
|
|
1573
|
+
r'|^supabase (db push|migration up)( |$)'
|
|
1574
|
+
r'|^drizzle-kit push( |$)')
|
|
1575
|
+
# npm / pnpm / yarn 自身の台本の実行(`npm run migrate` / `pnpm migrate:prod`)。
|
|
1576
|
+
_PKG_MIGRATE_RE = re.compile(r'^(npm|pnpm|yarn) ((run|run-script) )?migrate')
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _prod_db_change(seg, tokens):
|
|
1580
|
+
"""1セグメントが本番 DB を直接変える道具なら理由を返す。"""
|
|
1581
|
+
words = _tool_words(tokens)
|
|
1582
|
+
if not words:
|
|
1583
|
+
return None
|
|
1584
|
+
joined = ' '.join(words)
|
|
1585
|
+
if _PROD_DB_CHANGE_RE.match(joined):
|
|
1586
|
+
return f'本番 DB の直接変更 ({" ".join(words[:3])})'
|
|
1587
|
+
if _PKG_MIGRATE_RE.match(joined):
|
|
1588
|
+
return f'本番 DB の直接変更 ({" ".join(words[:3])})'
|
|
1589
|
+
if words[0] in _HTTP_VERBS and 'rest/v1/rpc/exec_sql' in seg.lower():
|
|
1590
|
+
return '本番 DB の直接変更 (REST の exec_sql)'
|
|
1591
|
+
return None
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _prod_db_change_scan(command: str, depth: int = 0):
|
|
1595
|
+
for seg in split_segments(command):
|
|
1596
|
+
hit = _prod_db_change(seg, tokens_of(seg))
|
|
1597
|
+
if hit:
|
|
1598
|
+
return hit
|
|
1599
|
+
if depth < _UNWRAP_MAX_DEPTH:
|
|
1600
|
+
for kind, runner, inner in _inner_payloads(command):
|
|
1601
|
+
if kind == 'shell' and inner and inner.strip():
|
|
1602
|
+
hit = _prod_db_change_scan(inner, depth + 1)
|
|
1603
|
+
if hit:
|
|
1604
|
+
return f'{hit} (inside {runner})'
|
|
1605
|
+
return None
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _prod_db_change_result(reason):
|
|
1609
|
+
if PROD_DB_CHANGE_DECISION == GATE:
|
|
1610
|
+
return _r(GATE, f'gate(db): {reason}', GATE_DB)
|
|
1611
|
+
return _r(DENY, f'danger: {reason}')
|
|
1612
|
+
|
|
1613
|
+
|
|
1071
1614
|
# 危険でない作業フォルダの絶対パス先頭成分(/tmp/xxx は限定削除として通す)。
|
|
1072
1615
|
_SAFE_ABS_TOP = frozenset({'tmp'})
|
|
1073
1616
|
|
|
@@ -1196,62 +1739,120 @@ _SQL_TEXT_ONLY_VERBS = frozenset({
|
|
|
1196
1739
|
})
|
|
1197
1740
|
|
|
1198
1741
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1742
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1743
|
+
# ★関門は「実際に動く道具」で決める(便 ad6b5db9・えふさん確定 2026-09-18)
|
|
1744
|
+
#
|
|
1745
|
+
# 【直したずれ(便 30879701 の実機)】
|
|
1746
|
+
# ・本物の本番反映 `pnpm --filter <部品> exec wrangler deploy --env production` が素通りした
|
|
1747
|
+
# (頭の pnpm --filter … exec を剥がさず、wrangler deploy も関門に入っていなかった)。
|
|
1748
|
+
# ・報告をヒアドキュメントで書くだけの Bash が、本文の「反映」の文字で関門に当たり8分止まった。
|
|
1749
|
+
# 【どう決めるか】
|
|
1750
|
+
# 1. split_commands で本物の区切りだけで割る(引用符・ヒアドキュメントの中は割らない)。
|
|
1751
|
+
# 2. 頭の前置き(pnpm --filter … exec・pnpm exec・npm exec・npx・yarn・bunx・env 変数=…・time・
|
|
1752
|
+
# nohup・xargs 等)を剥がして、実際に動く道具の語の並び(_tool_words)を見る。
|
|
1753
|
+
# 3. 文字を扱うだけの道具(echo・cat・tee・grep・rg・git・gh …)は関門にしない。
|
|
1754
|
+
# その引数・ヒアドキュメントの本文に何が書かれていても、実行はされないため。
|
|
1755
|
+
# 4. シェル(bash -c "…" / bash <<EOF)は、中身を同じ物差しで測り直す。
|
|
1756
|
+
# 5. SQL の字面は「実行しうる入口」のときだけ見る=DB の道具・移行の道具・その場で走らせる形
|
|
1757
|
+
# (python3 -c・node -e・python3 - <<EOF・DB の道具へ流す形)・知らない実行ファイル。
|
|
1758
|
+
# ★止める側(deny)はこの判定の前に済んでいて、ここでは1件も変えない。
|
|
1759
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1760
|
+
_DB_CLIENTS = frozenset({'psql', 'mysql', 'mongosh', 'dropdb', 'createdb', 'pg_restore', 'pg_dump'})
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def _gate_release(words, tokens):
|
|
1764
|
+
"""本番反映の関門(実際に動く道具の語の並びで見る)。"""
|
|
1765
|
+
if not words:
|
|
1766
|
+
return None
|
|
1767
|
+
v = words[0]
|
|
1768
|
+
if v == 'vercel':
|
|
1221
1769
|
if '--prod' in tokens or '--production' in tokens:
|
|
1222
1770
|
return 'vercel --prod'
|
|
1223
|
-
for
|
|
1224
|
-
if
|
|
1225
|
-
return f'vercel {
|
|
1226
|
-
if
|
|
1227
|
-
|
|
1228
|
-
|
|
1771
|
+
for w in words[1:]:
|
|
1772
|
+
if w in ('promote', 'alias', 'rollback'):
|
|
1773
|
+
return f'vercel {w}'
|
|
1774
|
+
if v == 'wrangler' and '--dry-run' not in tokens and (
|
|
1775
|
+
words[1:2] in (['deploy'], ['publish'])
|
|
1776
|
+
or words[1:3] in (['versions', 'deploy'], ['pages', 'deploy'])):
|
|
1777
|
+
return 'wrangler ' + (' '.join(words[1:3]) if words[1] in ('versions', 'pages') else words[1])
|
|
1778
|
+
if v in _PKG_MANAGERS and 'publish' in words[1:3]:
|
|
1779
|
+
return f'{v} publish'
|
|
1780
|
+
if words[:3] == ['supabase', 'functions', 'deploy']:
|
|
1229
1781
|
return 'supabase functions deploy'
|
|
1230
|
-
# 「deploy
|
|
1231
|
-
if
|
|
1782
|
+
# 「deploy の語」+「本番の指定」の組み合わせ(新しい道具にも効く一般形)。
|
|
1783
|
+
if 'deploy' in words and ('--prod' in tokens or '--production' in tokens):
|
|
1232
1784
|
return 'deploy --prod'
|
|
1233
1785
|
return None
|
|
1234
1786
|
|
|
1235
1787
|
|
|
1236
|
-
def _gate_db(
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1788
|
+
def _gate_db(words, tokens, segment: str):
|
|
1789
|
+
"""DB 実行の関門(実際に動く道具の語の並びで見る)。"""
|
|
1790
|
+
if not words:
|
|
1791
|
+
return None
|
|
1792
|
+
v = words[0]
|
|
1793
|
+
if v in _DB_CLIENTS:
|
|
1794
|
+
return f'database client ({v})'
|
|
1795
|
+
joined = ' '.join(words)
|
|
1796
|
+
if re.match(r'prisma (migrate|db)( |$)', joined):
|
|
1240
1797
|
return 'prisma migrate/db'
|
|
1241
|
-
if re.
|
|
1798
|
+
if re.match(r'supabase (db (push|reset|query|execute)|migration up)( |$)', joined):
|
|
1242
1799
|
return 'supabase db/migration'
|
|
1243
|
-
if re.
|
|
1800
|
+
if re.match(r'drizzle-kit (push|migrate)( |$)', joined):
|
|
1244
1801
|
return 'drizzle-kit push/migrate'
|
|
1245
|
-
# SQL
|
|
1246
|
-
#
|
|
1247
|
-
|
|
1248
|
-
# 一方、psql/mysql への `| psql` は分割後に psql セグメントが別途上で関門に当たり、
|
|
1249
|
-
# run-sql / apply のような未知のSQL実行入口は safe 集合に無いのでここで関門に落ちる。
|
|
1250
|
-
if verb not in _SQL_TEXT_ONLY_VERBS and _SQL_GATE_RE.search(segment):
|
|
1802
|
+
# SQL の字面(DDL / GRANT / RLS)。★ここへ来るのは「実行しうる入口」だけ
|
|
1803
|
+
# (文字を扱うだけの道具とシェルは _gate_one で先に振り分け済み)。
|
|
1804
|
+
if _SQL_GATE_RE.search(segment):
|
|
1251
1805
|
return 'SQL DDL/GRANT/RLS'
|
|
1252
1806
|
return None
|
|
1253
1807
|
|
|
1254
1808
|
|
|
1809
|
+
def _shell_payloads(head_tokens, body):
|
|
1810
|
+
"""シェルに渡された中身(-c の文字列・ヒアドキュメントの本文)。"""
|
|
1811
|
+
out = []
|
|
1812
|
+
for k, t in enumerate(head_tokens):
|
|
1813
|
+
if t in _CODE_FLAGS and k + 1 < len(head_tokens):
|
|
1814
|
+
out.append(head_tokens[k + 1])
|
|
1815
|
+
if body.strip():
|
|
1816
|
+
out.append(body)
|
|
1817
|
+
return out
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def _gate_one(segment: str, depth: int):
|
|
1821
|
+
"""本物の1コマンドの関門。返り (種別, 理由) / None。"""
|
|
1822
|
+
head, _, body = segment.partition('\n')
|
|
1823
|
+
toks = tokens_of(head)
|
|
1824
|
+
words = _tool_words(toks)
|
|
1825
|
+
if not words:
|
|
1826
|
+
return None
|
|
1827
|
+
v = words[0]
|
|
1828
|
+
if v in _SQL_TEXT_ONLY_VERBS:
|
|
1829
|
+
return None # 文字を扱うだけ。引数・本文に何が書かれていても実行されない。
|
|
1830
|
+
if v in _SHELL_RUNNERS:
|
|
1831
|
+
if depth >= _UNWRAP_MAX_DEPTH:
|
|
1832
|
+
return None
|
|
1833
|
+
for inner in _shell_payloads(toks, body):
|
|
1834
|
+
hit = _gate_command(inner, depth + 1)
|
|
1835
|
+
if hit:
|
|
1836
|
+
return hit
|
|
1837
|
+
return None
|
|
1838
|
+
hit = _gate_release(words, toks)
|
|
1839
|
+
if hit:
|
|
1840
|
+
return GATE_RELEASE, hit
|
|
1841
|
+
hit = _gate_db(words, toks, segment)
|
|
1842
|
+
if hit:
|
|
1843
|
+
return GATE_DB, hit
|
|
1844
|
+
return None
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def _gate_command(command: str, depth: int = 0):
|
|
1848
|
+
"""コマンド全体の関門(最初に当たったもの)。返り (種別, 理由) / None。"""
|
|
1849
|
+
for seg in split_commands(command):
|
|
1850
|
+
hit = _gate_one(seg, depth)
|
|
1851
|
+
if hit:
|
|
1852
|
+
return hit
|
|
1853
|
+
return None
|
|
1854
|
+
|
|
1855
|
+
|
|
1255
1856
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
1256
1857
|
# 入口: Bash
|
|
1257
1858
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
@@ -1294,20 +1895,21 @@ def decide_bash(command: str, cwd=None):
|
|
|
1294
1895
|
if hit:
|
|
1295
1896
|
return _r(DENY, f'danger: {hit}')
|
|
1296
1897
|
|
|
1297
|
-
# ----
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1898
|
+
# ---- 1d) 抜け道(便 fc22bc34・main 直 push・保護の迂回と変更・秘密の値・検査の省略) ----
|
|
1899
|
+
hit = _bypass_scan(command, cwd)
|
|
1900
|
+
if hit:
|
|
1901
|
+
return _r(DENY, f'danger: {hit}')
|
|
1902
|
+
|
|
1903
|
+
# ---- 1e) 本番 DB の直接変更(★判定の置き場は _prod_db_change の1か所・決定は下の定数) ----
|
|
1904
|
+
hit = _prod_db_change_scan(command)
|
|
1905
|
+
if hit:
|
|
1906
|
+
return _prod_db_change_result(hit)
|
|
1907
|
+
|
|
1908
|
+
# ---- 2) 関門(★本物の区切りと、実際に動く道具で見る・便 ad6b5db9) ----
|
|
1909
|
+
hit = _gate_command(command)
|
|
1910
|
+
if hit:
|
|
1911
|
+
gate, reason = hit
|
|
1912
|
+
return _r(GATE, f'gate({gate}): {reason}', gate)
|
|
1311
1913
|
|
|
1312
1914
|
# ---- 3) それ以外は自走 ----
|
|
1313
1915
|
return _r(ALLOW, 'no gate, no danger')
|
|
@@ -1550,6 +2152,10 @@ def decide_event(data):
|
|
|
1550
2152
|
return decide_read(fp, data.get('cwd'))
|
|
1551
2153
|
if tool in SEARCH_TOOLS:
|
|
1552
2154
|
return decide_search(tin, data.get('cwd'), tool)
|
|
2155
|
+
# ★保護の変更(便 fc22bc34): つなぎ先の道具で本番のデプロイの保護を変える形。
|
|
2156
|
+
# 強める向きも含めて DENY(字面では強める・弱めるを見分けられない)。
|
|
2157
|
+
if isinstance(tool, str) and re.search(r'(^|__)update_project_deployment_protection$', tool):
|
|
2158
|
+
return _r(DENY, 'danger: 保護の変更 (デプロイの保護・つなぎ先の道具)')
|
|
1553
2159
|
# ★知らない道具(MCP・将来増える組み込み)も、入力にパスらしい項目があれば同じ判定を通す。
|
|
1554
2160
|
# 当たらなければ従来どおり自走する(WebSearch・道具の呼び出し等は1つも止まらない)。
|
|
1555
2161
|
return decide_any_path(tin, data.get('cwd'))
|