@tyhld/conductor 0.12.32 → 0.12.33

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.32",
3
+ "version": "0.12.33",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -753,6 +753,15 @@ _QUOTED_RE = re.compile(r'''['"]([^'"\n]{1,300})['"]''')
753
753
  # 入れ子の上限。`bash -c "bash -c ..."` の2段まで見れば実用上足りる(無限再帰の防止)。
754
754
  _UNWRAP_MAX_DEPTH = 2
755
755
 
756
+ # awk の仲間で、次の語を【値として食べる】旗(この次の語はプログラム本文ではない)。
757
+ _AWK_OPTS_WITH_ARG = frozenset({
758
+ '-v', '--assign', '-f', '--file', '-F', '--field-separator', '-i', '--include',
759
+ '-l', '--load', '-E', '--exec',
760
+ })
761
+
762
+ # awk のプログラムをファイルから読む旗(この形には本文が無い)。
763
+ _AWK_PROGRAM_FILE_OPTS = frozenset({'-f', '--file'})
764
+
756
765
 
757
766
  def _code_secret_reason(text: str):
758
767
  """プログラム文字列の中に秘密ファイルの名前が出てくるなら理由を返す。
@@ -770,6 +779,66 @@ def _code_secret_reason(text: str):
770
779
  return None
771
780
 
772
781
 
782
+ def _awk_payloads(command: str):
783
+ """awk の仲間の【プログラム本文】を、中身として取り出す(便 3d1974f4)。
784
+
785
+ 【なぜ要るか(実測 2026-09-20・便 675e2d05 / 85e6f140)】
786
+ python3 -c / node -e / ruby -e の中身は名前を見て止めていたのに、awk だけは
787
+ プログラム本文を見ていなかった。awk は本文を【旗ではなく位置の語】で受け取るので、
788
+ _CODE_FLAGS(-c / -e)を探す作りでは1つも拾えない。そのため
789
+ awk "BEGIN{print \"X=1\" > \"<秘密ファイル>\"}"
790
+ が素通りし、実機で秘密ファイルを作り直せた。getline < "…"・system("…") も同じ。
791
+ ★止める側を増やすだけで、今まで止まっていた形は1つも変わらない。
792
+
793
+ 返す形は _inner_payloads と同じ (種別, 呼び出した道具, 中身)。
794
+ ・('code', 'awk', 本文) … 本文の中の文字列リテラルから秘密の名前を探す
795
+ ・('shell', 'awk command', 文字列) … system("rm …") や `| "cat > …"` の中身を
796
+ もう一度コマンドとして測り直す
797
+ """
798
+ out = []
799
+ seen = set()
800
+ for text in [command] + split_segments(command):
801
+ toks = tokens_of(text)
802
+ for i, t in enumerate(toks):
803
+ if t.split('/')[-1] not in _AWK_VERBS:
804
+ continue
805
+ args = toks[i + 1:]
806
+ program = None
807
+ from_file = False
808
+ j = 0
809
+ while j < len(args):
810
+ a = args[j]
811
+ if a in _AWK_OPTS_WITH_ARG:
812
+ if a in _AWK_PROGRAM_FILE_OPTS:
813
+ from_file = True
814
+ # ★`-v NAME=<秘密ファイル>` のように、旗の値でパスを渡す形も見る。
815
+ if a in ('-v', '--assign') and j + 1 < len(args):
816
+ val = args[j + 1].split('=', 1)[-1].strip()
817
+ if val:
818
+ out.append(('code', t, '"%s"' % val))
819
+ j += 2
820
+ continue
821
+ if a.startswith('-') and len(a) > 1:
822
+ j += 1
823
+ continue
824
+ program = a
825
+ break
826
+ if from_file or not program:
827
+ continue
828
+ key = (t, program)
829
+ if key in seen:
830
+ continue
831
+ seen.add(key)
832
+ out.append(('code', t, program))
833
+ # ★本文の中の文字列が【コマンド】のこともある(system("rm …")・`| "cat > …"`)。
834
+ # 空白を含む文字列だけを、もう一度コマンドとして測り直す。
835
+ for m in _QUOTED_RE.finditer(program):
836
+ lit = m.group(1).strip()
837
+ if lit and ' ' in lit:
838
+ out.append(('shell', f'{t} command', lit))
839
+ return out
840
+
841
+
773
842
  def _inner_payloads(command: str):
774
843
  """ラッパが抱えている「中身」を (種別, 呼び出した道具, 中身) の一覧で返す。
775
844
 
@@ -798,6 +867,9 @@ def _inner_payloads(command: str):
798
867
  kind = 'shell' if runner in _SHELL_RUNNERS else 'code'
799
868
  out.append((kind, runner or 'heredoc', m.group(2)))
800
869
 
870
+ # ③ awk の仲間のプログラム本文(旗ではなく位置の語で渡されるので別に取り出す)。
871
+ out += _awk_payloads(command)
872
+
801
873
  return out
802
874
 
803
875
 
@@ -844,6 +916,86 @@ def _wrapper_danger(command: str, depth: int = 0, cwd=None):
844
916
  return None
845
917
 
846
918
 
919
+ # ═════════════════════════════════════════════════════════════════════════════
920
+ # 変数に入れたパス(便 3d1974f4)
921
+ #
922
+ # 【なぜ要るか(実測 2026-09-20・便 675e2d05 / 85e6f140)】
923
+ # 番人は「コマンドの字面に出るパス」を見て止めていた。だから
924
+ # F=<秘密ファイル>; echo X=1 > "$F"
925
+ # のように、同じコマンドの中でいったん変数に入れるだけで、書き込みも読み出しも
926
+ # 素通りした(実機で書けた)。名前は字面に在るのに、使う場所には $F しか無いためである。
927
+ #
928
+ # 【どう塞ぐか】同じコマンドの中の代入(F=… ・export F=…)を先に読み、$F・${F}・"$F" を
929
+ # 展開してから、もう一度【同じ物差し】を当てる。展開できたものだけを見るので、
930
+ # 外から来る値(環境変数・$(…) の結果)は今までどおり字面では止まらない=そこは
931
+ # サンドボックスと設定の禁止ルールが受け持つ(限界として報告に書く)。
932
+ #
933
+ # ★止める側を増やすだけ。展開して「危険」に当たったときにだけ deny を返し、
934
+ # 展開の結果で allow に緩めることは1度もしない。
935
+ # ═════════════════════════════════════════════════════════════════════════════
936
+
937
+ #: 同じコマンドの中の代入(`F=値` / `export F=値`)。値はクォートの有無を問わない。
938
+ _VAR_ASSIGN_RE = re.compile(
939
+ r'(?:^|[;&|(]|\s)(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)='
940
+ r'''("[^"\n]*"|'[^'\n]*'|[^\s;|&()<>]*)''')
941
+
942
+ #: 変数の参照($F / ${F})。$1・$?・$(…) は拾わない。
943
+ _VAR_REF_RE = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)')
944
+
945
+ #: 展開の繰り返しの上限(`A=/tmp; B=$A/x; cat "$B"` のような数珠つなぎのため)。
946
+ _VAR_EXPAND_ROUNDS = 3
947
+
948
+
949
+ def _expand_local_vars(command: str, rounds: int = _VAR_EXPAND_ROUNDS) -> str:
950
+ """同じコマンドの中で代入された値だけを展開した文字列を返す。
951
+
952
+ ★分からない変数はそのまま残す(勝手に空へ潰さない=別のコマンドに化けさせない)。
953
+ """
954
+ text = command or ''
955
+ values = {}
956
+ for m in _VAR_ASSIGN_RE.finditer(text):
957
+ raw = m.group(2) or ''
958
+ if len(raw) >= 2 and raw[0] in ('"', "'") and raw[-1] == raw[0]:
959
+ raw = raw[1:-1]
960
+ if raw:
961
+ values[m.group(1)] = raw
962
+ if not values:
963
+ return text
964
+
965
+ def sub(m):
966
+ name = m.group(1) or m.group(2)
967
+ return values.get(name, m.group(0))
968
+
969
+ for _ in range(max(1, rounds)):
970
+ new = _VAR_REF_RE.sub(sub, text)
971
+ if new == text:
972
+ break
973
+ text = new
974
+ return text
975
+
976
+
977
+ def _var_expansion_danger(command: str, cwd=None):
978
+ """変数を展開してから、同じ危険の物差しを当て直す。危険なら理由を返す。"""
979
+ expanded = _expand_local_vars(command)
980
+ if not expanded or expanded == command:
981
+ return None
982
+ hit = _rm_danger(expanded)
983
+ if hit:
984
+ return hit
985
+ for seg in _real_segments(expanded):
986
+ toks = tokens_of(seg)
987
+ v = verb_of(toks)
988
+ hit = (_secret_danger(seg, v, toks, cwd) or _bulk_copy_danger(v, toks, cwd)
989
+ or _recursive_read_danger(v, toks, cwd) or _admin_merge_danger(v, toks))
990
+ if hit:
991
+ return hit
992
+ hit = (_guard_write_scan(expanded, cwd) or _env_write_scan(expanded, cwd)
993
+ or _bypass_scan(expanded, cwd) or _wrapper_danger(expanded, 0, cwd))
994
+ if hit:
995
+ return hit
996
+ return None
997
+
998
+
847
999
  # ═════════════════════════════════════════════════════════════════════════════
848
1000
  # フォルダごとの持ち出し(便 230c3175・D2)
849
1001
  #
@@ -3077,6 +3229,11 @@ def decide_bash(command: str, cwd=None):
3077
3229
  if hit:
3078
3230
  return _r(DENY, f'danger: {hit}')
3079
3231
 
3232
+ # ---- 1c-2) 変数に入れたパス(便 3d1974f4・`F=秘密; echo > "$F"` を素通りさせない) ----
3233
+ hit = _var_expansion_danger(command, cwd)
3234
+ if hit:
3235
+ return _r(DENY, f'danger: {hit} (変数を展開して判定)')
3236
+
3080
3237
  # ---- 1c') 番人の設定への書き込み(便 2.5・リダイレクト・tee・cp・mv・sed -i ほか) ----
3081
3238
  hit = _guard_write_scan(command, cwd)
3082
3239
  if hit:
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """便 3d1974f4: awk の本文の中のパスと、変数に入れたパスでも秘密ファイルを守る試験。
3
+
4
+ 実行: python3 scripts/hooks/test_awk_and_var_paths.py
5
+ 1件でも期待と違えば exit 1。
6
+
7
+ 【背景(実測 2026-09-20・便 675e2d05 と回収便 85e6f140)】
8
+ 配られていた番人(0.12.30)で、次の形が素通りしていた。実機でも書けた。
9
+ ・awk "BEGIN{print \\"X=1\\" > \\"<秘密ファイル>\\"}"(gawk・mawk・nawk も同じ)
10
+ ・awk の中の >> 追記・getline < 読み・system("rm …")・| "cat > …"
11
+ ・F=<秘密ファイル>; echo X=1 > "$F"(変数に入れると読み書きとも素通り)
12
+ 番人は「コマンドの字面に出るパス」と「一部の実行系(python/node/ruby/perl)の
13
+ プログラム文字列」しか見ていなかった。awk は本文を【旗ではなく位置の語】で受け取るので
14
+ 1つも拾えず、変数の展開はそもそも追っていなかった。
15
+ .dev.vars だけでなく .env・ホームの .env・接続情報のファイルまで同じ形で抜けた。
16
+
17
+ 【固定すること】
18
+ ① awk の仲間(awk / gawk / mawk / nawk)の本文の中で秘密ファイルに触れる形が止まる。
19
+ ② 同じコマンドの中で変数に入れたパスでも、書き込み・読み出し・削除が止まる。
20
+ ③ 今まで止まっていた形は1つも減っていない(止める側を増やしただけ)。
21
+ ④ 普段の awk・変数の使い方は1つも止めない。
22
+ ⑤ ★空振り確認:足した2つの仕組みを外すと ①② が素通りに戻る(試験が本当に効いている証拠)。
23
+
24
+ ★本物の秘密に1バイトも触れない。使うのは作り物の場所と名前だけ。名前は組み立てる。
25
+ """
26
+ import os
27
+ import sys
28
+
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
+
31
+ import policy # noqa: E402
32
+
33
+ failures = []
34
+
35
+
36
+ def check(label, got, want):
37
+ ok = got == want
38
+ if not ok:
39
+ failures.append(f'{label}: got={got!r} want={want!r}')
40
+ print(f'{"OK " if ok else "NG "} {label:72} {str(got)[:20]}')
41
+
42
+
43
+ DV = '.dev' + '.vars' # 手元の秘密(本体)
44
+ DVL = DV + '.local' # 環境別(値が入る=秘密)
45
+ ENV = '.e' + 'nv' # 比較用
46
+ CONDENV = '.conductor' + '.e' + 'nv' # 接続情報のファイル(ホームに在る)
47
+ CWD = '/home/fshim/projects/x'
48
+ TMP = '/tmp/claude-1000/probe'
49
+ HOME = os.path.expanduser('~')
50
+
51
+ #: 秘密として守られるべき置き場(★どれも作り物の道=実物には触れない)。
52
+ TARGETS = [
53
+ ('使い捨ての場所の手元の秘密', f'{TMP}/{DV}'),
54
+ ('リポの中の手元の秘密', f'{CWD}/{DV}'),
55
+ ('環境別の手元の秘密', f'{TMP}/{DVL}'),
56
+ ('リポの中の env', f'{CWD}/{ENV}'),
57
+ ('ホームの接続情報', f'{HOME}/{CONDENV}'),
58
+ ]
59
+
60
+ #: awk の本文で秘密に触れる書き方({t} に道を入れる)。
61
+ AWK_FORMS = [
62
+ ('本文の中で > に書く', 'BEGIN{{print "X=1" > "{t}"}}'),
63
+ ('本文の中で >> 追記', 'BEGIN{{printf "X=1" >> "{t}"}}'),
64
+ ('本文の中で getline 読み', 'BEGIN{{while((getline l < "{t}")>0) print l}}'),
65
+ ('本文の中で close する', 'BEGIN{{print "X" > "{t}"; close("{t}")}}'),
66
+ ('system で消す', 'BEGIN{{system("rm -f {t}")}}'),
67
+ ('パイプで外へ流す', 'BEGIN{{print "X" | "cat > {t}"}}'),
68
+ ]
69
+
70
+ AWK_VERBS = ['awk', 'gawk', 'mawk', 'nawk']
71
+
72
+
73
+ def dbash(cmd):
74
+ return policy.decide_bash(cmd, CWD)['decision']
75
+
76
+
77
+ def rbash(cmd):
78
+ return policy.decide_bash(cmd, CWD).get('reason') or ''
79
+
80
+
81
+ def awk_cmd(verb, program):
82
+ """実際に打つ形(シェルが二重引用符で包み、本文の中の引用符は escape される)。"""
83
+ return verb + ' "' + program.replace('"', '\\"') + '"'
84
+
85
+
86
+ print('── ① awk の本文の中のパス(awk / gawk / mawk / nawk) ' + '─' * 12)
87
+ for verb in AWK_VERBS:
88
+ for label, program in AWK_FORMS:
89
+ cmd = awk_cmd(verb, program.format(t=f'{TMP}/{DV}'))
90
+ check(f'① {verb}: {label}', dbash(cmd), policy.DENY)
91
+
92
+ print('\n── ① どの秘密ファイルでも同じ(awk の本文の中の >) ' + '─' * 12)
93
+ for name, path in TARGETS:
94
+ cmd = awk_cmd('awk', 'BEGIN{{print "X=1" > "{t}"}}'.format(t=path))
95
+ check(f'① {name}', dbash(cmd), policy.DENY)
96
+ check(f'① {name}: 読みも止まる',
97
+ dbash(awk_cmd('awk', 'BEGIN{{while((getline l < "{t}")>0) print l}}'.format(t=path))),
98
+ policy.DENY)
99
+
100
+ print('\n── ① awk の旗・置き場所を変えても同じ ' + '─' * 20)
101
+ P = f'{TMP}/{DV}'
102
+ check('① -v で渡す', dbash(f'awk -v F={P} "BEGIN{{print \\"X=1\\" > F}}"'), policy.DENY)
103
+ check('① -F の後ろに本文', dbash(awk_cmd('awk -F:', 'BEGIN{{print "X" > "{t}"}}'.format(t=P))),
104
+ policy.DENY)
105
+ check('① パイプの後ろの awk', dbash('echo hi | ' + awk_cmd('awk', '{{print > "%s"}}' % P)),
106
+ policy.DENY)
107
+ check('① 道つきで呼ぶ', dbash(awk_cmd('/usr/bin/awk', 'BEGIN{{print "X" > "{t}"}}'.format(t=P))),
108
+ policy.DENY)
109
+ check('① bash -c の中の awk',
110
+ dbash("bash -c '" + awk_cmd('awk', 'BEGIN{{print "X" > "{t}"}}'.format(t=P)) + "'"),
111
+ policy.DENY)
112
+ check('① 理由に秘密ファイルの名前が出る', DV in rbash(awk_cmd('awk', 'BEGIN{{print "X" > "{t}"}}'.format(t=P))), True)
113
+
114
+ print('\n── ② 変数に入れたパス ' + '─' * 30)
115
+ VAR_FORMS = [
116
+ ('変数に入れて > で書く', f'F={P}; echo X=1 > "$F"'),
117
+ ('クォート無しで書く', f'F={P}; echo X=1 > $F'),
118
+ ('波括弧で書く', f'F={P}; echo X=1 > ${{F}}'),
119
+ ('変数に入れて読む', f'F={P}; cat "$F"'),
120
+ ('変数に入れて消す', f'F={P}; rm -f "$F"'),
121
+ ('変数に入れて写す', f'F={P}; cp "$F" /tmp/claude-1000/copy.txt'),
122
+ ('export で入れて書く', f'export F={P}; echo X=1 > "$F"'),
123
+ ('引用符つきの代入', f'F="{P}"; echo X=1 > "$F"'),
124
+ ('数珠つなぎ', f'D={TMP}; F=$D/{DV}; echo X=1 > "$F"'),
125
+ ('変数と awk の合わせ技', f'F={P}; ' + 'awk "BEGIN{print \\"X\\" > \\"$F\\"}"'),
126
+ ('変数に入れて包みの中で書く', f'F={P}; bash -c "echo X=1 > $F"'),
127
+ ]
128
+ for label, cmd in VAR_FORMS:
129
+ check(f'② {label}', dbash(cmd), policy.DENY)
130
+
131
+ for name, path in TARGETS:
132
+ check(f'② どの秘密ファイルでも同じ: {name}',
133
+ dbash(f'F={path}; echo X=1 > "$F"'), policy.DENY)
134
+
135
+ print('\n── ③ 今まで止まっていた形は1つも減っていない ' + '─' * 16)
136
+ SRC = '/tmp/claude-1000/src.txt'
137
+ for cmd in [f'echo X=1 > {P}', f'echo X=1 >> {P}', f'echo X=1 | tee {P}', f'cp {SRC} {P}',
138
+ f'mv {SRC} {P}', f'mv {P} /tmp/claude-1000/moved.txt', f'rm -f {P}', f'touch {P}',
139
+ f'truncate -s 0 {P}', f'sed -i s/a/b/ {P}', f'perl -pi -e s/a/b/ {P}',
140
+ f'dd if=/dev/null of={P}', f'install {SRC} {P}', f'ln -sf {SRC} {P}',
141
+ f'python3 -c "open(\'{P}\',\'w\').write(\'X\')"',
142
+ f'node -e "require(\'fs\').writeFileSync(\'{P}\',\'X\')"',
143
+ f'ruby -e "File.write(\'{P}\',\'X\')"', f'sh -c "echo X=1 > {P}"',
144
+ f'awk "{{print}}" {P}', f'awk -i inplace "{{print}}" {P}', f'cat {P}']:
145
+ check(f'③ deny のまま: {cmd[:56]}', dbash(cmd), policy.DENY)
146
+ for tool in ('Write', 'Edit', 'MultiEdit', 'NotebookEdit'):
147
+ check(f'③ {tool} は deny のまま',
148
+ policy.decide_event({'tool_name': tool, 'tool_input': {'file_path': P},
149
+ 'cwd': CWD})['decision'], policy.DENY)
150
+
151
+ print('\n── ④ 普段の awk・変数の使い方は1つも止めない ' + '─' * 16)
152
+ OK_CMDS = [
153
+ 'awk "{print \\$1}" package.json',
154
+ 'ps aux | awk -F: "{print \\$2}"',
155
+ 'awk "BEGIN{print \\"hello world\\"}"',
156
+ 'awk "BEGIN{print \\"x\\" > \\"/tmp/claude-1000/out.txt\\"}"',
157
+ 'awk "BEGIN{system(\\"ls /tmp\\")}"',
158
+ 'awk -f scripts/report.awk data.csv',
159
+ 'git log --oneline | awk "{print \\$1}"',
160
+ 'F=/tmp/claude-1000/out.txt; echo hi > "$F"',
161
+ 'DIR=/home/fshim/projects/x; ls "$DIR"',
162
+ 'B=main; git log --oneline "$B" -3',
163
+ 'N=3; npm test -- --reporter dot',
164
+ 'F=dist/cli.js; node "$F" --help',
165
+ 'MSG="直しました"; git commit -m "$MSG"',
166
+ ]
167
+ for cmd in OK_CMDS:
168
+ check(f'④ allow: {cmd[:56]}', dbash(cmd), policy.ALLOW)
169
+
170
+ print('\n── ★空振り確認(足した仕組みを外すと素通りに戻る) ' + '─' * 10)
171
+ HOLE_AWK = awk_cmd('awk', 'BEGIN{{print "X=1" > "{t}"}}'.format(t=P))
172
+ HOLE_VAR = f'F={P}; echo X=1 > "$F"'
173
+
174
+ orig_awk = policy._awk_payloads
175
+ orig_var = policy._var_expansion_danger
176
+ try:
177
+ policy._awk_payloads = lambda command: []
178
+ check('★空振り: awk の本文を見なくすると素通りに戻る', dbash(HOLE_AWK), policy.ALLOW)
179
+ finally:
180
+ policy._awk_payloads = orig_awk
181
+ check('★戻したあと: awk は止まる', dbash(HOLE_AWK), policy.DENY)
182
+
183
+ try:
184
+ policy._var_expansion_danger = lambda command, cwd=None: None
185
+ check('★空振り: 変数を展開しないと素通りに戻る', dbash(HOLE_VAR), policy.ALLOW)
186
+ finally:
187
+ policy._var_expansion_danger = orig_var
188
+ check('★戻したあと: 変数ごしも止まる', dbash(HOLE_VAR), policy.DENY)
189
+
190
+ print('\n── ★展開そのものの見え方(分からない値は潰さない) ' + '─' * 10)
191
+ check('★同じコマンドの代入は展開する',
192
+ policy._expand_local_vars('F=/tmp/x; cat "$F"'), 'F=/tmp/x; cat "/tmp/x"')
193
+ check('★外から来る値はそのまま残す',
194
+ policy._expand_local_vars('cat "$HOME_SECRET"'), 'cat "$HOME_SECRET"')
195
+ check('★位置の引数は触らない', policy._expand_local_vars('echo "$1"'), 'echo "$1"')
196
+ check('★自分を指す代入で無限に回らない',
197
+ policy._expand_local_vars('A=$A; echo "$A"'), 'A=$A; echo "$A"')
198
+
199
+ print('\n' + '=' * 72)
200
+ if failures:
201
+ print(f'NG {len(failures)} 件:')
202
+ for f in failures:
203
+ print(' -', f)
204
+ sys.exit(1)
205
+ print('すべて期待どおり')
206
+ sys.exit(0)