@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.
@@ -10,11 +10,15 @@
10
10
 
11
11
  【この版の判定(上から順に1つだけ当たる)】
12
12
  1. 危険 → deny … 本番破壊・force push・データ消失・認証情報の露出/持ち出し
13
- 2. 3関門 → gate … main直変更 / 本番確認 / DB実行(人が必ず判断する)
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完全自動化)。詳しくは _gate_merge の説明。
17
- ただし保護を迂回する `gh pr merge --admin` は危険(deny)。詳しくは _admin_merge_danger の説明。
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 の中身は「PRのマージ」から「main への直接 push」へ縮んだ(_gate_merge 参照)。
42
- # 種別の字面 'merge' は管制の表示・集計が使っているので変えない。
43
- GATE_MERGE = 'merge' # 関門1 本番ブランチの直接変更
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
- def _gate_merge(low: str, verb: str, tokens):
1200
- """関門1: 本番ブランチ(main 等)を【PRを通さずに】直接書き換える操作。
1201
-
1202
- ★PRのマージ(`gh pr merge` / `gh api …/pulls/N/merge`)は関門から外した(案B完全自動化)。
1203
- 理由: マージを関門にすると、職人は最後の1手で必ず人を待つことになり、
1204
- 「指示を出したら終わりまで進む」が成立しなかった。マージはPRという記録が残り、
1205
- レビューとCIの結果が付いた上での操作なので、人が毎回押す価値より止まる損の方が大きい。
1206
- ★外したのはマージだけ。main への【直接 push】は関門のまま残す。こちらはPRもレビューも
1207
- CIも通らずに本番の元が変わるため、性質がまったく違う(外すと歯止めが無くなる)。
1208
- """
1209
- if verb == 'git' and len(tokens) >= 2 and tokens[1] == 'push':
1210
- for t in tokens[2:]:
1211
- if t.startswith('-'):
1212
- continue
1213
- ref = t.split(':')[-1].strip()
1214
- if ref in PROTECTED_BRANCHES:
1215
- return f'git push to protected branch ({ref})'
1216
- return None
1217
-
1218
-
1219
- def _gate_release(low: str, verb: str, tokens):
1220
- if verb == 'vercel' or (verb == 'npx' and 'vercel' in tokens[:3]):
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 t in tokens:
1224
- if t in ('promote', 'alias', 'rollback'):
1225
- return f'vercel {t}'
1226
- if verb in ('npm', 'pnpm', 'yarn') and 'publish' in tokens[1:3]:
1227
- return f'{verb} publish'
1228
- if re.search(r'\bsupabase\s+functions\s+deploy\b', low):
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 re.search(r'\bdeploy\b', low) and re.search(r'--prod(uction)?\b', low):
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(low: str, verb: str, tokens, segment: str):
1237
- if verb in ('psql', 'mysql', 'mongosh', 'dropdb', 'createdb', 'pg_restore', 'pg_dump'):
1238
- return f'database client ({verb})'
1239
- if re.search(r'\bprisma\s+(migrate|db)\b', low):
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.search(r'\bsupabase\s+(db\s+(push|reset|query|execute)|migration\s+up)\b', low):
1798
+ if re.match(r'supabase (db (push|reset|query|execute)|migration up)( |$)', joined):
1242
1799
  return 'supabase db/migration'
1243
- if re.search(r'\bdrizzle-kit\s+(push|migrate)\b', low):
1800
+ if re.match(r'drizzle-kit (push|migrate)( |$)', joined):
1244
1801
  return 'drizzle-kit push/migrate'
1245
- # SQL 文字列(DDL / GRANT / RLS)を根拠にした関門。
1246
- # ★「実際にSQLを実行しうる入口」に限る。文字列を扱うだけの動詞(echo / grep / git …)は
1247
- # 引数にSQLの字面があっても実行しないので関門にしない(=誤検知を消す・緩める変更)。
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
- # ---- 2) 3関門 ----
1298
- for seg in segments:
1299
- toks = tokens_of(seg)
1300
- v = verb_of(toks)
1301
- seg_low = seg.lower()
1302
- hit = _gate_merge(seg_low, v, toks)
1303
- if hit:
1304
- return _r(GATE, f'gate(merge): {hit}', GATE_MERGE)
1305
- hit = _gate_release(seg_low, v, toks)
1306
- if hit:
1307
- return _r(GATE, f'gate(release): {hit}', GATE_RELEASE)
1308
- hit = _gate_db(seg_low, v, toks, seg)
1309
- if hit:
1310
- return _r(GATE, f'gate(db): {hit}', GATE_DB)
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'))