@tyhld/conductor 0.12.17 → 0.12.19

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
 
@@ -706,6 +711,10 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
706
711
  or _recursive_read_danger(v, toks, cwd) or _admin_merge_danger(v, toks))
707
712
  if hit:
708
713
  return f'{hit} ({where})'
714
+ # ★抜け道も包みの中で測り直す(`bash -c "git push origin main"` を素通りさせない)。
715
+ hit = _bypass_scan(inner, cwd)
716
+ if hit:
717
+ return f'{hit} ({where})'
709
718
  hit = _wrapper_danger(inner, depth + 1, cwd)
710
719
  if hit:
711
720
  return hit
@@ -1068,6 +1077,411 @@ def _admin_merge_danger(verb: str, tokens):
1068
1077
  return None
1069
1078
 
1070
1079
 
1080
+ # ═════════════════════════════════════════════════════════════════════════════
1081
+ # 抜け道(DENY・便 fc22bc34・えふさん確定 2026-09-18)
1082
+ #
1083
+ # 【考え方】ALLOW=普通の開発・練習環境/ASK=正当だが実世界に影響する操作/
1084
+ # DENY=職人が行う必要が無い抜け道(main 直 push・本番 DB の直接変更・秘密の値の操作・
1085
+ # 保護の変更・検査の省略・迂回による保護回避)。ここは DENY の側。
1086
+ # 【字面だけでは止めない】どれも「実際に動く道具の語の並び」で見る。コミットの説明や PR の本文に
1087
+ # 同じ語が書かれているだけの操作は止めない(動詞と、その後ろの語の並びで決める)。
1088
+ # ★例外は [skip ci] 類: コミットの説明に書かれた時点で GitHub が CI を飛ばす(本物の省略)。
1089
+ # ═════════════════════════════════════════════════════════════════════════════
1090
+
1091
+ # npx / pnpm dlx などの「道具を呼ぶための前置き」。取り除いて道具の語の並びを見る。
1092
+ _TOOL_RUNNERS = frozenset({'npx', 'pnpx', 'bunx'})
1093
+ _PKG_MANAGERS = frozenset({'npm', 'pnpm', 'yarn'})
1094
+ _PKG_EXEC_WORDS = frozenset({'exec', 'dlx', 'x'})
1095
+ # 前置きの後ろに直接書かれる道具(`pnpm vercel env add` の形)。
1096
+ _KNOWN_TOOLS = frozenset({
1097
+ 'vercel', 'supabase', 'wrangler', 'prisma', 'drizzle-kit', 'fly', 'flyctl', 'heroku',
1098
+ 'netlify', 'gh', 'psql', 'dropdb', 'claude',
1099
+ })
1100
+ # 値を1つ取る旗(語の並びから値ごと外す)。
1101
+ _VALUE_FLAGS = frozenset({
1102
+ '--scope', '-S', '--token', '-t', '--cwd', '--project-ref', '--app', '-a', '--config',
1103
+ '-c', '-C', '--account-id', '--site', '--env', '-e', '--repo', '-R', '--schema',
1104
+ })
1105
+
1106
+ # 作業場所が変数などで決まり、確かめられないことを表す印(None=「指定なし=今の場所」とは別)。
1107
+ _UNKNOWN_DIR = object()
1108
+
1109
+
1110
+ def _tool_words(tokens):
1111
+ """道具の語の並び(前置き・変数代入・旗を外し、小文字にしたもの)。無ければ []。"""
1112
+ i = 0
1113
+ while i < len(tokens):
1114
+ t = tokens[i]
1115
+ if _ASSIGN_RE.match(t):
1116
+ i += 1
1117
+ continue
1118
+ base = t.split('/')[-1]
1119
+ if base in _WRAPPERS:
1120
+ i += 1
1121
+ while i < len(tokens) and tokens[i].startswith('-'):
1122
+ i += 1
1123
+ if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
1124
+ and not tokens[i].startswith('-'):
1125
+ i += 1
1126
+ continue
1127
+ break
1128
+ rest = tokens[i:]
1129
+ if not rest:
1130
+ return []
1131
+ head = rest[0].split('/')[-1].lower()
1132
+ if head in _TOOL_RUNNERS or head in _PKG_MANAGERS:
1133
+ j = 1
1134
+ while j < len(rest) and (rest[j].startswith('-') or rest[j].lower() in _PKG_EXEC_WORDS):
1135
+ j += 1
1136
+ nxt = rest[j].split('/')[-1].lower() if j < len(rest) else ''
1137
+ if head in _TOOL_RUNNERS or rest[1:2] and rest[1].lower() in _PKG_EXEC_WORDS \
1138
+ or nxt in _KNOWN_TOOLS:
1139
+ rest = rest[j:]
1140
+ words = []
1141
+ skip = False
1142
+ for k, t in enumerate(rest):
1143
+ if skip:
1144
+ skip = False
1145
+ continue
1146
+ if k > 0 and t.startswith('-'):
1147
+ if t in _VALUE_FLAGS:
1148
+ skip = True
1149
+ continue
1150
+ words.append(t.split('/')[-1].lower() if k == 0 else t.lower())
1151
+ return words
1152
+
1153
+
1154
+ def _current_branch_targets(cwd):
1155
+ """cwd のリポで、引数なしの push が書き込む先のブランチ名の集合。確かめられなければ None。
1156
+
1157
+ ★今のブランチ(rev-parse --abbrev-ref HEAD)と、設定された push 先(@{push})の両方を見る。
1158
+ 切り離された HEAD・git が無い・遅い(5秒)・cwd が分からない、は確かめられない=None。
1159
+ """
1160
+ try:
1161
+ import subprocess
1162
+ def out(args):
1163
+ r = subprocess.run(['git'] + args, cwd=cwd or None, capture_output=True,
1164
+ text=True, timeout=5)
1165
+ return r.stdout.strip() if r.returncode == 0 else None
1166
+ head = out(['rev-parse', '--abbrev-ref', 'HEAD'])
1167
+ if not head or head == 'HEAD':
1168
+ return None
1169
+ targets = {head}
1170
+ push = out(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{push}'])
1171
+ if push and '/' in push:
1172
+ targets.add(push.split('/', 1)[1])
1173
+ return targets
1174
+ except Exception: # noqa: BLE001
1175
+ return None
1176
+
1177
+
1178
+ def _main_push_danger(tokens, cwd):
1179
+ """main など保護ブランチへの直接 push(★便 fc22bc34 で関門から DENY へ・えふさん確定①)。
1180
+
1181
+ ★通常は PR 経由に統一する。職人が main へ直接 push する必要は無い。
1182
+ ★書き込む先を書かない形(`git push` / `git push origin` / `git push origin HEAD`)は、
1183
+ 今のブランチを git で確かめる。確かめられないとき(切り離された HEAD・git が答えない・
1184
+ 作業場所が変数で決まる)は DENY に倒す=main へ書く可能性を消せないものは通さない。
1185
+ ★`--all` / `--mirror` / `--branches` は全部のブランチを書くので DENY。
1186
+ """
1187
+ words = _tool_words(tokens)
1188
+ if not words or words[0] != 'git':
1189
+ return None
1190
+ try:
1191
+ start = [t.split('/')[-1] for t in tokens].index('git')
1192
+ except ValueError:
1193
+ return None
1194
+ # git の前置きの旗(-C 場所 / -c 設定)を読む。
1195
+ where = cwd
1196
+ args = list(tokens[start + 1:])
1197
+ while args and args[0].startswith('-'):
1198
+ flag = args.pop(0)
1199
+ if flag == '-C' and args:
1200
+ p = args.pop(0)
1201
+ if any(c in p for c in '$`') or where is _UNKNOWN_DIR:
1202
+ where = _UNKNOWN_DIR
1203
+ else:
1204
+ p = os.path.expanduser(p)
1205
+ where = p if os.path.isabs(p) else os.path.join(where or os.getcwd(), p)
1206
+ elif flag == '-c' and args:
1207
+ args.pop(0)
1208
+ if not args or args[0] != 'push':
1209
+ return None
1210
+ push_args = args[1:]
1211
+ positional = []
1212
+ it = iter(push_args)
1213
+ for t in it:
1214
+ if t in ('--all', '--mirror', '--branches'):
1215
+ return f'git push {t} (全部のブランチ=保護ブランチも書く)'
1216
+ if t in ('--repo', '-o', '--push-option', '--receive-pack', '--exec'):
1217
+ next(it, None)
1218
+ continue
1219
+ if t.startswith('-'):
1220
+ continue
1221
+ positional.append(t)
1222
+ refspecs = positional[1:]
1223
+ needs_head = not refspecs
1224
+ for spec in refspecs:
1225
+ spec = spec.lstrip('+')
1226
+ src, _, dst = spec.partition(':')
1227
+ target = dst if dst else src
1228
+ if target in ('HEAD', '@'):
1229
+ needs_head = True
1230
+ continue
1231
+ name = target.removeprefix('refs/heads/')
1232
+ if name in PROTECTED_BRANCHES:
1233
+ return f'git push to protected branch ({name})'
1234
+ if needs_head:
1235
+ if where is _UNKNOWN_DIR:
1236
+ return 'git push (書き込む先のブランチを確かめられない=作業場所が変数)'
1237
+ targets = _current_branch_targets(where)
1238
+ if targets is None:
1239
+ return 'git push (書き込む先のブランチを確かめられない)'
1240
+ hit = sorted(targets & PROTECTED_BRANCHES)
1241
+ if hit:
1242
+ return f'git push to protected branch ({hit[0]}・今のブランチ)'
1243
+ return None
1244
+
1245
+
1246
+ # GitHub の API の道筋(書き込み)。
1247
+ _API_MERGE_RE = re.compile(r'(^|/)pulls/[^/]+/merge/?$')
1248
+ _API_PROTECTION_RE = re.compile(
1249
+ r'/branches/[^/]+/protection(/|$)|(^|/)rulesets(/|$)|/environments/[^/]+/?$'
1250
+ r'|/deployment-branch-policies(/|$)|/deployment_protection_rules(/|$)')
1251
+ _API_SECRET_RE = re.compile(
1252
+ r'/(actions|dependabot|codespaces|agents)/(secrets|variables)(/|$)'
1253
+ r'|/environments/[^/]+/(secrets|variables)(/|$)')
1254
+ _API_REPO_RE = re.compile(r'^repos/[^/]+/[^/]+/?$')
1255
+
1256
+
1257
+ def _github_api_write(endpoint: str, method: str, body_low: str):
1258
+ """GitHub の API への書き込み1件が抜け道なら理由を返す(読み取りは通す)。"""
1259
+ ep = re.sub(r'^https?://api\.github\.com/', '', endpoint.strip()).lstrip('/').split('?')[0]
1260
+ if method in ('GET', 'HEAD'):
1261
+ return None
1262
+ if _API_MERGE_RE.search(ep) and method in ('PUT', 'POST'):
1263
+ return 'API での合流 (pulls/N/merge・保護を迂回する合流)'
1264
+ if ep.rstrip('/') == 'graphql' and 'mergepullrequest' in body_low:
1265
+ return 'GraphQL での合流 (mergePullRequest・保護を迂回する合流)'
1266
+ if _API_PROTECTION_RE.search(ep):
1267
+ return f'保護の変更 ({method} {ep})'
1268
+ if _API_SECRET_RE.search(ep):
1269
+ return f'秘密の値・変数の変更 ({method} {ep})'
1270
+ if _API_REPO_RE.match(ep) and method == 'DELETE':
1271
+ return 'リポの削除 (API)'
1272
+ return None
1273
+
1274
+
1275
+ _GH_API_VALUE_FLAGS = frozenset({
1276
+ '-X', '--method', '-H', '--header', '-f', '--raw-field', '-F', '--field', '--input',
1277
+ '-q', '--jq', '-t', '--template', '--hostname', '-p', '--preview', '--cache',
1278
+ })
1279
+ _GH_API_BODY_FLAGS = frozenset({'-f', '--raw-field', '-F', '--field', '--input'})
1280
+
1281
+
1282
+ def _gh_api_danger(words, tokens, seg_low):
1283
+ if words[:2] != ['gh', 'api']:
1284
+ return None
1285
+ try:
1286
+ start = [t.split('/')[-1].lower() for t in tokens].index('gh')
1287
+ except ValueError:
1288
+ return None
1289
+ args = tokens[start + 2:]
1290
+ method, endpoint, has_body = None, None, False
1291
+ k = 0
1292
+ while k < len(args):
1293
+ t = args[k]
1294
+ if t.startswith('-X') and len(t) > 2:
1295
+ method = t[2:]
1296
+ elif t.startswith('--method='):
1297
+ method = t.split('=', 1)[1]
1298
+ elif t in _GH_API_VALUE_FLAGS:
1299
+ if t in ('-X', '--method') and k + 1 < len(args):
1300
+ method = args[k + 1]
1301
+ if t in _GH_API_BODY_FLAGS:
1302
+ has_body = True
1303
+ k += 2
1304
+ continue
1305
+ elif any(t.startswith(f + '=') for f in ('--raw-field', '--field', '--input')):
1306
+ has_body = True
1307
+ elif not t.startswith('-') and endpoint is None:
1308
+ endpoint = t
1309
+ k += 1
1310
+ if endpoint is None:
1311
+ return None
1312
+ method = (method or ('POST' if has_body else 'GET')).upper()
1313
+ return _github_api_write(endpoint, method, seg_low)
1314
+
1315
+
1316
+ _HTTP_VERBS = frozenset({'curl', 'wget', 'http', 'https', 'xh'})
1317
+
1318
+
1319
+ def _http_github_danger(words, tokens, seg_low):
1320
+ """curl などで GitHub の API へ直接書き込む形(gh を通さない抜け道)。"""
1321
+ if not words or words[0] not in _HTTP_VERBS:
1322
+ return None
1323
+ urls = [t for t in tokens if 'api.github.com/' in t.lower()]
1324
+ if not urls:
1325
+ return None
1326
+ method = None
1327
+ for k, t in enumerate(tokens):
1328
+ if t in ('-X', '--request', '--method') and k + 1 < len(tokens):
1329
+ method = tokens[k + 1]
1330
+ elif t.startswith('-X') and len(t) > 2:
1331
+ method = t[2:]
1332
+ elif t.startswith('--method='):
1333
+ method = t.split('=', 1)[1]
1334
+ if method is None:
1335
+ has_body = any(t in ('-d', '--data', '--data-raw', '--data-binary', '--json', '-F', '--form')
1336
+ or t.startswith('--data') for t in tokens)
1337
+ method = 'POST' if has_body else 'GET'
1338
+ if words[0] in ('http', 'https', 'xh') and len(words) > 1 \
1339
+ and words[1].upper() in ('PUT', 'POST', 'PATCH', 'DELETE'):
1340
+ method = words[1]
1341
+ for u in urls:
1342
+ hit = _github_api_write(u.split('api.github.com/', 1)[1], method.upper(), seg_low)
1343
+ if hit:
1344
+ return hit
1345
+ return None
1346
+
1347
+
1348
+ _SECRET_SET_RE = re.compile(
1349
+ r'^(vercel env (add|rm|remove|update)'
1350
+ r'|supabase secrets (set|unset)'
1351
+ r'|wrangler (versions )?secret (put|delete|bulk)'
1352
+ r'|gh variable (set|delete)'
1353
+ r'|(fly|flyctl) secrets (set|unset|import)'
1354
+ r'|heroku config:(set|unset)'
1355
+ r'|netlify env:(set|unset|import))( |$)')
1356
+
1357
+ # GitHub が「CI を飛ばす」と読む書き方(コミットの説明に入った時点で効く)。
1358
+ _SKIP_CI_RE = re.compile(
1359
+ r'\[\s*(skip ci|ci skip|no ci|skip actions|actions skip)\s*\]|skip-checks\s*:\s*true',
1360
+ re.IGNORECASE)
1361
+
1362
+ _SKIP_PERMISSION_FLAGS = frozenset({
1363
+ '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions',
1364
+ })
1365
+
1366
+
1367
+ def _bypass_segment_danger(seg, tokens, cwd, command):
1368
+ """1セグメントの抜け道(main 直 push 以外)。"""
1369
+ words = _tool_words(tokens)
1370
+ if not words:
1371
+ return None
1372
+ seg_low = seg.lower()
1373
+ hit = _gh_api_danger(words, tokens, seg_low) or _http_github_danger(words, tokens, seg_low)
1374
+ if hit:
1375
+ return hit
1376
+ joined = ' '.join(words)
1377
+ if _SECRET_SET_RE.match(joined):
1378
+ return f'秘密の値の設定・削除 ({" ".join(words[:3])})'
1379
+ if words[:3] == ['gh', 'repo', 'delete']:
1380
+ return 'リポの削除 (gh repo delete)'
1381
+ if words[0] == 'git':
1382
+ if '--no-verify' in tokens:
1383
+ return 'git --no-verify (手元の検査の省略)'
1384
+ sub = next((w for w in words[1:] if w != 'git'), '')
1385
+ if sub == 'commit' and '-n' in tokens:
1386
+ return 'git commit -n (手元の検査の省略)'
1387
+ if any(t.lower().startswith('core.hookspath=') for t in tokens):
1388
+ return 'git -c core.hooksPath (手元の検査の差し替え)'
1389
+ if sub == 'commit' and _SKIP_CI_RE.search(command):
1390
+ return 'コミットの説明に [skip ci] 類 (CI の省略)'
1391
+ if words[:3] == ['gh', 'pr', 'merge'] and _SKIP_CI_RE.search(command):
1392
+ return '合流の説明に [skip ci] 類 (CI の省略)'
1393
+ if words[0] in ('claude', 'claude-code'):
1394
+ if any(t in _SKIP_PERMISSION_FLAGS for t in tokens):
1395
+ return 'claude --dangerously-skip-permissions (安全設定の省略)'
1396
+ for k, t in enumerate(tokens):
1397
+ v = t.split('=', 1)[1] if t.startswith('--permission-mode=') else (
1398
+ tokens[k + 1] if t == '--permission-mode' and k + 1 < len(tokens) else None)
1399
+ if v and v.lower() == 'bypasspermissions':
1400
+ return 'claude --permission-mode bypassPermissions (安全設定の省略)'
1401
+ return None
1402
+
1403
+
1404
+ def _bypass_scan(command: str, cwd=None):
1405
+ """コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。"""
1406
+ here = cwd
1407
+ for seg in split_segments(command):
1408
+ toks = tokens_of(seg)
1409
+ v = verb_of(toks)
1410
+ if v == 'cd':
1411
+ after = toks[toks.index('cd') + 1:] if 'cd' in toks else []
1412
+ dest = next((t for t in after if t != '--'), '~')
1413
+ p = os.path.expanduser(dest)
1414
+ if dest == '-' or any(c in dest for c in '$`*?') or here is _UNKNOWN_DIR:
1415
+ here = _UNKNOWN_DIR
1416
+ elif os.path.isabs(p):
1417
+ here = p
1418
+ else:
1419
+ here = os.path.join(here or os.getcwd(), p)
1420
+ continue
1421
+ hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
1422
+ if hit:
1423
+ return hit
1424
+ return None
1425
+
1426
+
1427
+ # ─────────────────────────────────────────────────────────────────────────────
1428
+ # 本番 DB の直接変更(★えふさん確定② 2026-09-18)
1429
+ #
1430
+ # 【いまは DENY】判定は _prod_db_change の1か所、決定は PROD_DB_CHANGE_DECISION の1か所。
1431
+ # 対象は、今の設定の deny(ホーム+全20現場に共通)と同じ道具に揃えた。
1432
+ # 【あとで ASK へ移すとき】「職人が本番の鍵を持たない+人が承認した専用の経路」が完成したら、
1433
+ # PROD_DB_CHANGE_DECISION を GATE へ差し替える(1行)。判定の中身はそのまま使える。
1434
+ # GATE にすると関門 db(管制の2択)として返る(試験 test_policy の (p2) で固定)。
1435
+ # ★差し替えるときは、設定の deny(ホーム・各現場)も人の手番で外す。外さないと、番人が
1436
+ # 「はい」を受けても設定の deny で止まる(便 df4accf8 の重大発見2)。
1437
+ # ─────────────────────────────────────────────────────────────────────────────
1438
+ PROD_DB_CHANGE_DECISION = DENY
1439
+
1440
+ # ★頭の語は道具の名前と【完全に一致】するときだけ当てる(`psql"` のような文字列の切れ端を拾わない)。
1441
+ _PROD_DB_CHANGE_RE = re.compile(
1442
+ r'^(psql|dropdb)( |$)'
1443
+ r'|^prisma (db push|migrate (deploy|reset))( |$)'
1444
+ r'|^supabase (db push|migration up)( |$)'
1445
+ r'|^drizzle-kit push( |$)')
1446
+ # npm / pnpm / yarn 自身の台本の実行(`npm run migrate` / `pnpm migrate:prod`)。
1447
+ _PKG_MIGRATE_RE = re.compile(r'^(npm|pnpm|yarn) ((run|run-script) )?migrate')
1448
+
1449
+
1450
+ def _prod_db_change(seg, tokens):
1451
+ """1セグメントが本番 DB を直接変える道具なら理由を返す。"""
1452
+ words = _tool_words(tokens)
1453
+ if not words:
1454
+ return None
1455
+ joined = ' '.join(words)
1456
+ if _PROD_DB_CHANGE_RE.match(joined):
1457
+ return f'本番 DB の直接変更 ({" ".join(words[:3])})'
1458
+ if _PKG_MIGRATE_RE.match(joined):
1459
+ return f'本番 DB の直接変更 ({" ".join(words[:3])})'
1460
+ if words[0] in _HTTP_VERBS and 'rest/v1/rpc/exec_sql' in seg.lower():
1461
+ return '本番 DB の直接変更 (REST の exec_sql)'
1462
+ return None
1463
+
1464
+
1465
+ def _prod_db_change_scan(command: str, depth: int = 0):
1466
+ for seg in split_segments(command):
1467
+ hit = _prod_db_change(seg, tokens_of(seg))
1468
+ if hit:
1469
+ return hit
1470
+ if depth < _UNWRAP_MAX_DEPTH:
1471
+ for kind, runner, inner in _inner_payloads(command):
1472
+ if kind == 'shell' and inner and inner.strip():
1473
+ hit = _prod_db_change_scan(inner, depth + 1)
1474
+ if hit:
1475
+ return f'{hit} (inside {runner})'
1476
+ return None
1477
+
1478
+
1479
+ def _prod_db_change_result(reason):
1480
+ if PROD_DB_CHANGE_DECISION == GATE:
1481
+ return _r(GATE, f'gate(db): {reason}', GATE_DB)
1482
+ return _r(DENY, f'danger: {reason}')
1483
+
1484
+
1071
1485
  # 危険でない作業フォルダの絶対パス先頭成分(/tmp/xxx は限定削除として通す)。
1072
1486
  _SAFE_ABS_TOP = frozenset({'tmp'})
1073
1487
 
@@ -1196,26 +1610,6 @@ _SQL_TEXT_ONLY_VERBS = frozenset({
1196
1610
  })
1197
1611
 
1198
1612
 
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
1613
  def _gate_release(low: str, verb: str, tokens):
1220
1614
  if verb == 'vercel' or (verb == 'npx' and 'vercel' in tokens[:3]):
1221
1615
  if '--prod' in tokens or '--production' in tokens:
@@ -1294,14 +1688,21 @@ def decide_bash(command: str, cwd=None):
1294
1688
  if hit:
1295
1689
  return _r(DENY, f'danger: {hit}')
1296
1690
 
1691
+ # ---- 1d) 抜け道(便 fc22bc34・main 直 push・保護の迂回と変更・秘密の値・検査の省略) ----
1692
+ hit = _bypass_scan(command, cwd)
1693
+ if hit:
1694
+ return _r(DENY, f'danger: {hit}')
1695
+
1696
+ # ---- 1e) 本番 DB の直接変更(★判定の置き場は _prod_db_change の1か所・決定は下の定数) ----
1697
+ hit = _prod_db_change_scan(command)
1698
+ if hit:
1699
+ return _prod_db_change_result(hit)
1700
+
1297
1701
  # ---- 2) 3関門 ----
1298
1702
  for seg in segments:
1299
1703
  toks = tokens_of(seg)
1300
1704
  v = verb_of(toks)
1301
1705
  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
1706
  hit = _gate_release(seg_low, v, toks)
1306
1707
  if hit:
1307
1708
  return _r(GATE, f'gate(release): {hit}', GATE_RELEASE)
@@ -1550,6 +1951,10 @@ def decide_event(data):
1550
1951
  return decide_read(fp, data.get('cwd'))
1551
1952
  if tool in SEARCH_TOOLS:
1552
1953
  return decide_search(tin, data.get('cwd'), tool)
1954
+ # ★保護の変更(便 fc22bc34): つなぎ先の道具で本番のデプロイの保護を変える形。
1955
+ # 強める向きも含めて DENY(字面では強める・弱めるを見分けられない)。
1956
+ if isinstance(tool, str) and re.search(r'(^|__)update_project_deployment_protection$', tool):
1957
+ return _r(DENY, 'danger: 保護の変更 (デプロイの保護・つなぎ先の道具)')
1553
1958
  # ★知らない道具(MCP・将来増える組み込み)も、入力にパスらしい項目があれば同じ判定を通す。
1554
1959
  # 当たらなければ従来どおり自走する(WebSearch・道具の呼び出し等は1つも止まらない)。
1555
1960
  return decide_any_path(tin, data.get('cwd'))
@@ -27,6 +27,7 @@
27
27
  "hooks": [
28
28
  {
29
29
  "type": "command",
30
+ "timeout": 600,
30
31
  "command": "python3 $HOME/projects/conductor/scripts/hooks/cc2_guard.py"
31
32
  }
32
33
  ]