@tyhld/conductor 0.12.19 → 0.12.21

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.
@@ -32,6 +32,7 @@
32
32
  import os
33
33
  import re
34
34
  import shlex
35
+ import urllib.parse
35
36
 
36
37
  # ═════════════════════════════════════════════════════════════════════════════
37
38
  # 判定の返り値
@@ -476,10 +477,115 @@ _SPLIT_RE = re.compile(r'&&|\|\||[;|&\n]')
476
477
 
477
478
 
478
479
  def split_segments(command: str):
479
- """複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。"""
480
+ """複合コマンドを単純コマンドへ割る(判定は各セグメントに対して行う)。
481
+
482
+ ★機械的に割る(引用符・ヒアドキュメントの中でも割る)。危険(deny)の判定はこちらを使う
483
+ =割り方を変えても止める側は1件も減らない(便 ad6b5db9)。関門は split_commands を使う。
484
+ """
480
485
  return [s.strip() for s in _SPLIT_RE.split(command) if s.strip()]
481
486
 
482
487
 
488
+ # ヒアドキュメントの始まり(<<EOF / <<-EOF / <<'EOF' / <<"EOF")。<<< は文字列を渡す別物。
489
+ _HEREDOC_START_RE = re.compile(r'<<(-?)[ \t]*([\'"]?)([A-Za-z_][A-Za-z0-9_]*)\2')
490
+
491
+
492
+ def split_commands(command: str, bodies: bool = True):
493
+ """★本物の区切りだけで割る(便 ad6b5db9・関門の判定に使う)。
494
+
495
+ 【なぜ要るか(便 df4accf8 の重大発見1・便 30879701)】split_segments は引用符や
496
+ ヒアドキュメントの中でも割るので、コミットの説明・PR の本文・検索の文字・手順書の中の
497
+ 「create table」「vercel --prod」が、ひとつのコマンドとして関門に当たっていた(7日で90件)。
498
+ 【どう割るか】
499
+ ・引用符(' " `)の中は割らない。\\ の後ろの1文字もそのまま。
500
+ ・ヒアドキュメントの本文は、行の終わりで【その時点の最後のコマンド】に付けてひとかたまりにする
501
+ (`cat <<EOF > f` なら cat に、`cat <<EOF | psql` なら流れ込む先の psql に付く)。
502
+ ・# から行末までの注釈は捨てる(語の頭にあるときだけ)。
503
+ ・2>&1 / &> / >& の & は区切りではない。
504
+ ・bodies=False のときは、ヒアドキュメントの本文を付けずに読み飛ばす(便 2.5・止める側の
505
+ 読み直しに使う。本文の文字をコマンドの引数と取り違えないため)。
506
+ """
507
+ out, cur = [], []
508
+ pending = [] # 本文を待っているヒアドキュメント [(区切りの語, 字下げを許すか)]
509
+ quote = None
510
+ i, n = 0, len(command or '')
511
+
512
+ def flush():
513
+ s = ''.join(cur).strip()
514
+ if s:
515
+ out.append(s)
516
+ cur.clear()
517
+
518
+ while i < n:
519
+ c = command[i]
520
+ if quote:
521
+ cur.append(c)
522
+ if c == '\\' and quote != "'" and i + 1 < n:
523
+ cur.append(command[i + 1])
524
+ i += 2
525
+ continue
526
+ if c == quote:
527
+ quote = None
528
+ i += 1
529
+ continue
530
+ if c == '\\' and i + 1 < n:
531
+ cur.append(command[i:i + 2])
532
+ i += 2
533
+ continue
534
+ if c in ('"', "'", '`'):
535
+ quote = c
536
+ cur.append(c)
537
+ i += 1
538
+ continue
539
+ if c == '#' and (i == 0 or command[i - 1] in ' \t\n;&|('):
540
+ j = command.find('\n', i)
541
+ i = n if j < 0 else j
542
+ continue
543
+ if command.startswith('<<', i) and not command.startswith('<<<', i):
544
+ m = _HEREDOC_START_RE.match(command, i)
545
+ if m:
546
+ pending.append((m.group(3), m.group(1) == '-'))
547
+ cur.append(m.group(0))
548
+ i = m.end()
549
+ continue
550
+ if c == '\n':
551
+ i += 1
552
+ if pending:
553
+ for delim, strip_tabs in pending:
554
+ body = []
555
+ while i < n:
556
+ j = command.find('\n', i)
557
+ line = command[i:n if j < 0 else j]
558
+ i = n if j < 0 else j + 1
559
+ if (line.lstrip('\t') if strip_tabs else line).strip() == delim:
560
+ break
561
+ body.append(line)
562
+ if bodies:
563
+ cur.append('\n' + '\n'.join(body))
564
+ pending = []
565
+ flush()
566
+ continue
567
+ if command.startswith('&&', i) or command.startswith('||', i):
568
+ flush()
569
+ i += 2
570
+ continue
571
+ if c == '&' and ((i > 0 and command[i - 1] == '>') or command.startswith('&>', i)):
572
+ cur.append(c)
573
+ i += 1
574
+ continue
575
+ if c == '|' and i > 0 and command[i - 1] == '>':
576
+ cur.append(c) # `>|`(上書きのリダイレクト)は縦棒ではない(便 2.5)
577
+ i += 1
578
+ continue
579
+ if c in ';|&':
580
+ flush()
581
+ i += 1
582
+ continue
583
+ cur.append(c)
584
+ i += 1
585
+ flush()
586
+ return out
587
+
588
+
483
589
  def tokens_of(segment: str):
484
590
  """1セグメントを語に割る。クォートが壊れていても落ちない(安全側で素朴分割へ)。"""
485
591
  try:
@@ -704,7 +810,10 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
704
810
  hit = _rm_danger(inner)
705
811
  if hit:
706
812
  return f'{hit} ({where})'
707
- for seg in split_segments(inner):
813
+ hit = _guard_write_scan(inner, cwd)
814
+ if hit:
815
+ return f'{hit} ({where})'
816
+ for seg in _real_segments(inner):
708
817
  toks = tokens_of(seg)
709
818
  v = verb_of(toks)
710
819
  hit = (_secret_danger(seg, v, toks, cwd) or _bulk_copy_danger(v, toks, cwd)
@@ -1103,6 +1212,11 @@ _VALUE_FLAGS = frozenset({
1103
1212
  '-c', '-C', '--account-id', '--site', '--env', '-e', '--repo', '-R', '--schema',
1104
1213
  })
1105
1214
 
1215
+ # 前置き(npm / pnpm / yarn / npx)の、値を1つ取る旗(`--filter <部品>` など)。
1216
+ _RUNNER_VALUE_FLAGS = frozenset({
1217
+ '--filter', '-F', '--dir', '-C', '--workspace', '-w', '--prefix', '--package', '-p',
1218
+ })
1219
+
1106
1220
  # 作業場所が変数などで決まり、確かめられないことを表す印(None=「指定なし=今の場所」とは別)。
1107
1221
  _UNKNOWN_DIR = object()
1108
1222
 
@@ -1130,13 +1244,34 @@ def _tool_words(tokens):
1130
1244
  return []
1131
1245
  head = rest[0].split('/')[-1].lower()
1132
1246
  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:
1247
+ # ★`pnpm --filter <部品> exec <道具>` / `npm -w <部品> exec -- <道具>` / `npx -p <部品> <道具>` /
1248
+ # `yarn workspace <部品> <道具>` の前置きを剥がす(便 ad6b5db9・便 30879701 の実機)。
1249
+ j, saw_exec = 1, head in _TOOL_RUNNERS
1250
+ while j < len(rest):
1251
+ t = rest[j]
1252
+ if t in _RUNNER_VALUE_FLAGS:
1253
+ j += 2
1254
+ continue
1255
+ if t.startswith('-'):
1256
+ j += 1
1257
+ continue
1258
+ if t.lower() in _PKG_EXEC_WORDS:
1259
+ saw_exec = True
1260
+ j += 1
1261
+ continue
1262
+ if head == 'yarn' and t.lower() == 'workspace':
1263
+ j += 2
1264
+ continue
1265
+ break
1266
+ nxt = _strip_version(rest[j].split('/')[-1].lower()) if j < len(rest) else ''
1267
+ if saw_exec or nxt in _KNOWN_TOOLS:
1139
1268
  rest = rest[j:]
1269
+ if not rest:
1270
+ return [] # 前置きだけで道具が無い(`npx` だけ等)。★例外で落とさない。
1271
+ # 前置きの旗(-p・-w 等)は、頭が npm / pnpm / yarn のまま残ったときだけ値ごと外す
1272
+ # (`mkdir -p` や `grep -w` の後ろの語を消さないため)。
1273
+ value_flags = _VALUE_FLAGS | (_RUNNER_VALUE_FLAGS
1274
+ if rest[0].split('/')[-1].lower() in _PKG_MANAGERS else frozenset())
1140
1275
  words = []
1141
1276
  skip = False
1142
1277
  for k, t in enumerate(rest):
@@ -1144,13 +1279,18 @@ def _tool_words(tokens):
1144
1279
  skip = False
1145
1280
  continue
1146
1281
  if k > 0 and t.startswith('-'):
1147
- if t in _VALUE_FLAGS:
1282
+ if t in value_flags:
1148
1283
  skip = True
1149
1284
  continue
1150
- words.append(t.split('/')[-1].lower() if k == 0 else t.lower())
1285
+ words.append(_strip_version(t.split('/')[-1].lower()) if k == 0 else t.lower())
1151
1286
  return words
1152
1287
 
1153
1288
 
1289
+ def _strip_version(name: str) -> str:
1290
+ """`wrangler@3` → `wrangler`(版の指定を外す)。`@scope` の頭の @ は残す。"""
1291
+ return name.split('@', 1)[0] if '@' in name[1:] else name
1292
+
1293
+
1154
1294
  def _current_branch_targets(cwd):
1155
1295
  """cwd のリポで、引数なしの push が書き込む先のブランチ名の集合。確かめられなければ None。
1156
1296
 
@@ -1253,12 +1393,92 @@ _API_SECRET_RE = re.compile(
1253
1393
  r'|/environments/[^/]+/(secrets|variables)(/|$)')
1254
1394
  _API_REPO_RE = re.compile(r'^repos/[^/]+/[^/]+/?$')
1255
1395
 
1396
+ # ─────────────────────────────────────────────────────────────────────────────
1397
+ # API で main の先端を書き換える形(DENY・便 2.5・便 fc22bc34 の要判断1)
1398
+ # ・git/refs/heads/<保護ブランチ> の PATCH・POST・PUT・DELETE(先端の差し替え・消去)
1399
+ # ・git/refs の POST で ref に保護ブランチを書く形
1400
+ # ・contents の PUT・DELETE で保護ブランチのファイルを直接書く形(branch を書かない=既定の
1401
+ # ブランチ=main へ書くので DENY)
1402
+ # ・GraphQL の createCommitOnBranch・updateRefs(保護ブランチ)・updateRef(先が確かめられない)
1403
+ # ★git/commits・git/trees を作るだけでは main は変わらない。「作って先端を差し替える一連の形」は、
1404
+ # 先端を差し替える一手(refs の書き換え)で止まる。
1405
+ # ★書き込む先のブランチが変数($BR・{branch})で確かめられないときは DENY に倒す(main 直 push と同じ)。
1406
+ # ★作業ブランチへの書き込み(branch=feat/…)は通す。
1407
+ # ─────────────────────────────────────────────────────────────────────────────
1408
+ _API_REFS_RE = re.compile(r'(^|/)git/refs(/(?P<ref>.+))?/?$')
1409
+ _API_CONTENTS_RE = re.compile(r'(^|/)contents(/|$)')
1410
+ # 本文の中の「ブランチ名」の書き方(-f branch=… / "branch":"…" / branchName:"…" / ref=refs/heads/…)。
1411
+ _BODY_BRANCH_RE = re.compile(
1412
+ r'''["']?\b(branch|branchname|qualifiedname|ref|name)["']?\s*[:=]\s*["']?([^\s"',{}\\)]+)''')
1413
+
1414
+
1415
+ def _branch_state(name: str) -> str:
1416
+ """ブランチ名(refs/heads/ 付きも)が 'protected' / 'unknown' / 'ok' のどれか。"""
1417
+ n = name.strip().strip('"\'')
1418
+ for pre in ('refs/heads/', 'heads/'):
1419
+ if n.startswith(pre):
1420
+ n = n[len(pre):]
1421
+ break
1422
+ if not n or any(c in n for c in '$`{}'):
1423
+ return 'unknown'
1424
+ return 'protected' if n in PROTECTED_BRANCHES else 'ok'
1425
+
1426
+
1427
+ def _body_branches(body_low: str, keys):
1428
+ return [m.group(2) for m in _BODY_BRANCH_RE.finditer(body_low or '') if m.group(1) in keys]
1429
+
1430
+
1431
+ def _main_tip_write(ep: str, method: str, body_low: str):
1432
+ """API で保護ブランチの先端を書き換える形なら理由。"""
1433
+ m = _API_REFS_RE.search(ep)
1434
+ if m:
1435
+ ref = m.group('ref')
1436
+ if ref:
1437
+ if ref.startswith('heads/') or '$' in ref or '{' in ref:
1438
+ st = _branch_state(ref)
1439
+ if st == 'protected':
1440
+ return f'API で保護ブランチの先端を書き換える ({method} git/refs/{ref})'
1441
+ if st == 'unknown':
1442
+ return f'API で先端を書き換える (git/refs/{ref}・書き込む先を確かめられない)'
1443
+ return None
1444
+ if method == 'POST':
1445
+ refs = _body_branches(body_low, ('ref',))
1446
+ states = {_branch_state(r) for r in refs if r.startswith(('refs/heads/', '$', '{'))}
1447
+ if not refs or 'protected' in states or 'unknown' in states:
1448
+ return 'API で保護ブランチの ref を作る (POST git/refs・書き込む先が保護か確かめられない)'
1449
+ return None
1450
+ if _API_CONTENTS_RE.search(ep) and method in ('PUT', 'DELETE'):
1451
+ branches = _body_branches(body_low, ('branch',))
1452
+ if not branches:
1453
+ return f'API でファイルを既定のブランチへ直接書く ({method} contents・branch の指定なし)'
1454
+ states = {_branch_state(b) for b in branches}
1455
+ if 'protected' in states:
1456
+ return f'API で保護ブランチのファイルを直接書く ({method} contents)'
1457
+ if 'unknown' in states:
1458
+ return f'API でファイルを直接書く ({method} contents・書き込む先を確かめられない)'
1459
+ return None
1460
+ if ep.rstrip('/') == 'graphql':
1461
+ if 'updateref(' in re.sub(r'\s', '', body_low):
1462
+ return 'GraphQL の updateRef (先端の書き換え・書き込む先を確かめられない)'
1463
+ for mut, keys in (('createcommitonbranch', ('branchname', 'qualifiedname', 'branch')),
1464
+ ('updaterefs', ('name', 'branchname', 'qualifiedname'))):
1465
+ if mut in body_low:
1466
+ names = _body_branches(body_low, keys)
1467
+ states = {_branch_state(b) for b in names}
1468
+ if not names or 'protected' in states or 'unknown' in states:
1469
+ return f'GraphQL の {mut} (保護ブランチの先端の書き換え・または確かめられない)'
1470
+ return None
1471
+
1256
1472
 
1257
1473
  def _github_api_write(endpoint: str, method: str, body_low: str):
1258
1474
  """GitHub の API への書き込み1件が抜け道なら理由を返す(読み取りは通す)。"""
1259
1475
  ep = re.sub(r'^https?://api\.github\.com/', '', endpoint.strip()).lstrip('/').split('?')[0]
1476
+ ep = urllib.parse.unquote(ep)
1260
1477
  if method in ('GET', 'HEAD'):
1261
1478
  return None
1479
+ hit = _main_tip_write(ep, method, body_low)
1480
+ if hit:
1481
+ return hit
1262
1482
  if _API_MERGE_RE.search(ep) and method in ('PUT', 'POST'):
1263
1483
  return 'API での合流 (pulls/N/merge・保護を迂回する合流)'
1264
1484
  if ep.rstrip('/') == 'graphql' and 'mergepullrequest' in body_low:
@@ -1401,26 +1621,35 @@ def _bypass_segment_danger(seg, tokens, cwd, command):
1401
1621
  return None
1402
1622
 
1403
1623
 
1624
+ def _cd_dest(toks, here):
1625
+ """`cd 場所` の後の作業場所。変数・`cd -`・分からないときは _UNKNOWN_DIR。"""
1626
+ after = toks[toks.index('cd') + 1:] if 'cd' in toks else []
1627
+ dest = next((t for t in after if t != '--'), '~')
1628
+ p = os.path.expanduser(dest)
1629
+ if dest == '-' or any(c in dest for c in '$`*?') or here is _UNKNOWN_DIR:
1630
+ return _UNKNOWN_DIR
1631
+ if os.path.isabs(p):
1632
+ return p
1633
+ return os.path.join(here or os.getcwd(), p)
1634
+
1635
+
1404
1636
  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
1637
+ """コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。
1638
+
1639
+ ★機械的な区切りで1回、本物の区切り(本文なし)でもう1回測る(便 2.5)。複数行の引用符の
1640
+ 中に書いた GraphQL の本文などを、ひとかたまりのまま読むため。止める側は減らない。
1641
+ """
1642
+ for segs in (split_segments(command), split_commands(command, bodies=False)):
1643
+ here = cwd
1644
+ for seg in segs:
1645
+ toks = tokens_of(seg)
1646
+ v = verb_of(toks)
1647
+ if v == 'cd':
1648
+ here = _cd_dest(toks, here)
1649
+ continue
1650
+ hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
1651
+ if hit:
1652
+ return hit
1424
1653
  return None
1425
1654
 
1426
1655
 
@@ -1560,7 +1789,9 @@ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
1560
1789
  # コミットメッセージや説明文に .env と書いただけで止まる(実際に止まった)。
1561
1790
  # その代わり、未知の道具に秘密を渡す形(`mytool .env`)は素通りする=
1562
1791
  # 限界として README に明記してある。
1563
- if verb in READ_VERBS or verb in WRITE_VERBS or verb in EXFIL_VERBS:
1792
+ # ★探す道具(rg・ag・ack・ugrep …)も中身を画面に出す=読む(便 2.5・`rg "a|b" .env`)。
1793
+ if verb in READ_VERBS or verb in WRITE_VERBS or verb in EXFIL_VERBS \
1794
+ or verb in _PATTERN_FIRST_VERBS:
1564
1795
  return f'{sr} touched by {verb}'
1565
1796
  if verb in WRITE_VERBS or is_inplace_edit(verb, tokens):
1566
1797
  syr = system_write_reason(a)
@@ -1578,6 +1809,400 @@ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
1578
1809
  return None
1579
1810
 
1580
1811
 
1812
+ def _real_segments(command: str):
1813
+ """止める側の読み直しに使う区切り(便 2.5)。機械的な区切りに、本物の区切りを【足す】。
1814
+
1815
+ ★機械的な区切り(split_segments)は1件も外さない=止める側は減らない。
1816
+ ★本物の区切り(split_commands・本文なし)を足すと、引用符の中の `|` `;` で動詞が千切れた
1817
+ コマンド(`grep -n "x|y" .env` → `y" .env` という切れ端)も、正しい動詞で測り直せる。
1818
+ """
1819
+ segs = split_segments(command)
1820
+ seen = set(segs)
1821
+ for s in split_commands(command, bodies=False):
1822
+ if s not in seen:
1823
+ seen.add(s)
1824
+ segs.append(s)
1825
+ return segs
1826
+
1827
+
1828
+ # ═════════════════════════════════════════════════════════════════════════════
1829
+ # 番人の設定への書き込み(DENY・便 2.5・えふさん確定 2026-09-18)
1830
+ #
1831
+ # 【なぜ要るか(便 ad6b5db9 の重大発見1(ア))】書き込みの道具(Write)では止まるのに、Bash の
1832
+ # リダイレクト・tee・cp・mv・sed -i・python3 で開いて書く形では止まらなかった(リダイレクトの
1833
+ # 行き先は秘密とシステム領域しか見ていなかった)。番人の設定を職人が書き換えられると、番人
1834
+ # そのものが外れる。
1835
+ # 【守る場所】~/.claude/settings.json・~/.claude/settings.local.json(と、各現場の
1836
+ # .claude/settings*.json=Write と同じ判定 is_guard_settings_path)・~/.tyhld/hooks 配下・
1837
+ # ~/.codex/hooks.json・~/.codex/config.toml・~/.config/systemd 配下。
1838
+ # ★ホーム直下の隠しフォルダの hooks.json・config.toml は、道具の名前を持たない一般の形で守る
1839
+ # (~/.codex の2つはこれに当たる)。番人の判定に道具名を持ち込まない決め(test/guard-for-codex・
1840
+ # ADR-022)を守るため。ほかの道具の同じ形の登録ファイルも、同じく守る側に入る。
1841
+ # 【止めないもの】読むだけ(cat・grep・diff・jq・cp で外へ写す)。使い捨ての作業場(/tmp の下)に
1842
+ # 作った同じ名前(Write と同じ例外 is_throwaway_path)。
1843
+ # 【どう見るか】本物の区切り(引用符・ヒアドキュメントの本文の中は割らない)で割った各コマンドの
1844
+ # 「書き込む先」だけを見る。報告やコミットの説明に同じ場所の名前が書いてあるだけでは止めない。
1845
+ # ═════════════════════════════════════════════════════════════════════════════
1846
+
1847
+ #: ★~/.claude/settings*.json は Write と同じ名前の判定(is_guard_settings_path)で守る(一覧は持たない)。
1848
+ #: ホーム直下の隠しフォルダに置かれたときだけ、道具の番人の登録・設定と分かる名前。
1849
+ _AGENT_CONFIG_NAMES = frozenset({'hooks.json', 'config.toml'})
1850
+ #: ホームの下の、中身ぜんぶが番人の設定の置き場。
1851
+ _GUARD_HOME_DIRS = ('.tyhld/hooks', '.config/systemd')
1852
+ #: 番人の設定を中に持つ置き場(消す・動かす・フォルダごと写し込むと設定が変わる)。
1853
+ #: ★ほかに、ホーム直下の隠しフォルダで hooks.json・config.toml を持つものも同じ扱い。
1854
+ _GUARD_HOME_PARENTS = ('.claude', '.tyhld', '.config')
1855
+
1856
+
1857
+ def _expand_path(path: str, cwd=None) -> str:
1858
+ """字面のパスを、近道を解かずに絶対パスへ($HOME・~・相対を解く)。"""
1859
+ q = normalize_redirect_target(path)
1860
+ for var in ('${HOME}', '$HOME'):
1861
+ if q == var or q.startswith(var + '/'):
1862
+ q = os.path.expanduser('~') + q[len(var):]
1863
+ break
1864
+ q = os.path.expanduser(q)
1865
+ if not os.path.isabs(q):
1866
+ base = cwd if (isinstance(cwd, str) and os.path.isabs(cwd)) else os.getcwd()
1867
+ q = os.path.join(base, q)
1868
+ return os.path.normpath(q)
1869
+
1870
+
1871
+ def _home_rels(abs_paths):
1872
+ """絶対パスたちの、ホームからの相対(ホームの外は落とす)。字面のホームと実体のホームの両方。"""
1873
+ homes = {os.path.expanduser('~').rstrip('/'), _home_dir().rstrip('/')}
1874
+ out = []
1875
+ for p in abs_paths:
1876
+ for h in homes:
1877
+ if h and p.startswith(h + '/'):
1878
+ out.append(p[len(h) + 1:])
1879
+ return out
1880
+
1881
+
1882
+ def guard_config_reason(path: str, cwd=None):
1883
+ """書き込むと番人の設定が変わる場所なら理由、そうでなければ None(★読むだけには使わない)。
1884
+
1885
+ ★字面と、近道(symlink)を解いた実体の両方を見る(別名から本物へ書くのを塞ぐ)。
1886
+ """
1887
+ if not path or not path.strip():
1888
+ return None
1889
+ try:
1890
+ lit = _expand_path(path, cwd)
1891
+ real = resolved_path(lit)
1892
+ except Exception: # noqa: BLE001
1893
+ return None
1894
+ for p in (lit, real):
1895
+ if is_guard_settings_path(p) and not is_throwaway_path(p):
1896
+ return 'guard settings (.claude/settings*.json)'
1897
+ for rel in _home_rels((lit, real)):
1898
+ parts = rel.split('/')
1899
+ if len(parts) == 2 and parts[0].startswith('.') and parts[1] in _AGENT_CONFIG_NAMES:
1900
+ return f'agent hook settings (~/{rel})'
1901
+ for d in _GUARD_HOME_DIRS:
1902
+ if rel == d or rel.startswith(d + '/'):
1903
+ return f'guard directory (~/{d})'
1904
+ return None
1905
+
1906
+
1907
+ def _guard_parent_reason(path: str, cwd=None):
1908
+ """番人の設定を中に持つ置き場そのもの(~/.claude・~/.tyhld・~/.config・現場の .claude・
1909
+ ホーム直下の隠しフォルダで hooks.json か config.toml を持つもの)。"""
1910
+ try:
1911
+ lit = _expand_path(path, cwd)
1912
+ real = resolved_path(lit)
1913
+ except Exception: # noqa: BLE001
1914
+ return None
1915
+ for p in (lit, real):
1916
+ if p.rstrip('/').split('/')[-1] == '.claude' and not is_throwaway_path(p):
1917
+ return 'guard settings folder (.claude)'
1918
+ for rel in _home_rels((lit, real)):
1919
+ rel = rel.rstrip('/')
1920
+ if rel in _GUARD_HOME_PARENTS or any(d.startswith(rel + '/') for d in _GUARD_HOME_DIRS):
1921
+ return f'guard settings folder (~/{rel})'
1922
+ if '/' not in rel and rel.startswith('.') and any(
1923
+ os.path.exists(os.path.join(os.path.expanduser('~'), rel, n)) for n in _AGENT_CONFIG_NAMES):
1924
+ return f'agent hook settings folder (~/{rel})'
1925
+ return None
1926
+
1927
+
1928
+ # リダイレクトの記号(語の頭に付いたもの)。`2>&1` `>&2` のような写しは書き込みではない。
1929
+ _REDIRECT_TOKEN_RE = re.compile(r'^\d*(>>|>\||&>>|&>|>&|>|<<<|<<-?|<>|<)')
1930
+ #: 書き込む先が「最後の引数(か -t の値)」の道具。それより前は読むだけ。
1931
+ _COPY_VERBS = frozenset({'cp', 'install', 'ln', 'rsync', 'ditto'})
1932
+ #: 引数ぜんぶが書き換え・削除の対象になる道具。
1933
+ _TOUCH_ALL_VERBS = frozenset({
1934
+ 'tee', 'rm', 'rmdir', 'shred', 'truncate', 'touch', 'chmod', 'chown', 'chgrp', 'patch',
1935
+ 'sponge', 'unlink', 'mkdir',
1936
+ })
1937
+ #: その場で書き換える旗を持つ道具(sed -i・perl -i・yq -i など)。
1938
+ _INPLACE_VERBS = frozenset({'sed', 'perl', 'ruby', 'yq'})
1939
+ #: systemctl --user の、~/.config/systemd の中を書き換える操作。
1940
+ _SYSTEMCTL_WRITE = frozenset({
1941
+ 'enable', 'disable', 'reenable', 'edit', 'link', 'mask', 'unmask', 'revert', 'preset',
1942
+ 'set-property', 'add-wants', 'add-requires',
1943
+ })
1944
+
1945
+
1946
+ def _real_redirect_targets(segment: str):
1947
+ """引用符の外にある本物のリダイレクトの書き込み先(`>` `>>` `>|` `&>`)。"""
1948
+ out = []
1949
+ head = segment.split('\n', 1)[0] if '<<' in segment else segment
1950
+ quote = None
1951
+ i, n = 0, len(head)
1952
+ while i < n:
1953
+ c = head[i]
1954
+ if quote:
1955
+ if c == '\\' and quote == '"':
1956
+ i += 2
1957
+ continue
1958
+ if c == quote:
1959
+ quote = None
1960
+ i += 1
1961
+ continue
1962
+ if c == '\\':
1963
+ i += 2
1964
+ continue
1965
+ if c in '"\'`':
1966
+ quote = c
1967
+ i += 1
1968
+ continue
1969
+ if c != '>':
1970
+ i += 1
1971
+ continue
1972
+ j = i + 1
1973
+ if j < n and head[j] in '>|':
1974
+ j += 1
1975
+ if j < n and head[j] in '&(':
1976
+ i = j + 1 # `>&2` / `2>&1`(写し)・`>(…)`(プロセス置換)は書き込み先ではない
1977
+ continue
1978
+ while j < n and head[j] in ' \t':
1979
+ j += 1
1980
+ k, q = j, None
1981
+ while k < n:
1982
+ ch = head[k]
1983
+ if q:
1984
+ if ch == q:
1985
+ q = None
1986
+ elif ch in '"\'':
1987
+ q = ch
1988
+ elif ch in ' \t;|&<>()':
1989
+ break
1990
+ k += 1
1991
+ word = head[j:k].replace('"', '').replace("'", '')
1992
+ if word:
1993
+ out.append(word)
1994
+ i = k
1995
+ return out
1996
+
1997
+
1998
+ def _plain_args(tokens, start):
1999
+ """動詞より後ろの、リダイレクトを除いた引数。"""
2000
+ out, skip = [], False
2001
+ for t in tokens[start:]:
2002
+ if skip:
2003
+ skip = False
2004
+ continue
2005
+ m = _REDIRECT_TOKEN_RE.match(t)
2006
+ if m:
2007
+ if m.end() == len(t):
2008
+ skip = True
2009
+ continue
2010
+ out.append(t)
2011
+ return out
2012
+
2013
+
2014
+ def _verb_pos(tokens):
2015
+ """verb_of と同じ読み方で、動詞の位置を返す(無ければ len(tokens))。"""
2016
+ i = 0
2017
+ while i < len(tokens):
2018
+ t = tokens[i]
2019
+ if _ASSIGN_RE.match(t):
2020
+ i += 1
2021
+ continue
2022
+ base = t.split('/')[-1]
2023
+ if base in _WRAPPERS:
2024
+ i += 1
2025
+ while i < len(tokens) and tokens[i].startswith('-'):
2026
+ i += 1
2027
+ if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
2028
+ and not tokens[i].startswith('-'):
2029
+ i += 1
2030
+ continue
2031
+ return i
2032
+ return len(tokens)
2033
+
2034
+
2035
+ def _guard_write_segment(segment: str, cwd=None):
2036
+ """本物の1コマンドが番人の設定へ書くなら理由。"""
2037
+ for t in _real_redirect_targets(segment):
2038
+ r = guard_config_reason(t, cwd)
2039
+ if r:
2040
+ return f'redirect into {r}'
2041
+ head = segment.split('\n', 1)[0] if '<<' in segment else segment
2042
+ tokens = tokens_of(head)
2043
+ pos = _verb_pos(tokens)
2044
+ if pos >= len(tokens):
2045
+ return None
2046
+ verb = tokens[pos].split('/')[-1]
2047
+ args = _plain_args(tokens, pos + 1)
2048
+ paths = [a for a in args if not a.startswith('-')]
2049
+ targets, parents = [], []
2050
+ if verb in _TOUCH_ALL_VERBS:
2051
+ targets = paths
2052
+ if verb in ('rm', 'rmdir', 'shred', 'unlink'):
2053
+ parents = paths
2054
+ elif verb == 'mv':
2055
+ targets = parents = paths # 動かす元も、動かした先も変わる
2056
+ elif verb in _COPY_VERBS:
2057
+ dest = None
2058
+ for k, a in enumerate(args):
2059
+ if a in ('-t', '--target-directory') and k + 1 < len(args):
2060
+ dest = args[k + 1]
2061
+ elif a.startswith('--target-directory='):
2062
+ dest = a.split('=', 1)[1]
2063
+ srcs = [p for p in paths if p != dest]
2064
+ if dest is None and paths:
2065
+ dest, srcs = paths[-1], paths[:-1]
2066
+ if dest:
2067
+ targets = [dest] + [os.path.join(_expand_path(dest, cwd), os.path.basename(s.rstrip('/')))
2068
+ for s in srcs if s.rstrip('/')]
2069
+ recursive = any(a in ('-r', '-R', '--recursive', '-a', '--archive')
2070
+ or (re.match(r'^-[A-Za-z]+$', a) and ('r' in a or 'R' in a or 'a' in a))
2071
+ for a in args)
2072
+ if recursive or verb == 'rsync':
2073
+ parents = [dest]
2074
+ elif verb == 'dd':
2075
+ targets = [a[3:] for a in args if a.startswith('of=')]
2076
+ elif verb in _INPLACE_VERBS and (
2077
+ is_inplace_edit(verb if verb != 'yq' else 'sed', tokens[pos:])
2078
+ or any(re.match(r'^-[a-zA-Z]*i', a) for a in args if not a.startswith('--'))):
2079
+ targets = paths # `perl -pi -e …` のように旗をまとめた形も
2080
+ elif verb == 'systemctl' and '--user' in args:
2081
+ if any(a in _SYSTEMCTL_WRITE for a in paths):
2082
+ return 'guard directory (~/.config/systemd) changed by systemctl --user'
2083
+ for p in targets:
2084
+ r = guard_config_reason(p, cwd)
2085
+ if r:
2086
+ return f'{r} written by {verb}'
2087
+ for p in parents:
2088
+ r = _guard_parent_reason(p, cwd)
2089
+ if r:
2090
+ return f'{r} changed by {verb}'
2091
+ return None
2092
+
2093
+
2094
+ def _guard_write_scan(command: str, cwd=None):
2095
+ """コマンド全体で、番人の設定への書き込みを探す(本物の区切り・`cd 場所` を追う)。
2096
+
2097
+ ★同じコマンドの中で先に決めた変数(`P=/tmp/x; … > $P/.claude/settings.json`)は解いてから測る。
2098
+ 解けない変数は、そのまま(=使い捨ての作業場と確かめられない=守る側)で測る。
2099
+ """
2100
+ here, env = cwd, {}
2101
+ for seg in split_commands(command, bodies=False):
2102
+ toks = tokens_of(seg)
2103
+ if toks and all(_ASSIGN_RE.match(t) for t in toks):
2104
+ for t in toks:
2105
+ k, _, v = t.partition('=')
2106
+ env[k] = _expand_vars(v, env)
2107
+ continue
2108
+ seg = _expand_vars(seg, env)
2109
+ toks = tokens_of(seg)
2110
+ if verb_of(toks) == 'cd':
2111
+ here = _cd_dest(toks, here)
2112
+ continue
2113
+ where = None if here is _UNKNOWN_DIR else here
2114
+ hit = _guard_write_segment(seg, where)
2115
+ if hit:
2116
+ return hit
2117
+ for code in _code_payloads(seg, command):
2118
+ hit = _code_guard_write(code, where)
2119
+ if hit:
2120
+ return hit
2121
+ return None
2122
+
2123
+
2124
+ def _expand_vars(text: str, env) -> str:
2125
+ """同じコマンドの中で決めた変数だけを解く($HOME などほかの変数は触らない)。"""
2126
+ if not env:
2127
+ return text
2128
+ return re.sub(r'\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?',
2129
+ lambda m: env.get(m.group(1), m.group(0)), text)
2130
+
2131
+
2132
+ def _code_payloads(segment: str, command: str):
2133
+ """1コマンドがその場で走らせるプログラムの中身(python3 -c・node -e・python3 - <<EOF)。"""
2134
+ head = segment.split('\n', 1)[0]
2135
+ toks = tokens_of(head)
2136
+ if verb_of(toks) not in _CODE_RUNNERS:
2137
+ return []
2138
+ out = [toks[k + 1] for k, t in enumerate(toks) if t in _CODE_FLAGS and k + 1 < len(toks)]
2139
+ m = _HEREDOC_START_RE.search(head)
2140
+ if m:
2141
+ body = _HEREDOC_RE.search(command[command.find(m.group(0)):] if m.group(0) in command else '')
2142
+ if body:
2143
+ out.append(body.group(2))
2144
+ return out
2145
+
2146
+
2147
+ # プログラム(python3 -c・node -e・python3 - <<EOF)の中の、番人の設定の場所の書き方。
2148
+ # ① 文字列そのものが場所('~/.claude/settings.json')→ guard_config_reason で測る
2149
+ # ② 部品で組み立てた形(Path.home() / '.claude' / 'settings.json'・join(h, '.<隠しフォルダ>', 'hooks.json'))
2150
+ # ★報告の文の中に場所の名前が出てくるだけ(空白を含む文字列)は場所と読まない。
2151
+ _CODE_GUARD_JOIN_RE = re.compile(
2152
+ r'''['"]\.claude['"]\s*[,/+]\s*['"]settings[\w.]*\.json['"]'''
2153
+ r'''|['"]\.tyhld['"]\s*[,/+]\s*['"]hooks['"/]'''
2154
+ r'''|['"]\.[\w-]+['"]\s*[,/+]\s*['"](hooks\.json|config\.toml)['"]'''
2155
+ r'''|['"]\.config['"]\s*[,/+]\s*['"]systemd['"/]''')
2156
+ _CODE_PATH_LITERAL_RE = re.compile(r'''['"`]([^'"`\s*?\[\]]{1,300})['"`]''')
2157
+ # ホームを式で足した後ろの部分(homedir() + '/.codex/hooks.json'・`${os.homedir()}/.claude/…`)。
2158
+ _CODE_HOME_TAIL_RE = re.compile(r'^(?:\$\{[^}]*\})?(/\..+)$')
2159
+ # プログラムの中の「書く」書き方(開いて書く・写す・動かす・消す)。
2160
+ _CODE_WRITE_RE = re.compile(
2161
+ r'''open\s*\([^)]*['"](?:[wax]|r\+)[bt+]*['"]|mode\s*=\s*['"](?:[wax]|r\+)'''
2162
+ r'''|\.write(_text|_bytes)?\s*\(|json\.dump\s*\(|shutil\.(copy\w*|move|rmtree)'''
2163
+ r'''|os\.(rename|replace|remove|unlink|symlink|rmdir)\s*\(|\.(unlink|rename|replace|symlink_to|touch)\s*\('''
2164
+ r'''|writefile|appendfile|fs\.(write|copy|rename|rm|unlink|symlink|cp)\w*'''
2165
+ r'''|file\.write|io\.write|['"]>>?['"]''', re.IGNORECASE)
2166
+
2167
+
2168
+ def _code_mentions_guard(stmt: str, cwd=None) -> bool:
2169
+ if _CODE_GUARD_JOIN_RE.search(stmt):
2170
+ return True
2171
+ for m in _CODE_PATH_LITERAL_RE.finditer(stmt):
2172
+ lit = m.group(1)
2173
+ if '/' in lit and guard_config_reason(lit, cwd):
2174
+ return True
2175
+ tail = _CODE_HOME_TAIL_RE.match(lit)
2176
+ if tail and guard_config_reason('~' + tail.group(1), cwd):
2177
+ return True
2178
+ return False
2179
+
2180
+
2181
+ def _code_guard_write(text: str, cwd=None):
2182
+ """プログラムの中で、番人の設定の場所を書き換えるなら理由。★読むだけ(json.load)は止めない。
2183
+
2184
+ ★場所が【書く呼び出しと同じ文】に出てくるか、場所を入れた変数が書く文で使われるときだけ止める
2185
+ (別のファイルを書き換えるプログラムの文の中に、場所の名前が書いてあるだけでは止めない)。
2186
+ """
2187
+ if not text or not _CODE_WRITE_RE.search(text):
2188
+ return None
2189
+ names = set()
2190
+ for stmt in re.split(r'[\n;]', text):
2191
+ if not stmt.strip():
2192
+ continue
2193
+ if _code_mentions_guard(stmt, cwd):
2194
+ if _CODE_WRITE_RE.search(stmt):
2195
+ return 'guard settings written by a program'
2196
+ m = re.match(r'\s*(?:const |let |var |my )?\$?([A-Za-z_][A-Za-z0-9_]*)\s*=[^=]', stmt)
2197
+ if m:
2198
+ names.add(m.group(1))
2199
+ continue
2200
+ if names and _CODE_WRITE_RE.search(stmt) and any(
2201
+ re.search(rf'(?<![\w.]){re.escape(n)}\b', stmt) for n in names):
2202
+ return 'guard settings written by a program'
2203
+ return None
2204
+
2205
+
1581
2206
  # ═════════════════════════════════════════════════════════════════════════════
1582
2207
  # 2) 3関門(gate)
1583
2208
  # ═════════════════════════════════════════════════════════════════════════════
@@ -1610,42 +2235,120 @@ _SQL_TEXT_ONLY_VERBS = frozenset({
1610
2235
  })
1611
2236
 
1612
2237
 
1613
- def _gate_release(low: str, verb: str, tokens):
1614
- if verb == 'vercel' or (verb == 'npx' and 'vercel' in tokens[:3]):
2238
+ # ─────────────────────────────────────────────────────────────────────────────
2239
+ # ★関門は「実際に動く道具」で決める(便 ad6b5db9・えふさん確定 2026-09-18)
2240
+ #
2241
+ # 【直したずれ(便 30879701 の実機)】
2242
+ # ・本物の本番反映 `pnpm --filter <部品> exec wrangler deploy --env production` が素通りした
2243
+ # (頭の pnpm --filter … exec を剥がさず、wrangler deploy も関門に入っていなかった)。
2244
+ # ・報告をヒアドキュメントで書くだけの Bash が、本文の「反映」の文字で関門に当たり8分止まった。
2245
+ # 【どう決めるか】
2246
+ # 1. split_commands で本物の区切りだけで割る(引用符・ヒアドキュメントの中は割らない)。
2247
+ # 2. 頭の前置き(pnpm --filter … exec・pnpm exec・npm exec・npx・yarn・bunx・env 変数=…・time・
2248
+ # nohup・xargs 等)を剥がして、実際に動く道具の語の並び(_tool_words)を見る。
2249
+ # 3. 文字を扱うだけの道具(echo・cat・tee・grep・rg・git・gh …)は関門にしない。
2250
+ # その引数・ヒアドキュメントの本文に何が書かれていても、実行はされないため。
2251
+ # 4. シェル(bash -c "…" / bash <<EOF)は、中身を同じ物差しで測り直す。
2252
+ # 5. SQL の字面は「実行しうる入口」のときだけ見る=DB の道具・移行の道具・その場で走らせる形
2253
+ # (python3 -c・node -e・python3 - <<EOF・DB の道具へ流す形)・知らない実行ファイル。
2254
+ # ★止める側(deny)はこの判定の前に済んでいて、ここでは1件も変えない。
2255
+ # ─────────────────────────────────────────────────────────────────────────────
2256
+ _DB_CLIENTS = frozenset({'psql', 'mysql', 'mongosh', 'dropdb', 'createdb', 'pg_restore', 'pg_dump'})
2257
+
2258
+
2259
+ def _gate_release(words, tokens):
2260
+ """本番反映の関門(実際に動く道具の語の並びで見る)。"""
2261
+ if not words:
2262
+ return None
2263
+ v = words[0]
2264
+ if v == 'vercel':
1615
2265
  if '--prod' in tokens or '--production' in tokens:
1616
2266
  return 'vercel --prod'
1617
- for t in tokens:
1618
- if t in ('promote', 'alias', 'rollback'):
1619
- return f'vercel {t}'
1620
- if verb in ('npm', 'pnpm', 'yarn') and 'publish' in tokens[1:3]:
1621
- return f'{verb} publish'
1622
- if re.search(r'\bsupabase\s+functions\s+deploy\b', low):
2267
+ for w in words[1:]:
2268
+ if w in ('promote', 'alias', 'rollback'):
2269
+ return f'vercel {w}'
2270
+ if v == 'wrangler' and '--dry-run' not in tokens and (
2271
+ words[1:2] in (['deploy'], ['publish'])
2272
+ or words[1:3] in (['versions', 'deploy'], ['pages', 'deploy'])):
2273
+ return 'wrangler ' + (' '.join(words[1:3]) if words[1] in ('versions', 'pages') else words[1])
2274
+ if v in _PKG_MANAGERS and 'publish' in words[1:3]:
2275
+ return f'{v} publish'
2276
+ if words[:3] == ['supabase', 'functions', 'deploy']:
1623
2277
  return 'supabase functions deploy'
1624
- # 「deploy っぽい語」+「本番の指定」の組み合わせ(新しい道具にも効く一般形)。
1625
- if re.search(r'\bdeploy\b', low) and re.search(r'--prod(uction)?\b', low):
2278
+ # 「deploy の語」+「本番の指定」の組み合わせ(新しい道具にも効く一般形)。
2279
+ if 'deploy' in words and ('--prod' in tokens or '--production' in tokens):
1626
2280
  return 'deploy --prod'
1627
2281
  return None
1628
2282
 
1629
2283
 
1630
- def _gate_db(low: str, verb: str, tokens, segment: str):
1631
- if verb in ('psql', 'mysql', 'mongosh', 'dropdb', 'createdb', 'pg_restore', 'pg_dump'):
1632
- return f'database client ({verb})'
1633
- if re.search(r'\bprisma\s+(migrate|db)\b', low):
2284
+ def _gate_db(words, tokens, segment: str):
2285
+ """DB 実行の関門(実際に動く道具の語の並びで見る)。"""
2286
+ if not words:
2287
+ return None
2288
+ v = words[0]
2289
+ if v in _DB_CLIENTS:
2290
+ return f'database client ({v})'
2291
+ joined = ' '.join(words)
2292
+ if re.match(r'prisma (migrate|db)( |$)', joined):
1634
2293
  return 'prisma migrate/db'
1635
- if re.search(r'\bsupabase\s+(db\s+(push|reset|query|execute)|migration\s+up)\b', low):
2294
+ if re.match(r'supabase (db (push|reset|query|execute)|migration up)( |$)', joined):
1636
2295
  return 'supabase db/migration'
1637
- if re.search(r'\bdrizzle-kit\s+(push|migrate)\b', low):
2296
+ if re.match(r'drizzle-kit (push|migrate)( |$)', joined):
1638
2297
  return 'drizzle-kit push/migrate'
1639
- # SQL 文字列(DDL / GRANT / RLS)を根拠にした関門。
1640
- # ★「実際にSQLを実行しうる入口」に限る。文字列を扱うだけの動詞(echo / grep / git …)は
1641
- # 引数にSQLの字面があっても実行しないので関門にしない(=誤検知を消す・緩める変更)。
1642
- # 一方、psql/mysql への `| psql` は分割後に psql セグメントが別途上で関門に当たり、
1643
- # run-sql / apply のような未知のSQL実行入口は safe 集合に無いのでここで関門に落ちる。
1644
- if verb not in _SQL_TEXT_ONLY_VERBS and _SQL_GATE_RE.search(segment):
2298
+ # SQL の字面(DDL / GRANT / RLS)。★ここへ来るのは「実行しうる入口」だけ
2299
+ # (文字を扱うだけの道具とシェルは _gate_one で先に振り分け済み)。
2300
+ if _SQL_GATE_RE.search(segment):
1645
2301
  return 'SQL DDL/GRANT/RLS'
1646
2302
  return None
1647
2303
 
1648
2304
 
2305
+ def _shell_payloads(head_tokens, body):
2306
+ """シェルに渡された中身(-c の文字列・ヒアドキュメントの本文)。"""
2307
+ out = []
2308
+ for k, t in enumerate(head_tokens):
2309
+ if t in _CODE_FLAGS and k + 1 < len(head_tokens):
2310
+ out.append(head_tokens[k + 1])
2311
+ if body.strip():
2312
+ out.append(body)
2313
+ return out
2314
+
2315
+
2316
+ def _gate_one(segment: str, depth: int):
2317
+ """本物の1コマンドの関門。返り (種別, 理由) / None。"""
2318
+ head, _, body = segment.partition('\n')
2319
+ toks = tokens_of(head)
2320
+ words = _tool_words(toks)
2321
+ if not words:
2322
+ return None
2323
+ v = words[0]
2324
+ if v in _SQL_TEXT_ONLY_VERBS:
2325
+ return None # 文字を扱うだけ。引数・本文に何が書かれていても実行されない。
2326
+ if v in _SHELL_RUNNERS:
2327
+ if depth >= _UNWRAP_MAX_DEPTH:
2328
+ return None
2329
+ for inner in _shell_payloads(toks, body):
2330
+ hit = _gate_command(inner, depth + 1)
2331
+ if hit:
2332
+ return hit
2333
+ return None
2334
+ hit = _gate_release(words, toks)
2335
+ if hit:
2336
+ return GATE_RELEASE, hit
2337
+ hit = _gate_db(words, toks, segment)
2338
+ if hit:
2339
+ return GATE_DB, hit
2340
+ return None
2341
+
2342
+
2343
+ def _gate_command(command: str, depth: int = 0):
2344
+ """コマンド全体の関門(最初に当たったもの)。返り (種別, 理由) / None。"""
2345
+ for seg in split_commands(command):
2346
+ hit = _gate_one(seg, depth)
2347
+ if hit:
2348
+ return hit
2349
+ return None
2350
+
2351
+
1649
2352
  # ═════════════════════════════════════════════════════════════════════════════
1650
2353
  # 入口: Bash
1651
2354
  # ═════════════════════════════════════════════════════════════════════════════
@@ -1664,7 +2367,8 @@ def decide_bash(command: str, cwd=None):
1664
2367
  if hit:
1665
2368
  return _r(DENY, f'danger: {hit}')
1666
2369
 
1667
- segments = split_segments(command)
2370
+ # ★機械的な区切りに、本物の区切りを足して測る(便 2.5・`grep -n "x|y" .env` の動詞を取り戻す)。
2371
+ segments = _real_segments(command)
1668
2372
 
1669
2373
  # ---- 1) 危険(セグメントごと・対象と作用) ----
1670
2374
  for seg in segments:
@@ -1688,6 +2392,11 @@ def decide_bash(command: str, cwd=None):
1688
2392
  if hit:
1689
2393
  return _r(DENY, f'danger: {hit}')
1690
2394
 
2395
+ # ---- 1c') 番人の設定への書き込み(便 2.5・リダイレクト・tee・cp・mv・sed -i ほか) ----
2396
+ hit = _guard_write_scan(command, cwd)
2397
+ if hit:
2398
+ return _r(DENY, f'danger: {hit}')
2399
+
1691
2400
  # ---- 1d) 抜け道(便 fc22bc34・main 直 push・保護の迂回と変更・秘密の値・検査の省略) ----
1692
2401
  hit = _bypass_scan(command, cwd)
1693
2402
  if hit:
@@ -1698,17 +2407,11 @@ def decide_bash(command: str, cwd=None):
1698
2407
  if hit:
1699
2408
  return _prod_db_change_result(hit)
1700
2409
 
1701
- # ---- 2) 3関門 ----
1702
- for seg in segments:
1703
- toks = tokens_of(seg)
1704
- v = verb_of(toks)
1705
- seg_low = seg.lower()
1706
- hit = _gate_release(seg_low, v, toks)
1707
- if hit:
1708
- return _r(GATE, f'gate(release): {hit}', GATE_RELEASE)
1709
- hit = _gate_db(seg_low, v, toks, seg)
1710
- if hit:
1711
- return _r(GATE, f'gate(db): {hit}', GATE_DB)
2410
+ # ---- 2) 関門(★本物の区切りと、実際に動く道具で見る・便 ad6b5db9) ----
2411
+ hit = _gate_command(command)
2412
+ if hit:
2413
+ gate, reason = hit
2414
+ return _r(GATE, f'gate({gate}): {reason}', gate)
1712
2415
 
1713
2416
  # ---- 3) それ以外は自走 ----
1714
2417
  return _r(ALLOW, 'no gate, no danger')
@@ -1752,6 +2455,11 @@ def decide_write(file_path: str, cwd=None):
1752
2455
  comps = [c for c in path.split('/') if c]
1753
2456
  if '.tyhld' in comps:
1754
2457
  return _r(DENY, 'danger: write to guard directory (.tyhld)')
2458
+ # ★Bash と同じ場所の一覧で守る(ホーム直下の隠しフォルダの hooks.json・config.toml・
2459
+ # ~/.config/systemd・便 2.5)。
2460
+ gr = guard_config_reason(path)
2461
+ if gr:
2462
+ return _r(DENY, f'danger: write to {gr}')
1755
2463
  return _r(ALLOW, 'no gate, no danger')
1756
2464
 
1757
2465