@tyhld/conductor 0.12.20 → 0.12.22

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.
@@ -29,9 +29,12 @@
29
29
 
30
30
  ★このモジュールは標準ライブラリだけで動き、製品固有の名前を持たない(切り出し可能)。
31
31
  """
32
+ import fnmatch
33
+ import glob
32
34
  import os
33
35
  import re
34
36
  import shlex
37
+ import urllib.parse
35
38
 
36
39
  # ═════════════════════════════════════════════════════════════════════════════
37
40
  # 判定の返り値
@@ -411,9 +414,8 @@ def _under(path: str, root: str) -> bool:
411
414
 
412
415
 
413
416
  def _named_guard_settings(path: str) -> bool:
414
- """字面が「.claude/settings…」の形か(名前だけの判定・単体では使わない)。"""
415
- comps = [c for c in path.replace('\\', '/').split('/') if c]
416
- return len(comps) >= 2 and comps[-2] == '.claude' and comps[-1].startswith('settings')
417
+ """字面が「.claude/settings…」の形か(名前だけの判定・単体では使わない)。★一覧 GUARD_PLACES から引く。"""
418
+ return _site_file_match(path)
417
419
 
418
420
 
419
421
  def is_guard_settings_path(path: str) -> bool:
@@ -488,7 +490,7 @@ def split_segments(command: str):
488
490
  _HEREDOC_START_RE = re.compile(r'<<(-?)[ \t]*([\'"]?)([A-Za-z_][A-Za-z0-9_]*)\2')
489
491
 
490
492
 
491
- def split_commands(command: str):
493
+ def split_commands(command: str, bodies: bool = True):
492
494
  """★本物の区切りだけで割る(便 ad6b5db9・関門の判定に使う)。
493
495
 
494
496
  【なぜ要るか(便 df4accf8 の重大発見1・便 30879701)】split_segments は引用符や
@@ -500,6 +502,8 @@ def split_commands(command: str):
500
502
  (`cat <<EOF > f` なら cat に、`cat <<EOF | psql` なら流れ込む先の psql に付く)。
501
503
  ・# から行末までの注釈は捨てる(語の頭にあるときだけ)。
502
504
  ・2>&1 / &> / >& の & は区切りではない。
505
+ ・bodies=False のときは、ヒアドキュメントの本文を付けずに読み飛ばす(便 2.5・止める側の
506
+ 読み直しに使う。本文の文字をコマンドの引数と取り違えないため)。
503
507
  """
504
508
  out, cur = [], []
505
509
  pending = [] # 本文を待っているヒアドキュメント [(区切りの語, 字下げを許すか)]
@@ -556,7 +560,8 @@ def split_commands(command: str):
556
560
  if (line.lstrip('\t') if strip_tabs else line).strip() == delim:
557
561
  break
558
562
  body.append(line)
559
- cur.append('\n' + '\n'.join(body))
563
+ if bodies:
564
+ cur.append('\n' + '\n'.join(body))
560
565
  pending = []
561
566
  flush()
562
567
  continue
@@ -568,6 +573,10 @@ def split_commands(command: str):
568
573
  cur.append(c)
569
574
  i += 1
570
575
  continue
576
+ if c == '|' and i > 0 and command[i - 1] == '>':
577
+ cur.append(c) # `>|`(上書きのリダイレクト)は縦棒ではない(便 2.5)
578
+ i += 1
579
+ continue
571
580
  if c in ';|&':
572
581
  flush()
573
582
  i += 1
@@ -802,7 +811,10 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
802
811
  hit = _rm_danger(inner)
803
812
  if hit:
804
813
  return f'{hit} ({where})'
805
- for seg in split_segments(inner):
814
+ hit = _guard_write_scan(inner, cwd)
815
+ if hit:
816
+ return f'{hit} ({where})'
817
+ for seg in _real_segments(inner):
806
818
  toks = tokens_of(seg)
807
819
  v = verb_of(toks)
808
820
  hit = (_secret_danger(seg, v, toks, cwd) or _bulk_copy_danger(v, toks, cwd)
@@ -1382,12 +1394,92 @@ _API_SECRET_RE = re.compile(
1382
1394
  r'|/environments/[^/]+/(secrets|variables)(/|$)')
1383
1395
  _API_REPO_RE = re.compile(r'^repos/[^/]+/[^/]+/?$')
1384
1396
 
1397
+ # ─────────────────────────────────────────────────────────────────────────────
1398
+ # API で main の先端を書き換える形(DENY・便 2.5・便 fc22bc34 の要判断1)
1399
+ # ・git/refs/heads/<保護ブランチ> の PATCH・POST・PUT・DELETE(先端の差し替え・消去)
1400
+ # ・git/refs の POST で ref に保護ブランチを書く形
1401
+ # ・contents の PUT・DELETE で保護ブランチのファイルを直接書く形(branch を書かない=既定の
1402
+ # ブランチ=main へ書くので DENY)
1403
+ # ・GraphQL の createCommitOnBranch・updateRefs(保護ブランチ)・updateRef(先が確かめられない)
1404
+ # ★git/commits・git/trees を作るだけでは main は変わらない。「作って先端を差し替える一連の形」は、
1405
+ # 先端を差し替える一手(refs の書き換え)で止まる。
1406
+ # ★書き込む先のブランチが変数($BR・{branch})で確かめられないときは DENY に倒す(main 直 push と同じ)。
1407
+ # ★作業ブランチへの書き込み(branch=feat/…)は通す。
1408
+ # ─────────────────────────────────────────────────────────────────────────────
1409
+ _API_REFS_RE = re.compile(r'(^|/)git/refs(/(?P<ref>.+))?/?$')
1410
+ _API_CONTENTS_RE = re.compile(r'(^|/)contents(/|$)')
1411
+ # 本文の中の「ブランチ名」の書き方(-f branch=… / "branch":"…" / branchName:"…" / ref=refs/heads/…)。
1412
+ _BODY_BRANCH_RE = re.compile(
1413
+ r'''["']?\b(branch|branchname|qualifiedname|ref|name)["']?\s*[:=]\s*["']?([^\s"',{}\\)]+)''')
1414
+
1415
+
1416
+ def _branch_state(name: str) -> str:
1417
+ """ブランチ名(refs/heads/ 付きも)が 'protected' / 'unknown' / 'ok' のどれか。"""
1418
+ n = name.strip().strip('"\'')
1419
+ for pre in ('refs/heads/', 'heads/'):
1420
+ if n.startswith(pre):
1421
+ n = n[len(pre):]
1422
+ break
1423
+ if not n or any(c in n for c in '$`{}'):
1424
+ return 'unknown'
1425
+ return 'protected' if n in PROTECTED_BRANCHES else 'ok'
1426
+
1427
+
1428
+ def _body_branches(body_low: str, keys):
1429
+ return [m.group(2) for m in _BODY_BRANCH_RE.finditer(body_low or '') if m.group(1) in keys]
1430
+
1431
+
1432
+ def _main_tip_write(ep: str, method: str, body_low: str):
1433
+ """API で保護ブランチの先端を書き換える形なら理由。"""
1434
+ m = _API_REFS_RE.search(ep)
1435
+ if m:
1436
+ ref = m.group('ref')
1437
+ if ref:
1438
+ if ref.startswith('heads/') or '$' in ref or '{' in ref:
1439
+ st = _branch_state(ref)
1440
+ if st == 'protected':
1441
+ return f'API で保護ブランチの先端を書き換える ({method} git/refs/{ref})'
1442
+ if st == 'unknown':
1443
+ return f'API で先端を書き換える (git/refs/{ref}・書き込む先を確かめられない)'
1444
+ return None
1445
+ if method == 'POST':
1446
+ refs = _body_branches(body_low, ('ref',))
1447
+ states = {_branch_state(r) for r in refs if r.startswith(('refs/heads/', '$', '{'))}
1448
+ if not refs or 'protected' in states or 'unknown' in states:
1449
+ return 'API で保護ブランチの ref を作る (POST git/refs・書き込む先が保護か確かめられない)'
1450
+ return None
1451
+ if _API_CONTENTS_RE.search(ep) and method in ('PUT', 'DELETE'):
1452
+ branches = _body_branches(body_low, ('branch',))
1453
+ if not branches:
1454
+ return f'API でファイルを既定のブランチへ直接書く ({method} contents・branch の指定なし)'
1455
+ states = {_branch_state(b) for b in branches}
1456
+ if 'protected' in states:
1457
+ return f'API で保護ブランチのファイルを直接書く ({method} contents)'
1458
+ if 'unknown' in states:
1459
+ return f'API でファイルを直接書く ({method} contents・書き込む先を確かめられない)'
1460
+ return None
1461
+ if ep.rstrip('/') == 'graphql':
1462
+ if 'updateref(' in re.sub(r'\s', '', body_low):
1463
+ return 'GraphQL の updateRef (先端の書き換え・書き込む先を確かめられない)'
1464
+ for mut, keys in (('createcommitonbranch', ('branchname', 'qualifiedname', 'branch')),
1465
+ ('updaterefs', ('name', 'branchname', 'qualifiedname'))):
1466
+ if mut in body_low:
1467
+ names = _body_branches(body_low, keys)
1468
+ states = {_branch_state(b) for b in names}
1469
+ if not names or 'protected' in states or 'unknown' in states:
1470
+ return f'GraphQL の {mut} (保護ブランチの先端の書き換え・または確かめられない)'
1471
+ return None
1472
+
1385
1473
 
1386
1474
  def _github_api_write(endpoint: str, method: str, body_low: str):
1387
1475
  """GitHub の API への書き込み1件が抜け道なら理由を返す(読み取りは通す)。"""
1388
1476
  ep = re.sub(r'^https?://api\.github\.com/', '', endpoint.strip()).lstrip('/').split('?')[0]
1477
+ ep = urllib.parse.unquote(ep)
1389
1478
  if method in ('GET', 'HEAD'):
1390
1479
  return None
1480
+ hit = _main_tip_write(ep, method, body_low)
1481
+ if hit:
1482
+ return hit
1391
1483
  if _API_MERGE_RE.search(ep) and method in ('PUT', 'POST'):
1392
1484
  return 'API での合流 (pulls/N/merge・保護を迂回する合流)'
1393
1485
  if ep.rstrip('/') == 'graphql' and 'mergepullrequest' in body_low:
@@ -1530,26 +1622,35 @@ def _bypass_segment_danger(seg, tokens, cwd, command):
1530
1622
  return None
1531
1623
 
1532
1624
 
1625
+ def _cd_dest(toks, here):
1626
+ """`cd 場所` の後の作業場所。変数・`cd -`・分からないときは _UNKNOWN_DIR。"""
1627
+ after = toks[toks.index('cd') + 1:] if 'cd' in toks else []
1628
+ dest = next((t for t in after if t != '--'), '~')
1629
+ p = os.path.expanduser(dest)
1630
+ if dest == '-' or any(c in dest for c in '$`*?') or here is _UNKNOWN_DIR:
1631
+ return _UNKNOWN_DIR
1632
+ if os.path.isabs(p):
1633
+ return p
1634
+ return os.path.join(here or os.getcwd(), p)
1635
+
1636
+
1533
1637
  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
1638
+ """コマンド全体の抜け道を探す。★`cd 場所` を追って、後ろの push の作業場所に使う。
1639
+
1640
+ ★機械的な区切りで1回、本物の区切り(本文なし)でもう1回測る(便 2.5)。複数行の引用符の
1641
+ 中に書いた GraphQL の本文などを、ひとかたまりのまま読むため。止める側は減らない。
1642
+ """
1643
+ for segs in (split_segments(command), split_commands(command, bodies=False)):
1644
+ here = cwd
1645
+ for seg in segs:
1646
+ toks = tokens_of(seg)
1647
+ v = verb_of(toks)
1648
+ if v == 'cd':
1649
+ here = _cd_dest(toks, here)
1650
+ continue
1651
+ hit = _bypass_segment_danger(seg, toks, here, command) or _main_push_danger(toks, here)
1652
+ if hit:
1653
+ return hit
1553
1654
  return None
1554
1655
 
1555
1656
 
@@ -1689,7 +1790,9 @@ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
1689
1790
  # コミットメッセージや説明文に .env と書いただけで止まる(実際に止まった)。
1690
1791
  # その代わり、未知の道具に秘密を渡す形(`mytool .env`)は素通りする=
1691
1792
  # 限界として README に明記してある。
1692
- if verb in READ_VERBS or verb in WRITE_VERBS or verb in EXFIL_VERBS:
1793
+ # ★探す道具(rg・ag・ack・ugrep …)も中身を画面に出す=読む(便 2.5・`rg "a|b" .env`)。
1794
+ if verb in READ_VERBS or verb in WRITE_VERBS or verb in EXFIL_VERBS \
1795
+ or verb in _PATTERN_FIRST_VERBS:
1693
1796
  return f'{sr} touched by {verb}'
1694
1797
  if verb in WRITE_VERBS or is_inplace_edit(verb, tokens):
1695
1798
  syr = system_write_reason(a)
@@ -1707,6 +1810,936 @@ def _secret_danger(segment: str, verb: str, tokens, cwd=None):
1707
1810
  return None
1708
1811
 
1709
1812
 
1813
+ def _real_segments(command: str):
1814
+ """止める側の読み直しに使う区切り(便 2.5)。機械的な区切りに、本物の区切りを【足す】。
1815
+
1816
+ ★機械的な区切り(split_segments)は1件も外さない=止める側は減らない。
1817
+ ★本物の区切り(split_commands・本文なし)を足すと、引用符の中の `|` `;` で動詞が千切れた
1818
+ コマンド(`grep -n "x|y" .env` → `y" .env` という切れ端)も、正しい動詞で測り直せる。
1819
+ """
1820
+ segs = split_segments(command)
1821
+ seen = set(segs)
1822
+ for s in split_commands(command, bodies=False):
1823
+ if s not in seen:
1824
+ seen.add(s)
1825
+ segs.append(s)
1826
+ return segs
1827
+
1828
+
1829
+ # ═════════════════════════════════════════════════════════════════════════════
1830
+ # 番人の設定を弱める書き込み(DENY・便 2.5 → 便 2.6 で「手段を問わず」へ・えふさん確定 2026-09-18)
1831
+ #
1832
+ # 【目的】職人から、番人・guard・hook・管理設定そのものを弱める変更をできないようにする。
1833
+ # 「見つかった1構文を禁止」ではなく、別の書き込み手段・別のシェルの書き方に置き換えても
1834
+ # 同じことができないところまで塞ぐ(便 2.6)。
1835
+ # 【守る場所】GUARD_PLACES の一覧だけ(★場所を2か所に書かない)。Bash・Write・プログラムの中・
1836
+ # 置き場ごとの操作は、どれもこの一覧から引いた判定(guard_config_reason / guard_ancestor_reason)を通る。
1837
+ # 【どう見るか】道具の名前ではなく「書き込む行為」でそろえる。1コマンドごとに
1838
+ # ① 包み(env・sudo・nohup・time・xargs・exec・timeout …・サブシェルの括弧・if/for/do)を剥がし、
1839
+ # ② 書き込む先(リダイレクト・道具ごとの書き込み先・展開の行き先・ダウンロードの行き先 …)を集め、
1840
+ # ③ 先に決めた変数・~・$HOME・${HOME}・相対の道・波括弧・* を解いてから、一覧と照らす。
1841
+ # ④ 中身を走らせる形(bash -c・eval・<<<・ヒアドキュメント・$(…)・`…`・find -exec)は中身を測り直す。
1842
+ # 【止めないもの】読むだけ(cat・less・grep・rg・diff・jq・ls・stat・git status・外へ写す cp)。
1843
+ # 使い捨ての作業場(/tmp の下)に作った同じ名前(Write と同じ例外 is_throwaway_path)。
1844
+ # 報告やコミットの説明に場所の名前が書いてあるだけ(引用符の中の `>` も書き込みと読まない)。
1845
+ # ═════════════════════════════════════════════════════════════════════════════
1846
+
1847
+ #: ★守る場所の一覧(ここだけ)。(種類, 場所)
1848
+ #: site-file … どの .claude フォルダでも、この名前で始まるファイル(ホームと各現場の settings.json・
1849
+ #: settings.local.json。Write が前から守っていた settings* の広さのまま)
1850
+ #: home-tree … ホームからの置き場(中身ぜんぶ)
1851
+ #: home-agent … ホーム直下の隠しフォルダに置かれた、この名前のファイル。道具の番人の登録と設定。
1852
+ #: ★道具の名前を持たない一般の形(番人の判定に道具名を持ち込まない決め・ADR-022)。
1853
+ #: abs-tree … 絶対パスの置き場(管理設定=managed settings)
1854
+ GUARD_PLACES = (
1855
+ ('site-file', '.claude/settings'),
1856
+ ('home-tree', '.claude/skills'),
1857
+ ('home-tree', '.tyhld/hooks'),
1858
+ ('home-tree', '.tyhld/bin'),
1859
+ ('home-tree', '.config/systemd'),
1860
+ ('home-agent', 'hooks.json'),
1861
+ ('home-agent', 'config.toml'),
1862
+ ('abs-tree', '/etc/claude-code'),
1863
+ ('abs-tree', '/Library/Application Support/ClaudeCode'),
1864
+ )
1865
+
1866
+
1867
+ def _places(kind):
1868
+ return tuple(p for k, p in GUARD_PLACES if k == kind)
1869
+
1870
+
1871
+ def _site_file_match(path: str) -> bool:
1872
+ """字面が「<どこか>/.claude/settings…」の形か(一覧の site-file から引く・名前だけの判定)。"""
1873
+ comps = [c for c in path.replace('\\', '/').split('/') if c]
1874
+ for p in _places('site-file'):
1875
+ d, name = p.split('/')
1876
+ if len(comps) >= 2 and comps[-2] == d and comps[-1].startswith(name):
1877
+ return True
1878
+ return False
1879
+
1880
+
1881
+ def _expand_path(path: str, cwd=None) -> str:
1882
+ """字面のパスを、近道を解かずに絶対パスへ($HOME・${HOME}・~・相対を解く)。"""
1883
+ q = normalize_redirect_target(path)
1884
+ for var in ('${HOME}', '$HOME'):
1885
+ if q == var or q.startswith(var + '/'):
1886
+ q = os.path.expanduser('~') + q[len(var):]
1887
+ break
1888
+ q = os.path.expanduser(q)
1889
+ if not os.path.isabs(q):
1890
+ base = cwd if (isinstance(cwd, str) and os.path.isabs(cwd)) else os.getcwd()
1891
+ q = os.path.join(base, q)
1892
+ return os.path.normpath(q)
1893
+
1894
+
1895
+ def _homes():
1896
+ return {h for h in (os.path.expanduser('~').rstrip('/'), _home_dir().rstrip('/')) if h}
1897
+
1898
+
1899
+ def _home_rels(abs_paths):
1900
+ """絶対パスたちの、ホームからの相対(ホームの外は落とす)。字面のホームと実体のホームの両方。"""
1901
+ out = []
1902
+ for p in abs_paths:
1903
+ for h in _homes():
1904
+ if p.startswith(h + '/'):
1905
+ out.append(p[len(h) + 1:])
1906
+ return out
1907
+
1908
+
1909
+ def _place_reason_abs(p: str):
1910
+ """絶対パス1つ(解いた後)が一覧の場所か。"""
1911
+ if _site_file_match(p) and not is_throwaway_path(p):
1912
+ return 'guard settings (.claude/settings*.json)'
1913
+ for root in _places('abs-tree'):
1914
+ if p == root or p.startswith(root + '/'):
1915
+ return f'managed settings ({root})'
1916
+ for rel in _home_rels((p,)):
1917
+ for d in _places('home-tree'):
1918
+ if rel == d or rel.startswith(d + '/'):
1919
+ return f'guard directory (~/{d})'
1920
+ parts = rel.split('/')
1921
+ if len(parts) == 2 and parts[0].startswith('.') and parts[1] in _places('home-agent'):
1922
+ return f'agent hook settings (~/{rel})'
1923
+ return None
1924
+
1925
+
1926
+ # 解けない変数・コマンド置換($X・${X}・$(…)・`…`)。この後ろの字面だけで一覧と照らす。
1927
+ _UNRESOLVED_RE = re.compile(r'\$\{[^}]*\}|\$\([^)]*\)|`[^`]*`|\$[A-Za-z_][A-Za-z0-9_]*')
1928
+ _GLOB_CHARS_RE = re.compile(r'[*?\[]')
1929
+
1930
+
1931
+ def _tail_reason(tail: str):
1932
+ """解けない部分の後ろの字面(例 `/.tyhld/hooks/p.py`)が、一覧のどこかの形か。"""
1933
+ if _site_file_match(tail):
1934
+ return 'guard settings (.claude/settings*.json)'
1935
+ for d in _places('home-tree'):
1936
+ if ('/' + d + '/') in tail or tail.endswith('/' + d) or tail.startswith(d + '/'):
1937
+ return f'guard directory (~/{d})'
1938
+ for root in _places('abs-tree'):
1939
+ if tail.startswith(root):
1940
+ return f'managed settings ({root})'
1941
+ m = re.search(r'/\.[^/]+/([^/]+)$', tail)
1942
+ if m and m.group(1) in _places('home-agent'):
1943
+ return f'agent hook settings ({m.group(0)})'
1944
+ return None
1945
+
1946
+
1947
+ def _concrete_places(cwd=None):
1948
+ """一覧から作る、実際の場所の例(* を含む書き方と照らすため)。"""
1949
+ out = []
1950
+ for h in _homes():
1951
+ out += [os.path.join(h, '.claude', 'settings.json'), os.path.join(h, '.claude', 'settings.local.json')]
1952
+ out += [os.path.join(h, d) for d in _places('home-tree')]
1953
+ for name in _places('home-agent'):
1954
+ out += glob.glob(os.path.join(h, '.*', name))
1955
+ base = cwd if (isinstance(cwd, str) and os.path.isabs(cwd)) else os.getcwd()
1956
+ out += [os.path.join(base, '.claude', 'settings.json'), os.path.join(base, '.claude', 'settings.local.json')]
1957
+ out += list(_places('abs-tree'))
1958
+ return out
1959
+
1960
+
1961
+ def guard_config_reason(path: str, cwd=None):
1962
+ """書き込むと番人の設定が変わる場所なら理由、そうでなければ None(★読むだけには使わない)。
1963
+
1964
+ ★字面と、近道(symlink)を解いた実体の両方を見る(別名から本物へ書くのを塞ぐ)。
1965
+ ★解けない変数($D/.tyhld/hooks/…)は後ろの字面で照らす。* を含む書き方は、実在の一致と、
1966
+ 一覧の場所の例との一致の両方で照らす(確かめられないものは守る側)。
1967
+ """
1968
+ if not path or not path.strip():
1969
+ return None
1970
+ try:
1971
+ lit = _expand_path(path, cwd)
1972
+ real = resolved_path(lit)
1973
+ except Exception: # noqa: BLE001
1974
+ return None
1975
+ for p in (lit, real):
1976
+ r = _place_reason_abs(p)
1977
+ if r:
1978
+ return r
1979
+ if _UNRESOLVED_RE.search(lit):
1980
+ r = _tail_reason(_UNRESOLVED_RE.split(lit)[-1])
1981
+ if r:
1982
+ return r
1983
+ if _GLOB_CHARS_RE.search(lit):
1984
+ try:
1985
+ hits = glob.glob(lit)
1986
+ except Exception: # noqa: BLE001
1987
+ hits = []
1988
+ for h in hits[:200]:
1989
+ r = _place_reason_abs(os.path.normpath(h))
1990
+ if r:
1991
+ return r
1992
+ pc = lit.split('/')
1993
+ for c in _concrete_places(cwd):
1994
+ cc = c.split('/')
1995
+ is_tree = _place_reason_abs(c + '/x') is not None and not _site_file_match(c)
1996
+ if len(pc) == len(cc) or (is_tree and len(pc) > len(cc)):
1997
+ if fnmatch.fnmatchcase(c, '/'.join(pc[:len(cc)])):
1998
+ r = _place_reason_abs(c) or _place_reason_abs(c + '/x')
1999
+ if r:
2000
+ return r
2001
+ return None
2002
+
2003
+
2004
+ def guard_ancestor_reason(path: str, cwd=None):
2005
+ """一覧の場所を【中に持つ】置き場(消す・動かす・フォルダごと写し込む・展開すると設定が変わる)。
2006
+
2007
+ ホームそのもの・~/.claude・~/.tyhld・~/.config・各現場の .claude・管理設定の親・
2008
+ ホーム直下の隠しフォルダで一覧の名前のファイルを持つもの。
2009
+ """
2010
+ try:
2011
+ lit = _expand_path(path, cwd)
2012
+ real = resolved_path(lit)
2013
+ except Exception: # noqa: BLE001
2014
+ return None
2015
+ for p in (lit, real):
2016
+ p = p.rstrip('/') or '/'
2017
+ if p in _homes():
2018
+ return 'guard settings folder (~)'
2019
+ site_dirs = {sf.split('/')[0] for sf in _places('site-file')}
2020
+ if p.split('/')[-1] in site_dirs and not is_throwaway_path(p):
2021
+ return f'guard settings folder ({p.split("/")[-1]})'
2022
+ for root in _places('abs-tree'):
2023
+ if root.startswith(p + '/') or p == '/':
2024
+ return f'managed settings folder ({root})'
2025
+ for rel in _home_rels((p,)):
2026
+ if any(d.startswith(rel + '/') for d in _places('home-tree')):
2027
+ return f'guard settings folder (~/{rel})'
2028
+ if '/' not in rel and rel.startswith('.') and any(
2029
+ os.path.exists(os.path.join(os.path.expanduser('~'), rel, n))
2030
+ for n in _places('home-agent')):
2031
+ return f'agent hook settings folder (~/{rel})'
2032
+ return None
2033
+
2034
+
2035
+ def _contains_guard(path: str, cwd=None):
2036
+ """展開先として危ない置き場か(一覧の場所そのもの・親・中に .claude フォルダを持つ場所)。"""
2037
+ r = guard_config_reason(path, cwd) or guard_ancestor_reason(path, cwd)
2038
+ if r:
2039
+ return r
2040
+ try:
2041
+ lit = _expand_path(path, cwd)
2042
+ except Exception: # noqa: BLE001
2043
+ return None
2044
+ for sf in _places('site-file'):
2045
+ if os.path.isdir(os.path.join(lit, sf.split('/')[0])):
2046
+ return f'folder holding {sf.split("/")[0]} settings'
2047
+ return None
2048
+
2049
+
2050
+ # リダイレクトの記号(語の頭に付いたもの)。`2>&1` `>&2` のような写しは書き込みではない。
2051
+ _REDIRECT_TOKEN_RE = re.compile(r'^\d*(>>|>\||&>>|&>|>&|<>|>|<<<|<<-?|<)')
2052
+ #: 書き込む先が「最後の引数(か -t の値)」の道具。それより前は読むだけ。
2053
+ _COPY_VERBS = frozenset({'cp', 'install', 'ln', 'rsync', 'ditto', 'scp', 'gcp'})
2054
+ #: 引数ぜんぶが書き換え・削除・権限変更の対象になる道具(エディタで開いて保存する形も含む)。
2055
+ _TOUCH_ALL_VERBS = frozenset({
2056
+ 'tee', 'rm', 'rmdir', 'shred', 'truncate', 'touch', 'chmod', 'chown', 'chgrp', 'chattr',
2057
+ 'setfacl', 'patch', 'sponge', 'unlink', 'mkdir', 'ed', 'ex', 'vi', 'vim', 'nvim', 'nano',
2058
+ 'pico', 'emacs', 'mcedit', 'micro',
2059
+ })
2060
+ #: 置き場ごと消す・権限を変える道具(引数が一覧の場所の親でも止める)。
2061
+ _PARENT_VERBS = frozenset({'rm', 'rmdir', 'shred', 'unlink', 'chmod', 'chown', 'chgrp', 'chattr', 'setfacl'})
2062
+ #: その場で書き換える旗を持つ道具(sed -i・perl -i・ruby -i・yq -i・awk -i inplace)。
2063
+ _INPLACE_VERBS = frozenset({'sed', 'gsed', 'perl', 'ruby', 'yq', 'awk', 'gawk'})
2064
+ _AWK_VERBS = frozenset({'awk', 'gawk', 'mawk', 'nawk'})
2065
+ #: 圧縮を解いて、同じ場所に元の名前で書き出す道具。
2066
+ _DECOMPRESS_VERBS = {'gunzip': '.gz', 'unpigz': '.gz', 'bunzip2': '.bz2', 'unxz': '.xz', 'unzstd': '.zst',
2067
+ 'unlzma': '.lzma'}
2068
+ #: systemctl --user の、~/.config/systemd の中を書き換える操作。
2069
+ _SYSTEMCTL_WRITE = frozenset({
2070
+ 'enable', 'disable', 'reenable', 'edit', 'link', 'mask', 'unmask', 'revert', 'preset',
2071
+ 'set-property', 'add-wants', 'add-requires',
2072
+ })
2073
+ #: 包み(前置き)と、その値を1つ取る旗。剥がして中の道具を見る。
2074
+ _GUARD_WRAPPERS = {
2075
+ 'env': {'-u', '--unset', '-C', '--chdir'}, 'sudo': {'-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T'},
2076
+ 'doas': {'-u', '-C'}, 'nohup': set(), 'time': {'-f', '-o'}, 'command': set(), 'builtin': set(),
2077
+ 'exec': {'-a'}, 'stdbuf': {'-i', '-o', '-e'}, 'setsid': set(), 'timeout': {'-s', '-k', '--signal', '--kill-after'},
2078
+ 'nice': {'-n', '--adjustment'}, 'ionice': {'-c', '-n', '-p'}, 'chronic': set(), 'unbuffer': set(),
2079
+ 'xargs': {'-I', '-i', '-n', '-P', '-d', '-a', '-E', '-L', '-s', '--max-args', '--max-procs', '--delimiter',
2080
+ '--arg-file', '--replace', '--max-lines', '--max-chars'},
2081
+ 'flock': {'-w', '-E', '--timeout', '--conflict-exit-code'},
2082
+ 'busybox': set(), 'toybox': set(), # 多機能の道具(`busybox sed -i …` の中の道具を見る)
2083
+ }
2084
+ # 文の頭に付く予約語(if/then/do …)とサブシェルの括弧。
2085
+ _SHELL_KEYWORDS = frozenset({'if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', '{', '(', 'time'})
2086
+
2087
+
2088
+ def _real_redirect_targets(segment: str):
2089
+ """引用符の外にある本物のリダイレクトの書き込み先(`>` `>>` `>|` `&>` `<>`)。"""
2090
+ out = []
2091
+ head = segment.split('\n', 1)[0] if '<<' in segment else segment
2092
+ quote = None
2093
+ i, n = 0, len(head)
2094
+ while i < n:
2095
+ c = head[i]
2096
+ if quote:
2097
+ if c == '\\' and quote == '"':
2098
+ i += 2
2099
+ continue
2100
+ if c == quote:
2101
+ quote = None
2102
+ i += 1
2103
+ continue
2104
+ if c == '\\':
2105
+ i += 2
2106
+ continue
2107
+ if c in '"\'`':
2108
+ quote = c
2109
+ i += 1
2110
+ continue
2111
+ if c != '>':
2112
+ i += 1
2113
+ continue
2114
+ j = i + 1
2115
+ if j < n and head[j] in '>|':
2116
+ j += 1
2117
+ if j < n and head[j] in '&(':
2118
+ i = j + 1 # `>&2` / `2>&1`(写し)・`>(…)`(プロセス置換・中身は別に測る)は書き込み先ではない
2119
+ continue
2120
+ while j < n and head[j] in ' \t':
2121
+ j += 1
2122
+ k, q, depth = j, None, 0
2123
+ while k < n:
2124
+ ch = head[k]
2125
+ if q:
2126
+ if ch == q:
2127
+ q = None
2128
+ elif ch in '"\'':
2129
+ q = ch
2130
+ elif ch == '`':
2131
+ nb = head.find('`', k + 1) # `…` はひとかたまり
2132
+ k = nb if nb > k else n
2133
+ elif ch == '$' and k + 1 < n and head[k + 1] == '(':
2134
+ depth += 1
2135
+ k += 1
2136
+ elif ch == '(' and depth:
2137
+ depth += 1
2138
+ elif ch == ')' and depth:
2139
+ depth -= 1
2140
+ elif depth == 0 and ch in ' \t;|&<>()':
2141
+ break
2142
+ k += 1
2143
+ word = head[j:k].replace('"', '').replace("'", '')
2144
+ if word:
2145
+ out.append(word)
2146
+ i = k
2147
+ return out
2148
+
2149
+
2150
+ def _plain_args(tokens, start):
2151
+ """動詞より後ろの、リダイレクトを除いた引数。"""
2152
+ out, skip = [], False
2153
+ for t in tokens[start:]:
2154
+ if skip:
2155
+ skip = False
2156
+ continue
2157
+ m = _REDIRECT_TOKEN_RE.match(t)
2158
+ if m:
2159
+ if m.end() == len(t):
2160
+ skip = True
2161
+ continue
2162
+ out.append(t)
2163
+ return out
2164
+
2165
+
2166
+ def _unwrap(tokens):
2167
+ """包み・変数代入・予約語を剥がした、道具の位置と、通った包みの名前の集合。"""
2168
+ i, seen = 0, set()
2169
+ while i < len(tokens):
2170
+ t = tokens[i]
2171
+ if t in _SHELL_KEYWORDS or _ASSIGN_RE.match(t):
2172
+ i += 1
2173
+ continue
2174
+ base = t.split('/')[-1]
2175
+ if base in _GUARD_WRAPPERS:
2176
+ seen.add(base)
2177
+ vals = _GUARD_WRAPPERS[base]
2178
+ i += 1
2179
+ while i < len(tokens) and tokens[i].startswith('-') and tokens[i] != '--':
2180
+ i += 2 if tokens[i] in vals else 1
2181
+ if i < len(tokens) and tokens[i] == '--':
2182
+ i += 1
2183
+ if base == 'env':
2184
+ while i < len(tokens) and _ASSIGN_RE.match(tokens[i]):
2185
+ i += 1
2186
+ if base in ('timeout', 'flock') and i < len(tokens):
2187
+ i += 1 # 時間・鍵のファイル
2188
+ if base in ('nice', 'ionice') and i < len(tokens) and tokens[i].lstrip('-').isdigit():
2189
+ i += 1
2190
+ continue
2191
+ return i, seen
2192
+ return len(tokens), seen
2193
+
2194
+
2195
+ def _brace_variants(word: str, limit: int = 64):
2196
+ """`a/{b,c}.json` → [a/b.json, a/c.json](入れ子なし・数に上限)。"""
2197
+ out = [word]
2198
+ for _ in range(4):
2199
+ nxt = []
2200
+ changed = False
2201
+ for w in out:
2202
+ m = re.search(r'\{([^{}]*,[^{}]*)\}', w)
2203
+ if not m:
2204
+ nxt.append(w)
2205
+ continue
2206
+ changed = True
2207
+ for part in m.group(1).split(','):
2208
+ nxt.append(w[:m.start()] + part + w[m.end():])
2209
+ out = nxt[:limit]
2210
+ if not changed:
2211
+ break
2212
+ return out
2213
+
2214
+
2215
+ def _normalize_segment(seg: str) -> str:
2216
+ """サブシェルの括弧・波括弧の組・行頭の予約語を剥がす(`(cd ~/.claude && rm x)` の形)。"""
2217
+ s = seg.strip()
2218
+ while s[:1] in ('(', '{') and (len(s) == 1 or s[1] in ' \t(' or s[0] == '('):
2219
+ s = s[1:].lstrip()
2220
+ if s.endswith(')') and s.count(')') > s.count('('):
2221
+ s = s[:-1].rstrip()
2222
+ if s.endswith('}') and (s.endswith(' }') or s.endswith(';}')):
2223
+ s = s[:-1].rstrip()
2224
+ return s
2225
+
2226
+
2227
+ def _subst_payloads(text: str):
2228
+ """コマンド置換・プロセス置換の中身(`$(…)`・`` `…` ``・`<(…)`・`>(…)`)。単引用符の中は除く。"""
2229
+ out = []
2230
+ i, n, quote = 0, len(text), None
2231
+ while i < n:
2232
+ c = text[i]
2233
+ if quote == "'":
2234
+ if c == "'":
2235
+ quote = None
2236
+ i += 1
2237
+ continue
2238
+ if c == "'" and quote is None:
2239
+ quote = "'"
2240
+ i += 1
2241
+ continue
2242
+ if c == '"':
2243
+ quote = None if quote == '"' else '"'
2244
+ i += 1
2245
+ continue
2246
+ if c == '`':
2247
+ j = text.find('`', i + 1)
2248
+ if j > i:
2249
+ out.append(text[i + 1:j])
2250
+ i = j + 1
2251
+ continue
2252
+ if c in '$<>' and i + 1 < n and text[i + 1] == '(' and not text.startswith('$((', i):
2253
+ depth, j = 0, i + 1
2254
+ while j < n:
2255
+ if text[j] == '(':
2256
+ depth += 1
2257
+ elif text[j] == ')':
2258
+ depth -= 1
2259
+ if depth == 0:
2260
+ break
2261
+ j += 1
2262
+ out.append(text[i + 2:j])
2263
+ i = j + 1
2264
+ continue
2265
+ i += 1
2266
+ return out
2267
+
2268
+
2269
+ def _heredoc_body(head: str, command: str):
2270
+ """このコマンドの頭に書かれたヒアドキュメントの本文(無ければ None)。"""
2271
+ m = _HEREDOC_START_RE.search(head)
2272
+ if not m or m.group(0) not in command:
2273
+ return None
2274
+ body = _HEREDOC_RE.search(command[command.find(m.group(0)):])
2275
+ return body.group(2) if body else None
2276
+
2277
+
2278
+ def _here_string(tokens):
2279
+ for k, t in enumerate(tokens):
2280
+ if t == '<<<' and k + 1 < len(tokens):
2281
+ return tokens[k + 1]
2282
+ if t.startswith('<<<') and len(t) > 3:
2283
+ return t[3:]
2284
+ return None
2285
+
2286
+
2287
+ def _archive_members(verb, archive, cwd=None):
2288
+ """展開する物の中の名前(読めないときは None=確かめられない)。★名前だけを読む(中身は開かない)。"""
2289
+ if not archive or archive == '-':
2290
+ return None
2291
+ try:
2292
+ import tarfile
2293
+ import zipfile
2294
+ p = _expand_path(archive, cwd)
2295
+ if verb == 'unzip' or zipfile.is_zipfile(p):
2296
+ with zipfile.ZipFile(p) as z:
2297
+ return z.namelist()[:5000]
2298
+ with tarfile.open(p) as t:
2299
+ return t.getnames()[:5000]
2300
+ except Exception: # noqa: BLE001
2301
+ return None
2302
+
2303
+
2304
+ def _patch_targets(tokens, args, head, command, cwd):
2305
+ """patch・git apply が書き換えるファイル(差分の `+++ ` 行・名指しのファイル)。"""
2306
+ strip = 0
2307
+ for k, a in enumerate(args):
2308
+ m = re.match(r'^-p(\d+)$', a) or (re.match(r'^--strip=(\d+)$', a))
2309
+ if m:
2310
+ strip = int(m.group(1))
2311
+ elif a == '-p' and k + 1 < len(args) and args[k + 1].isdigit():
2312
+ strip = int(args[k + 1])
2313
+ text = _heredoc_body(head, command) or ''
2314
+ src = None
2315
+ for k, t in enumerate(tokens):
2316
+ if t == '<' and k + 1 < len(tokens):
2317
+ src = tokens[k + 1]
2318
+ elif t in ('-i', '--input') and k + 1 < len(tokens):
2319
+ src = tokens[k + 1]
2320
+ if src:
2321
+ try:
2322
+ with open(_expand_path(src, cwd), encoding='utf-8', errors='replace') as fh:
2323
+ text += '\n' + fh.read(400000)
2324
+ except Exception: # noqa: BLE001
2325
+ pass
2326
+ out = []
2327
+ for line in text.splitlines():
2328
+ if line.startswith('+++ ') or line.startswith('--- '):
2329
+ name = line[4:].split('\t')[0].strip()
2330
+ if name == '/dev/null':
2331
+ continue
2332
+ parts = name.split('/')
2333
+ out.append('/'.join(parts[strip:]) if strip else name)
2334
+ return out
2335
+
2336
+
2337
+ def _find_danger(args, cwd):
2338
+ """find の探す場所に一覧の場所が入り、消す・書き換える・書き出す形なら理由。"""
2339
+ roots = []
2340
+ for a in args:
2341
+ if a.startswith('-') or a in ('(', ')', '!'):
2342
+ break
2343
+ roots.append(a)
2344
+ roots = roots or ['.']
2345
+ acts = set(a for a in args if a in ('-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprintf',
2346
+ '-fls', '-fprint0'))
2347
+ if not acts:
2348
+ return None
2349
+ for k, a in enumerate(args):
2350
+ if a in ('-fprint', '-fprintf', '-fls', '-fprint0') and k + 1 < len(args):
2351
+ r = guard_config_reason(args[k + 1], cwd)
2352
+ if r:
2353
+ return f'{r} written by find {a}'
2354
+ writes = '-delete' in acts
2355
+ for k, a in enumerate(args):
2356
+ if a in ('-exec', '-execdir', '-ok', '-okdir') and k + 1 < len(args):
2357
+ inner = []
2358
+ for t in args[k + 1:]:
2359
+ if t in (';', '+', '\\;'):
2360
+ break
2361
+ inner.append(t)
2362
+ pos, _ = _unwrap(inner)
2363
+ v = inner[pos].split('/')[-1] if pos < len(inner) else ''
2364
+ if v in _TOUCH_ALL_VERBS or v in _COPY_VERBS or v == 'mv' or v in _INPLACE_VERBS or v in _SHELL_RUNNERS:
2365
+ writes = True
2366
+ if not writes:
2367
+ return None
2368
+ names = [args[k + 1] for k, a in enumerate(args) if a in ('-name', '-iname', '-path', '-ipath', '-wholename')
2369
+ and k + 1 < len(args)]
2370
+ for root in roots:
2371
+ places = []
2372
+ if guard_config_reason(root, cwd):
2373
+ return f'{guard_config_reason(root, cwd)} changed by find'
2374
+ if guard_ancestor_reason(root, cwd) or _contains_guard(root, cwd):
2375
+ base = _expand_path(root, cwd)
2376
+ places = [c for c in _concrete_places(base) if c.startswith(base.rstrip('/') + '/')]
2377
+ for c in places:
2378
+ if not names or any(fnmatch.fnmatchcase(os.path.basename(c), n) or fnmatch.fnmatchcase(c, '*' + n)
2379
+ for n in names):
2380
+ return f'{_place_reason_abs(c) or "guard settings"} changed by find'
2381
+ return None
2382
+
2383
+
2384
+ def _git_write_targets(args, cwd):
2385
+ """git の、作業場所のファイルを上書きする形の書き込み先(checkout/restore の道・clone/worktree の行き先)。"""
2386
+ where, rest = cwd, list(args)
2387
+ while rest and rest[0].startswith('-'):
2388
+ f = rest.pop(0)
2389
+ if f == '-C' and rest:
2390
+ p = rest.pop(0)
2391
+ where = _expand_path(p, where)
2392
+ elif f in ('-c', '--git-dir', '--work-tree') and rest:
2393
+ rest.pop(0)
2394
+ if not rest:
2395
+ return where, []
2396
+ sub, sargs = rest[0], rest[1:]
2397
+ if sub in ('checkout', 'restore'):
2398
+ if '--' in sargs:
2399
+ paths = sargs[sargs.index('--') + 1:]
2400
+ else:
2401
+ vals = {'-s', '--source', '-b', '-B', '--orphan', '--conflict'}
2402
+ paths, skip = [], False
2403
+ for t in sargs:
2404
+ if skip:
2405
+ skip = False
2406
+ continue
2407
+ if t in vals:
2408
+ skip = True
2409
+ continue
2410
+ if not t.startswith('-'):
2411
+ paths.append(t)
2412
+ return where, paths
2413
+ if sub in ('apply', 'am'):
2414
+ return where, ['__patch__']
2415
+ if sub == 'clone':
2416
+ pos = [t for t in sargs if not t.startswith('-')]
2417
+ return where, pos[1:2]
2418
+ if sub == 'worktree' and sargs[:1] == ['add']:
2419
+ pos = [t for t in sargs[1:] if not t.startswith('-')]
2420
+ return where, pos[:1]
2421
+ return where, []
2422
+
2423
+
2424
+ def _guard_write_segment(segment: str, cwd=None, command='', depth=0, env=None):
2425
+ """本物の1コマンドが番人の設定を書き換えるなら理由。"""
2426
+ segment = _normalize_segment(segment)
2427
+ for t in _real_redirect_targets(segment):
2428
+ for v in _brace_variants(t):
2429
+ r = guard_config_reason(v, cwd)
2430
+ if r:
2431
+ return f'redirect into {r}'
2432
+ head = segment.split('\n', 1)[0] if '<<' in segment else segment
2433
+ # ★コマンド置換・プロセス置換の中身も、1つのコマンドとして測り直す。
2434
+ if depth < _UNWRAP_MAX_DEPTH + 1:
2435
+ for inner in _subst_payloads(head):
2436
+ hit = _guard_write_scan(inner, cwd, depth + 1, env)
2437
+ if hit:
2438
+ return f'{hit} (inside $(…))'
2439
+ tokens = tokens_of(head)
2440
+ pos, wrappers = _unwrap(tokens)
2441
+ if pos >= len(tokens):
2442
+ return None
2443
+ verb = tokens[pos].split('/')[-1]
2444
+ args = _plain_args(tokens, pos + 1)
2445
+ paths = [a for a in args if not a.startswith('-')]
2446
+ targets, parents = [], []
2447
+
2448
+ # ── 中身を走らせる形(シェル・eval・<<<・ヒアドキュメント・プログラム) ──
2449
+ if verb in _SHELL_RUNNERS or verb in ('eval', 'source', '.'):
2450
+ if depth <= _UNWRAP_MAX_DEPTH:
2451
+ payloads = []
2452
+ if verb == 'eval':
2453
+ payloads.append(' '.join(args))
2454
+ payloads += [tokens[k + 1] for k, t in enumerate(tokens) if t in _CODE_FLAGS and k + 1 < len(tokens)]
2455
+ for extra in (_here_string(tokens), _heredoc_body(head, command)):
2456
+ if extra:
2457
+ payloads.append(extra)
2458
+ for p in payloads:
2459
+ hit = _guard_write_scan(p, cwd, depth + 1)
2460
+ if hit:
2461
+ return f'{hit} (inside {verb})'
2462
+ return None
2463
+ if verb in _CODE_RUNNERS:
2464
+ codes = [tokens[k + 1] for k, t in enumerate(tokens) if t in _CODE_FLAGS and k + 1 < len(tokens)]
2465
+ for extra in (_here_string(tokens), _heredoc_body(head, command)):
2466
+ if extra:
2467
+ codes.append(extra)
2468
+ for code in codes:
2469
+ hit = _code_guard_write(code, cwd)
2470
+ if hit:
2471
+ return f'{hit} (inside {verb})'
2472
+ if not (verb in ('perl', 'ruby') and any(re.match(r'^-[a-zA-Z]*i', a) for a in args)):
2473
+ return None
2474
+
2475
+ # ── 書き込む先を集める(道具ごと) ──
2476
+ if verb in _TOUCH_ALL_VERBS:
2477
+ targets = paths
2478
+ if verb in _PARENT_VERBS:
2479
+ parents = paths
2480
+ if verb == 'patch':
2481
+ targets = targets + _patch_targets(tokens, args, head, command, cwd)
2482
+ elif verb == 'mv':
2483
+ targets = parents = paths # 動かす元も、動かした先も変わる
2484
+ elif verb in _COPY_VERBS:
2485
+ dest = None
2486
+ for k, a in enumerate(args):
2487
+ if a in ('-t', '--target-directory') and k + 1 < len(args):
2488
+ dest = args[k + 1]
2489
+ elif a.startswith('--target-directory='):
2490
+ dest = a.split('=', 1)[1]
2491
+ if verb == 'install' and '-d' in args:
2492
+ targets = paths
2493
+ else:
2494
+ srcs = [p for p in paths if p != dest]
2495
+ if dest is None and paths:
2496
+ dest, srcs = paths[-1], paths[:-1]
2497
+ if dest and ':' in dest.split('/')[0] and verb in ('scp', 'rsync'):
2498
+ dest = None # 別の機械への書き込み(ここは見ない)
2499
+ if dest:
2500
+ base = _expand_path(dest, cwd)
2501
+ targets = [dest] + [os.path.join(base, os.path.basename(s.rstrip('/'))) for s in srcs if s.rstrip('/')]
2502
+ recursive = any(a in ('-r', '-R', '--recursive', '-a', '--archive')
2503
+ or (re.match(r'^-[A-Za-z]+$', a) and ('r' in a or 'R' in a or 'a' in a))
2504
+ for a in args)
2505
+ if recursive or verb in ('rsync', 'ln', 'ditto'):
2506
+ parents = [dest]
2507
+ elif verb == 'dd':
2508
+ targets = [a[3:] for a in args if a.startswith('of=')]
2509
+ elif verb in _INPLACE_VERBS and (
2510
+ is_inplace_edit('sed' if verb in ('gsed', 'yq') else verb, tokens[pos:])
2511
+ or any(re.match(r'^-[a-zA-Z]*i', a) for a in args if not a.startswith('--'))
2512
+ or (verb in _AWK_VERBS and 'inplace' in args)):
2513
+ targets = paths # `perl -pi -e …`・`awk -i inplace …` のように旗をまとめた形も
2514
+ elif verb in _DECOMPRESS_VERBS or (verb in ('gzip', 'bzip2', 'xz', 'zstd', 'pigz') and ('-d' in args or '--decompress' in args)):
2515
+ if '-c' not in args and '--stdout' not in args:
2516
+ for p in paths:
2517
+ ext = _DECOMPRESS_VERBS.get(verb) or os.path.splitext(p)[1]
2518
+ targets.append(p[:-len(ext)] if ext and p.endswith(ext) else p)
2519
+ elif verb in ('curl', 'wget'):
2520
+ for k, a in enumerate(args):
2521
+ if a in ('-o', '--output', '-O', '--output-document') and k + 1 < len(args) and not (verb == 'curl' and a == '-O'):
2522
+ targets.append(args[k + 1])
2523
+ elif a.startswith('--output=') or a.startswith('--output-document='):
2524
+ targets.append(a.split('=', 1)[1])
2525
+ elif verb == 'wget' and a in ('-P', '--directory-prefix') and k + 1 < len(args):
2526
+ parents.append(args[k + 1])
2527
+ targets.append(args[k + 1] + '/x')
2528
+ elif verb in ('tar', 'bsdtar', 'gtar', 'unzip', '7z', '7za', 'cpio', 'ar'):
2529
+ extract = (verb == 'unzip' or (verb in ('7z', '7za') and paths[:1] in (['x'], ['e']))
2530
+ or (verb == 'cpio' and any(a in ('-i', '--extract') or re.match(r'^-[a-zA-Z]*i', a) for a in args))
2531
+ or (verb == 'ar' and paths[:1] and 'x' in paths[0])
2532
+ or (verb in ('tar', 'bsdtar', 'gtar') and (
2533
+ any(a in ('--extract', '--get') or re.match(r'^-[a-zA-Z]*x[a-zA-Z]*$', a) for a in args)
2534
+ or (args and not args[0].startswith('-') and 'x' in args[0]))))
2535
+ if extract:
2536
+ dest, archive = None, None
2537
+ for k, a in enumerate(args):
2538
+ if a in ('-C', '--directory', '-d') and k + 1 < len(args):
2539
+ dest = args[k + 1]
2540
+ elif a.startswith('--directory='):
2541
+ dest = a.split('=', 1)[1]
2542
+ elif a.startswith('-o') and verb in ('7z', '7za') and len(a) > 2:
2543
+ dest = a[2:]
2544
+ elif a in ('-f', '--file') and k + 1 < len(args):
2545
+ archive = args[k + 1]
2546
+ elif a.startswith('--file='):
2547
+ archive = a.split('=', 1)[1]
2548
+ if verb in ('tar', 'bsdtar', 'gtar') and archive is None:
2549
+ for k, a in enumerate(args):
2550
+ if k + 1 < len(args) and (re.match(r'^-[a-zA-Z]*f$', a)
2551
+ or (k == 0 and not a.startswith('-') and a.endswith('f'))):
2552
+ archive = args[k + 1]
2553
+ break
2554
+ if verb == 'unzip':
2555
+ archive = next((p for p in paths if p != dest), None)
2556
+ base = dest or '.'
2557
+ members = _archive_members(verb, archive, cwd)
2558
+ if members is None:
2559
+ r = _contains_guard(base, cwd)
2560
+ if r:
2561
+ return f'{r} (unpacked by {verb}・中身を確かめられない)'
2562
+ else:
2563
+ root = _expand_path(base, cwd)
2564
+ targets = [os.path.join(root, m.lstrip('/')) for m in members]
2565
+ parents = [base] if guard_config_reason(base, cwd) else []
2566
+ elif verb == 'git':
2567
+ where, gpaths = _git_write_targets(args, cwd)
2568
+ if gpaths == ['__patch__']:
2569
+ gpaths = _patch_targets(tokens, args, head, command, where)
2570
+ for p in gpaths:
2571
+ r = guard_config_reason(p, where) or guard_ancestor_reason(p, where)
2572
+ if r:
2573
+ return f'{r} overwritten by git'
2574
+ return None
2575
+ elif verb == 'find':
2576
+ return _find_danger(args, cwd)
2577
+ elif verb == 'systemctl' and '--user' in args:
2578
+ if any(a in _SYSTEMCTL_WRITE for a in paths):
2579
+ return 'guard directory (~/.config/systemd) changed by systemctl --user'
2580
+ return None
2581
+ elif verb in ('mount', 'umount'):
2582
+ # ★一覧の場所(や親)へ mount で覆う=設定を丸ごと差し替える。
2583
+ for p in paths:
2584
+ r = guard_config_reason(p, cwd) or guard_ancestor_reason(p, cwd)
2585
+ if r:
2586
+ return f'{r} shadowed by {verb}'
2587
+ return None
2588
+ elif verb in ('claude', 'claude-code') and paths[:1] == ['config'] and paths[1:2] and \
2589
+ paths[1] in ('set', 'add', 'remove', 'rm', 'reset'):
2590
+ return 'guard settings changed by claude config'
2591
+ elif verb in _AWK_VERBS:
2592
+ script = next((a for a in args if not a.startswith('-')), '')
2593
+ for m in re.finditer(r'>>?\s*"([^"]+)"', script):
2594
+ targets.append(m.group(1))
2595
+ for m in re.finditer(r'system\s*\(\s*"([^"]+)"', script):
2596
+ hit = _guard_write_scan(m.group(1), cwd, depth + 1)
2597
+ if hit:
2598
+ return f'{hit} (inside awk system)'
2599
+
2600
+ # ★xargs の後ろの書き込みは、書き込み先が標準入力から来る=コマンド全体に出てくる場所で照らす。
2601
+ if 'xargs' in wrappers and (verb in _TOUCH_ALL_VERBS or verb in _COPY_VERBS or verb == 'mv'
2602
+ or verb in _INPLACE_VERBS):
2603
+ for seg in split_commands(command or segment, bodies=False):
2604
+ for t in tokens_of(_normalize_segment(seg)):
2605
+ if t.startswith('-'):
2606
+ continue
2607
+ r = guard_config_reason(t, cwd) or (guard_ancestor_reason(t, cwd) if verb in _PARENT_VERBS
2608
+ or verb == 'mv' else None)
2609
+ if r:
2610
+ return f'{r} changed by xargs {verb}'
2611
+
2612
+ for p in targets:
2613
+ for v in _brace_variants(p):
2614
+ r = guard_config_reason(v, cwd)
2615
+ if r:
2616
+ return f'{r} written by {verb}'
2617
+ for p in parents:
2618
+ for v in _brace_variants(p):
2619
+ r = guard_ancestor_reason(v, cwd)
2620
+ if r:
2621
+ return f'{r} changed by {verb}'
2622
+ return None
2623
+
2624
+
2625
+ def _guard_write_scan(command: str, cwd=None, depth: int = 0, env=None):
2626
+ """コマンド全体で、番人の設定への書き込みを探す(本物の区切り・`cd`/`pushd` を追う)。
2627
+
2628
+ ★同じコマンドの中で先に決めた変数(`P=~/.claude; … > $P/settings.json`・`export P=…`・
2629
+ `for f in 場所 …; do … "$f"`)は解いてから測る。解けない変数は後ろの字面で照らす。
2630
+ """
2631
+ if depth > _UNWRAP_MAX_DEPTH + 1 or not command:
2632
+ return None
2633
+ here, env = cwd, dict(env or {})
2634
+ for seg in split_commands(command, bodies=False):
2635
+ seg = _normalize_segment(seg)
2636
+ toks = tokens_of(seg)
2637
+ if toks and toks[0] == 'export':
2638
+ toks = toks[1:]
2639
+ if toks and all(_ASSIGN_RE.match(t) for t in toks):
2640
+ for t in toks:
2641
+ k, _, v = t.partition('=')
2642
+ if v.startswith('$(') or v.startswith('`'):
2643
+ hit = _guard_write_scan(v.strip('$()`'), here if here is not _UNKNOWN_DIR else None,
2644
+ depth + 1, env)
2645
+ if hit:
2646
+ return hit
2647
+ env[k] = _expand_vars(v, env)
2648
+ continue
2649
+ if len(toks) >= 3 and toks[0] == 'for' and toks[2] == 'in':
2650
+ words = [_expand_vars(w, env) for w in toks[3:]]
2651
+ pick = next((w for w in words if guard_config_reason(w, cwd) or guard_ancestor_reason(w, cwd)), None)
2652
+ env[toks[1]] = pick or (words[0] if words else '')
2653
+ continue
2654
+ seg = _expand_vars(seg, env)
2655
+ toks = tokens_of(seg)
2656
+ pos, _ = _unwrap(toks)
2657
+ v = toks[pos].split('/')[-1] if pos < len(toks) else ''
2658
+ if v in ('cd', 'pushd'):
2659
+ here = _cd_dest(toks[pos:] if v == 'cd' else ['cd'] + toks[pos + 1:], here)
2660
+ continue
2661
+ where = None if here is _UNKNOWN_DIR else here
2662
+ hit = _guard_write_segment(seg, where, command, depth, env)
2663
+ if hit:
2664
+ return hit
2665
+ return None
2666
+
2667
+
2668
+ def _expand_vars(text: str, env) -> str:
2669
+ """同じコマンドの中で決めた変数だけを解く($HOME などほかの変数は触らない)。"""
2670
+ if not env:
2671
+ return text
2672
+ return re.sub(r'\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?',
2673
+ lambda m: env.get(m.group(1), m.group(0)), text)
2674
+
2675
+
2676
+ # プログラム(python3 -c・node -e・ruby -e・perl -e・<<EOF・<<<)の中の、一覧の場所の書き方。
2677
+ # ① 文字列そのものが場所('~/.claude/settings.json')→ guard_config_reason で測る
2678
+ # ② 部品で組み立てた形(Path.home() / '.claude' / 'settings.json'・join(h, '.<隠しフォルダ>', 'hooks.json'))
2679
+ # ③ ホームを式で足した後ろの部分(homedir() + '/.tyhld/hooks/…'・"$ENV{HOME}/.claude/…")
2680
+ # ★報告の文の中に場所の名前が出てくるだけ(空白を含む文字列)は場所と読まない。
2681
+ _CODE_GUARD_JOIN_RE = re.compile(
2682
+ r'''['"]\.claude['"]\s*[,/+]\s*['"](settings[\w.]*\.json|skills)['"/]'''
2683
+ r'''|['"]\.tyhld['"]\s*[,/+]\s*['"](hooks|bin)['"/]'''
2684
+ r'''|['"]\.[\w-]+['"]\s*[,/+]\s*['"](hooks\.json|config\.toml)['"]'''
2685
+ r'''|['"]\.config['"]\s*[,/+]\s*['"]systemd['"/]''')
2686
+ _CODE_PATH_LITERAL_RE = re.compile(r'''['"`]>{0,2}([^'"`\s*?\[\]]{1,300})['"`]''')
2687
+ # ホームを式で足した後ろの部分。
2688
+ _CODE_HOME_TAIL_RE = re.compile(r'(/\.[^/\s]+/.*)$')
2689
+ # プログラムの中の「書く」書き方(開いて書く・写す・動かす・消す・権限を変える・展開する・外の道具を呼ぶ)。
2690
+ _CODE_WRITE_RE = re.compile(
2691
+ r'''open\s*\([^)]*['"](?:[wax]|r\+)[bt+]*['"]|mode\s*=\s*['"](?:[wax]|r\+)'''
2692
+ r'''|\.write(_text|_bytes)?\s*\(|json\.dump\s*\(|shutil\.(copy\w*|move|rmtree|unpack_archive|chown)'''
2693
+ r'''|os\.(rename|replace|remove|unlink|symlink|rmdir|chmod|chown|truncate|system|popen)\s*\('''
2694
+ r'''|\.(unlink|rename|replace|symlink_to|touch|chmod|extractall|extract)\s*\('''
2695
+ r'''|writefile|appendfile|writetextfile|createwritestream|fs\.(write|copy|rename|rm|unlink|symlink|cp|chmod|truncate)\w*'''
2696
+ r'''|file\.write|io\.write|bun\.write|fileutils\.|subprocess\.|child_process|execsync|spawnsync|\bsystem\s*\('''
2697
+ r'''|['"]>>?['"]|['"]>>?\s*[$\w/~.]''', re.IGNORECASE)
2698
+
2699
+
2700
+ def _code_mentions_guard(stmt: str, cwd=None) -> bool:
2701
+ if _CODE_GUARD_JOIN_RE.search(stmt):
2702
+ return True
2703
+ for m in _CODE_PATH_LITERAL_RE.finditer(stmt):
2704
+ lit = m.group(1)
2705
+ if '/' not in lit:
2706
+ continue
2707
+ if guard_config_reason(lit, cwd):
2708
+ return True
2709
+ tail = _CODE_HOME_TAIL_RE.search(lit)
2710
+ if tail and lit[:tail.start()] and re.search(r'[$}{)]', lit[:tail.start()]) \
2711
+ and (guard_config_reason('~' + tail.group(1), cwd) or _tail_reason(tail.group(1))):
2712
+ return True
2713
+ if tail and not lit[:tail.start()] and guard_config_reason('~' + tail.group(1), cwd):
2714
+ return True
2715
+ return False
2716
+
2717
+
2718
+ def _code_guard_write(text: str, cwd=None):
2719
+ """プログラムの中で、一覧の場所を書き換えるなら理由。★読むだけ(json.load)は止めない。
2720
+
2721
+ ★場所が【書く呼び出しと同じ文】に出てくるか、場所を入れた変数が書く文で使われるときだけ止める
2722
+ (別のファイルを書き換えるプログラムの文の中に、場所の名前が書いてあるだけでは止めない)。
2723
+ """
2724
+ if not text or not _CODE_WRITE_RE.search(text):
2725
+ return None
2726
+ names = set()
2727
+ for stmt in re.split(r'[\n;]', text):
2728
+ if not stmt.strip():
2729
+ continue
2730
+ if _code_mentions_guard(stmt, cwd):
2731
+ if _CODE_WRITE_RE.search(stmt):
2732
+ return 'guard settings written by a program'
2733
+ m = re.match(r'\s*(?:const |let |var |my )?\$?([A-Za-z_][A-Za-z0-9_]*)\s*=[^=]', stmt)
2734
+ if m:
2735
+ names.add(m.group(1))
2736
+ continue
2737
+ if names and _CODE_WRITE_RE.search(stmt) and any(
2738
+ re.search(rf'(?<![\w.]){re.escape(n)}\b', stmt) for n in names):
2739
+ return 'guard settings written by a program'
2740
+ return None
2741
+
2742
+
1710
2743
  # ═════════════════════════════════════════════════════════════════════════════
1711
2744
  # 2) 3関門(gate)
1712
2745
  # ═════════════════════════════════════════════════════════════════════════════
@@ -1871,7 +2904,8 @@ def decide_bash(command: str, cwd=None):
1871
2904
  if hit:
1872
2905
  return _r(DENY, f'danger: {hit}')
1873
2906
 
1874
- segments = split_segments(command)
2907
+ # ★機械的な区切りに、本物の区切りを足して測る(便 2.5・`grep -n "x|y" .env` の動詞を取り戻す)。
2908
+ segments = _real_segments(command)
1875
2909
 
1876
2910
  # ---- 1) 危険(セグメントごと・対象と作用) ----
1877
2911
  for seg in segments:
@@ -1895,6 +2929,11 @@ def decide_bash(command: str, cwd=None):
1895
2929
  if hit:
1896
2930
  return _r(DENY, f'danger: {hit}')
1897
2931
 
2932
+ # ---- 1c') 番人の設定への書き込み(便 2.5・リダイレクト・tee・cp・mv・sed -i ほか) ----
2933
+ hit = _guard_write_scan(command, cwd)
2934
+ if hit:
2935
+ return _r(DENY, f'danger: {hit}')
2936
+
1898
2937
  # ---- 1d) 抜け道(便 fc22bc34・main 直 push・保護の迂回と変更・秘密の値・検査の省略) ----
1899
2938
  hit = _bypass_scan(command, cwd)
1900
2939
  if hit:
@@ -1948,8 +2987,11 @@ def decide_write(file_path: str, cwd=None):
1948
2987
  # 既定は守る側=使い捨てだと確実に言えるときだけ通す(本物の場所は1つも漏れない)。
1949
2988
  # ★字面だけでなく、近道(symlink)を解いた実体も見る。別名の近道
1950
2989
  # (例 /tmp/shortcut → ~/.claude)から本物へ書き込むのを塞ぐ。
1951
- if is_guard_settings_path(path) and not is_throwaway_path(path):
1952
- return _r(DENY, 'danger: write to guard settings (.claude/settings*.json)')
2990
+ # ★Bash と同じ場所の一覧(GUARD_PLACES)だけで守る(便 2.6・場所を2か所に書かない)。
2991
+ gr = guard_config_reason(path)
2992
+ if gr:
2993
+ return _r(DENY, f'danger: write to {gr}')
2994
+ # ★前からの広い守り(~/.tyhld のどこでも)は、弱めないためにそのまま残す。
1953
2995
  comps = [c for c in path.split('/') if c]
1954
2996
  if '.tyhld' in comps:
1955
2997
  return _r(DENY, 'danger: write to guard directory (.tyhld)')