@tyhld/conductor 0.12.32 → 0.12.34
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/dist/cli.js +18 -1
- package/dist/relay.js +51 -1
- package/package.json +1 -1
- package/scripts/hooks/policy.py +157 -0
- package/scripts/hooks/test_awk_and_var_paths.py +206 -0
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
-
import { RELAY_FETCH_TIMEOUT_MS, createRelayState, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, } from './relay.js';
|
|
16
|
+
import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, } from './relay.js';
|
|
17
17
|
import { createRelayRunner } from './relay-runner.js';
|
|
18
18
|
import { installProcessGuards } from './process-guard.js';
|
|
19
19
|
import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
|
|
@@ -22,6 +22,11 @@ import { loadEarConfig } from './ear-routing.js';
|
|
|
22
22
|
import { ensureEnvFilePermissions } from './env-file-perm.js';
|
|
23
23
|
import { createCentralPorts } from './build-central.js';
|
|
24
24
|
import { BUILD_NUDGE_NAME, REQUIRED_ENV, createBuildRunner, createMemoStore, loadBuildRunnerConfig, } from './build-runner.js';
|
|
25
|
+
/**
|
|
26
|
+
* relay の状態の参照(生存報告に「いま配れるか」を載せるため)。
|
|
27
|
+
* ★relay が無効な機では null のまま=報告の中身は今までと1バイトも変わらない。
|
|
28
|
+
*/
|
|
29
|
+
let relayStateForReport = null;
|
|
25
30
|
/** git コマンドを安全に実行し、失敗したら null を返す(git でない/未インストール等)。 */
|
|
26
31
|
function git(args) {
|
|
27
32
|
try {
|
|
@@ -161,6 +166,16 @@ async function sendReport(cfg) {
|
|
|
161
166
|
lastAction: cfg.lastAction,
|
|
162
167
|
artisanReady: tmuxHasSession(cfg.site),
|
|
163
168
|
};
|
|
169
|
+
// ★relay が動いている機だけ、2段目(いま配れるか)を足す。
|
|
170
|
+
// 未配達でないときは秒数と指示idを載せない=管制で「遅れ」を数えない。
|
|
171
|
+
if (relayStateForReport) {
|
|
172
|
+
const d = deliveryReport(relayStateForReport);
|
|
173
|
+
body.deliveryState = d.deliveryState;
|
|
174
|
+
if (d.undeliveredSec !== null)
|
|
175
|
+
body.undeliveredSec = d.undeliveredSec;
|
|
176
|
+
if (d.queuedCommandId !== null)
|
|
177
|
+
body.queuedCommandId = d.queuedCommandId;
|
|
178
|
+
}
|
|
164
179
|
const time = new Date().toLocaleTimeString();
|
|
165
180
|
try {
|
|
166
181
|
const res = await fetch(`${cfg.url}/api/conductor/report`, {
|
|
@@ -222,6 +237,8 @@ async function start(args) {
|
|
|
222
237
|
`(承認待ちの間だけ ${cfg.promptIntervalSec}s に短縮)` +
|
|
223
238
|
`(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
|
|
224
239
|
const relayState = createRelayState();
|
|
240
|
+
// 生存報告に「いま配れるか」を載せるための参照(便 600d2a6a)。
|
|
241
|
+
relayStateForReport = relayState;
|
|
225
242
|
relayRunner = createRelayRunner({
|
|
226
243
|
tick: () => relayTick(cfg, relayState, {
|
|
227
244
|
// ★管制へつながらない状態から戻った瞬間に1回だけ呼ばれる。
|
package/dist/relay.js
CHANGED
|
@@ -223,6 +223,47 @@ export function createRelayState() {
|
|
|
223
223
|
promptPending: false,
|
|
224
224
|
busyRaisedId: null,
|
|
225
225
|
central: { failures: 0, detail: '', downSince: null },
|
|
226
|
+
readiness: createReadiness(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** 「指示は在るのに配れていない」状態(この間が未配達の時間)。 */
|
|
230
|
+
const BLOCKED_STATES = new Set([
|
|
231
|
+
'busy',
|
|
232
|
+
'no-session',
|
|
233
|
+
'not-agent',
|
|
234
|
+
]);
|
|
235
|
+
export function createReadiness(now = Date.now()) {
|
|
236
|
+
return { state: 'unknown', commandId: null, since: now, skipCount: 0 };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 1周の結末を記録する。同じ結末が続く間は since を動かさない(=そこから何秒経ったかが分かる)。
|
|
240
|
+
* ★記録するだけ。配達の判断は1バイトも変えない。
|
|
241
|
+
*/
|
|
242
|
+
export function noteReadiness(state, next, commandId, now = Date.now()) {
|
|
243
|
+
const cur = state.readiness;
|
|
244
|
+
if (cur.state === next && cur.commandId === commandId) {
|
|
245
|
+
if (BLOCKED_STATES.has(next))
|
|
246
|
+
cur.skipCount += 1;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
state.readiness = {
|
|
250
|
+
state: next,
|
|
251
|
+
commandId,
|
|
252
|
+
since: now,
|
|
253
|
+
skipCount: BLOCKED_STATES.has(next) ? 1 : 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* 生存報告に載せる形。★「指示が在るのに配れていない」ときだけ、その長さと指示idを出す。
|
|
258
|
+
* 配れている・指示が無いときは undeliveredSec を null にする(管制で「遅れ」を数えない)。
|
|
259
|
+
*/
|
|
260
|
+
export function deliveryReport(state, now = Date.now()) {
|
|
261
|
+
const r = state.readiness;
|
|
262
|
+
const blocked = BLOCKED_STATES.has(r.state) && r.commandId !== null;
|
|
263
|
+
return {
|
|
264
|
+
deliveryState: r.state,
|
|
265
|
+
queuedCommandId: blocked ? r.commandId : null,
|
|
266
|
+
undeliveredSec: blocked ? Math.max(0, Math.round((now - r.since) / 1000)) : null,
|
|
226
267
|
};
|
|
227
268
|
}
|
|
228
269
|
/**
|
|
@@ -2045,6 +2086,7 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2045
2086
|
: 'relay停止中(緊急停止スイッチON)のため送信しません');
|
|
2046
2087
|
}
|
|
2047
2088
|
state.lastHalted = true;
|
|
2089
|
+
noteReadiness(state, 'halted', null);
|
|
2048
2090
|
return;
|
|
2049
2091
|
}
|
|
2050
2092
|
if (state.lastHalted === true) {
|
|
@@ -2053,8 +2095,10 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2053
2095
|
state.lastHalted = false;
|
|
2054
2096
|
// ── 新規指示の取得 ──
|
|
2055
2097
|
const cmd = await fetchCommand(cfg);
|
|
2056
|
-
if (!cmd)
|
|
2098
|
+
if (!cmd) {
|
|
2099
|
+
noteReadiness(state, 'no-command', null);
|
|
2057
2100
|
return; // 指示なし
|
|
2101
|
+
}
|
|
2058
2102
|
// ── 配達放棄ガード(無限ループの構造的封じ込め) ──
|
|
2059
2103
|
// 配達回数上限を超えて放棄した command は、サーバがまだ queued を返しても(=完了通知が
|
|
2060
2104
|
// 単線で切れて詰まった状態でも)二度と配達しない。放棄時に既にWARNを出しているので静かにスキップ。
|
|
@@ -2073,6 +2117,7 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2073
2117
|
// ── アイドル判定(保守的・二重チェック) ──
|
|
2074
2118
|
if (!tmuxHasSession(cfg.site)) {
|
|
2075
2119
|
log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
|
|
2120
|
+
noteReadiness(state, 'no-session', cmd.id);
|
|
2076
2121
|
return;
|
|
2077
2122
|
}
|
|
2078
2123
|
if (!(await isIdleStable(cfg.site, kindOf(cfg)))) {
|
|
@@ -2084,6 +2129,9 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2084
2129
|
state.skipWarned = false;
|
|
2085
2130
|
}
|
|
2086
2131
|
state.skipCount += 1;
|
|
2132
|
+
// ★「指示は在るのに配れていない」ことを、この周の結末として記録する(便 600d2a6a)。
|
|
2133
|
+
// 生存報告に載せて、管制が「職人が作業中だから待っている」と言えるようにする。
|
|
2134
|
+
noteReadiness(state, 'busy', cmd.id);
|
|
2087
2135
|
log(`site=${cfg.site} id=${cmd.id} 作業中の可能性ありスキップ(次回再判定)`);
|
|
2088
2136
|
if (state.skipCount > RELAY_SKIP_WARN_THRESHOLD && !state.skipWarned) {
|
|
2089
2137
|
state.skipWarned = true;
|
|
@@ -2104,6 +2152,7 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2104
2152
|
if (guardCap === null || !looksLikeAgent(guardCap, kindOf(cfg))) {
|
|
2105
2153
|
log(`WARN site=${cfg.site} id=${cmd.id} Claude Codeの箱と確認できないため送信せずスキップ` +
|
|
2106
2154
|
'(名前一致でも非claude窓への誤爆を防止)');
|
|
2155
|
+
noteReadiness(state, 'not-agent', cmd.id);
|
|
2107
2156
|
return;
|
|
2108
2157
|
}
|
|
2109
2158
|
// ── tmux 送信 ──
|
|
@@ -2140,6 +2189,7 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2140
2189
|
markDelivered(state, cmd.id);
|
|
2141
2190
|
// 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
|
|
2142
2191
|
state.lastHeartbeatAt = null;
|
|
2192
|
+
noteReadiness(state, 'delivered', cmd.id);
|
|
2143
2193
|
log(`site=${cfg.site} id=${cmd.id} 配達しました`);
|
|
2144
2194
|
// ★配達の直後に「配達済み(sent)」を管制へ書く。書けなければ次tick以降で書けるまでやり直す
|
|
2145
2195
|
// (markSentAtCentral)。★書けなくても再配達はしない。
|
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)
|