@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
package/scripts/hooks/policy.py
CHANGED
|
@@ -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)
|