@tyhld/conductor 0.12.21 → 0.12.23

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,6 +29,8 @@
29
29
 
30
30
  ★このモジュールは標準ライブラリだけで動き、製品固有の名前を持たない(切り出し可能)。
31
31
  """
32
+ import fnmatch
33
+ import glob
32
34
  import os
33
35
  import re
34
36
  import shlex
@@ -412,9 +414,8 @@ def _under(path: str, root: str) -> bool:
412
414
 
413
415
 
414
416
  def _named_guard_settings(path: str) -> bool:
415
- """字面が「.claude/settings…」の形か(名前だけの判定・単体では使わない)。"""
416
- comps = [c for c in path.replace('\\', '/').split('/') if c]
417
- return len(comps) >= 2 and comps[-2] == '.claude' and comps[-1].startswith('settings')
417
+ """字面が「.claude/settings…」の形か(名前だけの判定・単体では使わない)。★一覧 GUARD_PLACES から引く。"""
418
+ return _site_file_match(path)
418
419
 
419
420
 
420
421
  def is_guard_settings_path(path: str) -> bool:
@@ -1826,36 +1827,59 @@ def _real_segments(command: str):
1826
1827
 
1827
1828
 
1828
1829
  # ═════════════════════════════════════════════════════════════════════════════
1829
- # 番人の設定への書き込み(DENY・便 2.5・えふさん確定 2026-09-18)
1830
+ # 番人の設定を弱める書き込み(DENY・便 2.5 → 便 2.6 で「手段を問わず」へ・えふさん確定 2026-09-18)
1830
1831
  #
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
- # 「書き込む先」だけを見る。報告やコミットの説明に同じ場所の名前が書いてあるだけでは止めない。
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
1845
  # ═════════════════════════════════════════════════════════════════════════════
1846
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')
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
1855
1879
 
1856
1880
 
1857
1881
  def _expand_path(path: str, cwd=None) -> str:
1858
- """字面のパスを、近道を解かずに絶対パスへ($HOME・~・相対を解く)。"""
1882
+ """字面のパスを、近道を解かずに絶対パスへ($HOME・${HOME}・~・相対を解く)。"""
1859
1883
  q = normalize_redirect_target(path)
1860
1884
  for var in ('${HOME}', '$HOME'):
1861
1885
  if q == var or q.startswith(var + '/'):
@@ -1868,21 +1892,78 @@ def _expand_path(path: str, cwd=None) -> str:
1868
1892
  return os.path.normpath(q)
1869
1893
 
1870
1894
 
1895
+ def _homes():
1896
+ return {h for h in (os.path.expanduser('~').rstrip('/'), _home_dir().rstrip('/')) if h}
1897
+
1898
+
1871
1899
  def _home_rels(abs_paths):
1872
1900
  """絶対パスたちの、ホームからの相対(ホームの外は落とす)。字面のホームと実体のホームの両方。"""
1873
- homes = {os.path.expanduser('~').rstrip('/'), _home_dir().rstrip('/')}
1874
1901
  out = []
1875
1902
  for p in abs_paths:
1876
- for h in homes:
1877
- if h and p.startswith(h + '/'):
1903
+ for h in _homes():
1904
+ if p.startswith(h + '/'):
1878
1905
  out.append(p[len(h) + 1:])
1879
1906
  return out
1880
1907
 
1881
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
+
1882
1961
  def guard_config_reason(path: str, cwd=None):
1883
1962
  """書き込むと番人の設定が変わる場所なら理由、そうでなければ None(★読むだけには使わない)。
1884
1963
 
1885
1964
  ★字面と、近道(symlink)を解いた実体の両方を見る(別名から本物へ書くのを塞ぐ)。
1965
+ ★解けない変数($D/.tyhld/hooks/…)は後ろの字面で照らす。* を含む書き方は、実在の一致と、
1966
+ 一覧の場所の例との一致の両方で照らす(確かめられないものは守る側)。
1886
1967
  """
1887
1968
  if not path or not path.strip():
1888
1969
  return None
@@ -1892,59 +1973,120 @@ def guard_config_reason(path: str, cwd=None):
1892
1973
  except Exception: # noqa: BLE001
1893
1974
  return None
1894
1975
  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})'
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
1904
2001
  return None
1905
2002
 
1906
2003
 
1907
- def _guard_parent_reason(path: str, cwd=None):
1908
- """番人の設定を中に持つ置き場そのもの(~/.claude・~/.tyhld・~/.config・現場の .claude・
1909
- ホーム直下の隠しフォルダで hooks.json か config.toml を持つもの)。"""
2004
+ def guard_ancestor_reason(path: str, cwd=None):
2005
+ """一覧の場所を【中に持つ】置き場(消す・動かす・フォルダごと写し込む・展開すると設定が変わる)。
2006
+
2007
+ ホームそのもの・~/.claude・~/.tyhld・~/.config・各現場の .claude・管理設定の親・
2008
+ ホーム直下の隠しフォルダで一覧の名前のファイルを持つもの。
2009
+ """
1910
2010
  try:
1911
2011
  lit = _expand_path(path, cwd)
1912
2012
  real = resolved_path(lit)
1913
2013
  except Exception: # noqa: BLE001
1914
2014
  return None
1915
2015
  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})'
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'
1925
2047
  return None
1926
2048
 
1927
2049
 
1928
2050
  # リダイレクトの記号(語の頭に付いたもの)。`2>&1` `>&2` のような写しは書き込みではない。
1929
- _REDIRECT_TOKEN_RE = re.compile(r'^\d*(>>|>\||&>>|&>|>&|>|<<<|<<-?|<>|<)')
2051
+ _REDIRECT_TOKEN_RE = re.compile(r'^\d*(>>|>\||&>>|&>|>&|<>|>|<<<|<<-?|<)')
1930
2052
  #: 書き込む先が「最後の引数(か -t の値)」の道具。それより前は読むだけ。
1931
- _COPY_VERBS = frozenset({'cp', 'install', 'ln', 'rsync', 'ditto'})
1932
- #: 引数ぜんぶが書き換え・削除の対象になる道具。
2053
+ _COPY_VERBS = frozenset({'cp', 'install', 'ln', 'rsync', 'ditto', 'scp', 'gcp'})
2054
+ #: 引数ぜんぶが書き換え・削除・権限変更の対象になる道具(エディタで開いて保存する形も含む)。
1933
2055
  _TOUCH_ALL_VERBS = frozenset({
1934
- 'tee', 'rm', 'rmdir', 'shred', 'truncate', 'touch', 'chmod', 'chown', 'chgrp', 'patch',
1935
- 'sponge', 'unlink', 'mkdir',
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',
1936
2059
  })
1937
- #: その場で書き換える旗を持つ道具(sed -i・perl -i・yq -i など)。
1938
- _INPLACE_VERBS = frozenset({'sed', 'perl', 'ruby', 'yq'})
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'}
1939
2068
  #: systemctl --user の、~/.config/systemd の中を書き換える操作。
1940
2069
  _SYSTEMCTL_WRITE = frozenset({
1941
2070
  'enable', 'disable', 'reenable', 'edit', 'link', 'mask', 'unmask', 'revert', 'preset',
1942
2071
  'set-property', 'add-wants', 'add-requires',
1943
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'})
1944
2086
 
1945
2087
 
1946
2088
  def _real_redirect_targets(segment: str):
1947
- """引用符の外にある本物のリダイレクトの書き込み先(`>` `>>` `>|` `&>`)。"""
2089
+ """引用符の外にある本物のリダイレクトの書き込み先(`>` `>>` `>|` `&>` `<>`)。"""
1948
2090
  out = []
1949
2091
  head = segment.split('\n', 1)[0] if '<<' in segment else segment
1950
2092
  quote = None
@@ -1973,11 +2115,11 @@ def _real_redirect_targets(segment: str):
1973
2115
  if j < n and head[j] in '>|':
1974
2116
  j += 1
1975
2117
  if j < n and head[j] in '&(':
1976
- i = j + 1 # `>&2` / `2>&1`(写し)・`>(…)`(プロセス置換)は書き込み先ではない
2118
+ i = j + 1 # `>&2` / `2>&1`(写し)・`>(…)`(プロセス置換・中身は別に測る)は書き込み先ではない
1977
2119
  continue
1978
2120
  while j < n and head[j] in ' \t':
1979
2121
  j += 1
1980
- k, q = j, None
2122
+ k, q, depth = j, None, 0
1981
2123
  while k < n:
1982
2124
  ch = head[k]
1983
2125
  if q:
@@ -1985,7 +2127,17 @@ def _real_redirect_targets(segment: str):
1985
2127
  q = None
1986
2128
  elif ch in '"\'':
1987
2129
  q = ch
1988
- elif ch in ' \t;|&<>()':
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;|&<>()':
1989
2141
  break
1990
2142
  k += 1
1991
2143
  word = head[j:k].replace('"', '').replace("'", '')
@@ -2011,46 +2163,322 @@ def _plain_args(tokens, start):
2011
2163
  return out
2012
2164
 
2013
2165
 
2014
- def _verb_pos(tokens):
2015
- """verb_of と同じ読み方で、動詞の位置を返す(無ければ len(tokens))。"""
2016
- i = 0
2166
+ def _unwrap(tokens):
2167
+ """包み・変数代入・予約語を剥がした、道具の位置と、通った包みの名前の集合。"""
2168
+ i, seen = 0, set()
2017
2169
  while i < len(tokens):
2018
2170
  t = tokens[i]
2019
- if _ASSIGN_RE.match(t):
2171
+ if t in _SHELL_KEYWORDS or _ASSIGN_RE.match(t):
2020
2172
  i += 1
2021
2173
  continue
2022
2174
  base = t.split('/')[-1]
2023
- if base in _WRAPPERS:
2175
+ if base in _GUARD_WRAPPERS:
2176
+ seen.add(base)
2177
+ vals = _GUARD_WRAPPERS[base]
2024
2178
  i += 1
2025
- while i < len(tokens) and tokens[i].startswith('-'):
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] == '--':
2026
2182
  i += 1
2027
- if base in ('timeout', 'nice', 'ionice') and i < len(tokens) \
2028
- and not tokens[i].startswith('-'):
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():
2029
2189
  i += 1
2030
2190
  continue
2031
- return i
2032
- return len(tokens)
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
2033
2267
 
2034
2268
 
2035
- def _guard_write_segment(segment: str, cwd=None):
2036
- """本物の1コマンドが番人の設定へ書くなら理由。"""
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)
2037
2427
  for t in _real_redirect_targets(segment):
2038
- r = guard_config_reason(t, cwd)
2039
- if r:
2040
- return f'redirect into {r}'
2428
+ for v in _brace_variants(t):
2429
+ r = guard_config_reason(v, cwd)
2430
+ if r:
2431
+ return f'redirect into {r}'
2041
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 $(…))'
2042
2439
  tokens = tokens_of(head)
2043
- pos = _verb_pos(tokens)
2440
+ pos, wrappers = _unwrap(tokens)
2044
2441
  if pos >= len(tokens):
2045
2442
  return None
2046
2443
  verb = tokens[pos].split('/')[-1]
2047
2444
  args = _plain_args(tokens, pos + 1)
2048
2445
  paths = [a for a in args if not a.startswith('-')]
2049
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
+ # ── 書き込む先を集める(道具ごと) ──
2050
2476
  if verb in _TOUCH_ALL_VERBS:
2051
2477
  targets = paths
2052
- if verb in ('rm', 'rmdir', 'shred', 'unlink'):
2478
+ if verb in _PARENT_VERBS:
2053
2479
  parents = paths
2480
+ if verb == 'patch':
2481
+ targets = targets + _patch_targets(tokens, args, head, command, cwd)
2054
2482
  elif verb == 'mv':
2055
2483
  targets = parents = paths # 動かす元も、動かした先も変わる
2056
2484
  elif verb in _COPY_VERBS:
@@ -2060,64 +2488,180 @@ def _guard_write_segment(segment: str, cwd=None):
2060
2488
  dest = args[k + 1]
2061
2489
  elif a.startswith('--target-directory='):
2062
2490
  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]
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]
2074
2507
  elif verb == 'dd':
2075
2508
  targets = [a[3:] for a in args if a.startswith('of=')]
2076
2509
  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 …` のように旗をまとめた形も
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)
2080
2577
  elif verb == 'systemctl' and '--user' in args:
2081
2578
  if any(a in _SYSTEMCTL_WRITE for a in paths):
2082
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
+
2083
2612
  for p in targets:
2084
- r = guard_config_reason(p, cwd)
2085
- if r:
2086
- return f'{r} written by {verb}'
2613
+ for v in _brace_variants(p):
2614
+ r = guard_config_reason(v, cwd)
2615
+ if r:
2616
+ return f'{r} written by {verb}'
2087
2617
  for p in parents:
2088
- r = _guard_parent_reason(p, cwd)
2089
- if r:
2090
- return f'{r} changed by {verb}'
2618
+ for v in _brace_variants(p):
2619
+ r = guard_ancestor_reason(v, cwd)
2620
+ if r:
2621
+ return f'{r} changed by {verb}'
2091
2622
  return None
2092
2623
 
2093
2624
 
2094
- def _guard_write_scan(command: str, cwd=None):
2095
- """コマンド全体で、番人の設定への書き込みを探す(本物の区切り・`cd 場所` を追う)。
2625
+ def _guard_write_scan(command: str, cwd=None, depth: int = 0, env=None):
2626
+ """コマンド全体で、番人の設定への書き込みを探す(本物の区切り・`cd`/`pushd` を追う)。
2096
2627
 
2097
- ★同じコマンドの中で先に決めた変数(`P=/tmp/x; … > $P/.claude/settings.json`)は解いてから測る。
2098
- 解けない変数は、そのまま(=使い捨ての作業場と確かめられない=守る側)で測る。
2628
+ ★同じコマンドの中で先に決めた変数(`P=~/.claude; … > $P/settings.json`・`export P=…`・
2629
+ `for f in 場所 …; do … "$f"`)は解いてから測る。解けない変数は後ろの字面で照らす。
2099
2630
  """
2100
- here, env = cwd, {}
2631
+ if depth > _UNWRAP_MAX_DEPTH + 1 or not command:
2632
+ return None
2633
+ here, env = cwd, dict(env or {})
2101
2634
  for seg in split_commands(command, bodies=False):
2635
+ seg = _normalize_segment(seg)
2102
2636
  toks = tokens_of(seg)
2637
+ if toks and toks[0] == 'export':
2638
+ toks = toks[1:]
2103
2639
  if toks and all(_ASSIGN_RE.match(t) for t in toks):
2104
2640
  for t in toks:
2105
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
2106
2647
  env[k] = _expand_vars(v, env)
2107
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
2108
2654
  seg = _expand_vars(seg, env)
2109
2655
  toks = tokens_of(seg)
2110
- if verb_of(toks) == 'cd':
2111
- here = _cd_dest(toks, here)
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)
2112
2660
  continue
2113
2661
  where = None if here is _UNKNOWN_DIR else here
2114
- hit = _guard_write_segment(seg, where)
2662
+ hit = _guard_write_segment(seg, where, command, depth, env)
2115
2663
  if hit:
2116
2664
  return hit
2117
- for code in _code_payloads(seg, command):
2118
- hit = _code_guard_write(code, where)
2119
- if hit:
2120
- return hit
2121
2665
  return None
2122
2666
 
2123
2667
 
@@ -2129,40 +2673,28 @@ def _expand_vars(text: str, env) -> str:
2129
2673
  lambda m: env.get(m.group(1), m.group(0)), text)
2130
2674
 
2131
2675
 
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)の中の、番人の設定の場所の書き方。
2676
+ # プログラム(python3 -c・node -e・ruby -e・perl -e・<<EOF・<<<)の中の、一覧の場所の書き方。
2148
2677
  # ① 文字列そのものが場所('~/.claude/settings.json')→ guard_config_reason で測る
2149
2678
  # ② 部品で組み立てた形(Path.home() / '.claude' / 'settings.json'・join(h, '.<隠しフォルダ>', 'hooks.json'))
2679
+ # ③ ホームを式で足した後ろの部分(homedir() + '/.tyhld/hooks/…'・"$ENV{HOME}/.claude/…")
2150
2680
  # ★報告の文の中に場所の名前が出てくるだけ(空白を含む文字列)は場所と読まない。
2151
2681
  _CODE_GUARD_JOIN_RE = re.compile(
2152
- r'''['"]\.claude['"]\s*[,/+]\s*['"]settings[\w.]*\.json['"]'''
2153
- r'''|['"]\.tyhld['"]\s*[,/+]\s*['"]hooks['"/]'''
2682
+ r'''['"]\.claude['"]\s*[,/+]\s*['"](settings[\w.]*\.json|skills)['"/]'''
2683
+ r'''|['"]\.tyhld['"]\s*[,/+]\s*['"](hooks|bin)['"/]'''
2154
2684
  r'''|['"]\.[\w-]+['"]\s*[,/+]\s*['"](hooks\.json|config\.toml)['"]'''
2155
2685
  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
- # プログラムの中の「書く」書き方(開いて書く・写す・動かす・消す)。
2686
+ _CODE_PATH_LITERAL_RE = re.compile(r'''['"`]>{0,2}([^'"`\s*?\[\]]{1,300})['"`]''')
2687
+ # ホームを式で足した後ろの部分。
2688
+ _CODE_HOME_TAIL_RE = re.compile(r'(/\.[^/\s]+/.*)$')
2689
+ # プログラムの中の「書く」書き方(開いて書く・写す・動かす・消す・権限を変える・展開する・外の道具を呼ぶ)。
2160
2690
  _CODE_WRITE_RE = re.compile(
2161
2691
  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)
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)
2166
2698
 
2167
2699
 
2168
2700
  def _code_mentions_guard(stmt: str, cwd=None) -> bool:
@@ -2170,16 +2702,21 @@ def _code_mentions_guard(stmt: str, cwd=None) -> bool:
2170
2702
  return True
2171
2703
  for m in _CODE_PATH_LITERAL_RE.finditer(stmt):
2172
2704
  lit = m.group(1)
2173
- if '/' in lit and guard_config_reason(lit, cwd):
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))):
2174
2712
  return True
2175
- tail = _CODE_HOME_TAIL_RE.match(lit)
2176
- if tail and guard_config_reason('~' + tail.group(1), cwd):
2713
+ if tail and not lit[:tail.start()] and guard_config_reason('~' + tail.group(1), cwd):
2177
2714
  return True
2178
2715
  return False
2179
2716
 
2180
2717
 
2181
2718
  def _code_guard_write(text: str, cwd=None):
2182
- """プログラムの中で、番人の設定の場所を書き換えるなら理由。★読むだけ(json.load)は止めない。
2719
+ """プログラムの中で、一覧の場所を書き換えるなら理由。★読むだけ(json.load)は止めない。
2183
2720
 
2184
2721
  ★場所が【書く呼び出しと同じ文】に出てくるか、場所を入れた変数が書く文で使われるときだけ止める
2185
2722
  (別のファイルを書き換えるプログラムの文の中に、場所の名前が書いてあるだけでは止めない)。
@@ -2267,6 +2804,13 @@ def _gate_release(words, tokens):
2267
2804
  for w in words[1:]:
2268
2805
  if w in ('promote', 'alias', 'rollback'):
2269
2806
  return f'vercel {w}'
2807
+ # ★本番ドメインの変更(追加・削除・移管・購入)だけ関門。ls・inspect は読み取りなので自走
2808
+ # (便 6d6f2ff8・えふさん確定 #1: プレビューは自走・本番へ影響する操作は関門・強める向きのみ)。
2809
+ if 'domains' in words[1:]:
2810
+ after = [w for w in words[words.index('domains') + 1:] if not w.startswith('-')]
2811
+ if after[:1] and after[0] in ('add', 'rm', 'remove', 'buy', 'move',
2812
+ 'transfer', 'transfer-in'):
2813
+ return f'vercel domains {after[0]}'
2270
2814
  if v == 'wrangler' and '--dry-run' not in tokens and (
2271
2815
  words[1:2] in (['deploy'], ['publish'])
2272
2816
  or words[1:3] in (['versions', 'deploy'], ['pages', 'deploy'])):
@@ -2450,16 +2994,14 @@ def decide_write(file_path: str, cwd=None):
2450
2994
  # 既定は守る側=使い捨てだと確実に言えるときだけ通す(本物の場所は1つも漏れない)。
2451
2995
  # ★字面だけでなく、近道(symlink)を解いた実体も見る。別名の近道
2452
2996
  # (例 /tmp/shortcut → ~/.claude)から本物へ書き込むのを塞ぐ。
2453
- if is_guard_settings_path(path) and not is_throwaway_path(path):
2454
- return _r(DENY, 'danger: write to guard settings (.claude/settings*.json)')
2455
- comps = [c for c in path.split('/') if c]
2456
- if '.tyhld' in comps:
2457
- return _r(DENY, 'danger: write to guard directory (.tyhld)')
2458
- # ★Bash と同じ場所の一覧で守る(ホーム直下の隠しフォルダの hooks.json・config.toml・
2459
- # ~/.config/systemd・便 2.5)。
2997
+ # ★Bash と同じ場所の一覧(GUARD_PLACES)だけで守る(便 2.6・場所を2か所に書かない)。
2460
2998
  gr = guard_config_reason(path)
2461
2999
  if gr:
2462
3000
  return _r(DENY, f'danger: write to {gr}')
3001
+ # ★前からの広い守り(~/.tyhld のどこでも)は、弱めないためにそのまま残す。
3002
+ comps = [c for c in path.split('/') if c]
3003
+ if '.tyhld' in comps:
3004
+ return _r(DENY, 'danger: write to guard directory (.tyhld)')
2463
3005
  return _r(ALLOW, 'no gate, no danger')
2464
3006
 
2465
3007