安全修复:复合命令缺口、状态文件投毒、自批命令、协议 fail-open、dangerlist 漏报

- COMPOUND 补全换行/单个&/重定向(白名单不再被多行/重定向命令绕过),find 移出白名单
- gatekeeper 状态文件读写确定性硬拒(防 approved.json 投毒绕过灾难黑名单)
- 模型自批 approve 命令确定性硬拒(防 --yes 自我放行)
- approvedConsume 校验时间戳必须在过去(防未来时间戳使批准永不过期)
- daemon 协议错误/未知 op 返回 {ok:false},客户端降级内联完整审查(消除 fail-open)
- onReviewerError 支持 prompt 别名;llmTimeoutMs 超时预算钳制(防 hook 超时 fail-open)
- dangerlist 补漏:del/rd flag 顺序无关、Remove-Item 盘根星号、Format-Volume、format.com
- review 输入 JSON 解析失败与基础设施崩溃改为 fail-closed
- install 首次安装从模板生成 config.json;回归扩展至 41 项
This commit is contained in:
luoxiaoxin123
2026-09-13 20:59:36 +08:00
parent d1188c8eef
commit b207dbcaa0
8 changed files with 99 additions and 20 deletions
+1 -1
View File
@@ -188,7 +188,7 @@ GATEKEEPER_DEBUG=1 node gatekeeper.mjs … # 打印代理通道调试信
bash test.sh bash test.sh
``` ```
覆盖 29 项决策路径:白名单/黑名单拦截、双 shell 兼容、三种 onUncertain 落地、extraBody 注入、approve 一次性放行与用后即焚、daemon 通道冒烟、fail-closed 降级等。 覆盖 41 项决策路径:白名单/黑名单拦截、双 shell 兼容、三种 onUncertain 落地、extraBody 注入、approve 一次性放行与用后即焚、daemon 通道冒烟、fail-closed 降级等。
## 卸载 ## 卸载
-1
View File
@@ -19,7 +19,6 @@
^\s*cat\s ^\s*cat\s
^\s*head\s ^\s*head\s
^\s*tail\s ^\s*tail\s
^\s*find\s
^\s*grep\s ^\s*grep\s
^\s*rg\s ^\s*rg\s
^\s*wc\s ^\s*wc\s
+6
View File
@@ -77,6 +77,12 @@ async function cmdInstall() {
} }
out(`✓ 已复制 ${copied} 个文件 → ${TARGET_DIR}`); out(`✓ 已复制 ${copied} 个文件 → ${TARGET_DIR}`);
// 首次安装没有 config.json(仓库不含真实密钥)→ 从模板生成
if (!existsSync(path.join(TARGET_DIR, 'config.json'))) {
cpSync(path.join(SCRIPT_DIR, 'config.example.json'), path.join(TARGET_DIR, 'config.json'));
out('✓ 已从模板生成 config.json —— 请编辑填入你的审查端点(baseUrl/apiKey/model)');
}
if (!existsSync(USER_CONFIG)) { if (!existsSync(USER_CONFIG)) {
out(`✗ 未找到用户配置 ${USER_CONFIG} —— 请确认 ZCode 已安装过并至少启动过一次`); out(`✗ 未找到用户配置 ${USER_CONFIG} —— 请确认 ZCode 已安装过并至少启动过一次`);
return 1; return 1;
+36 -9
View File
@@ -54,7 +54,7 @@ const DEFAULTS = {
baseUrl: 'https://grok.002256.xyz/v1', baseUrl: 'https://grok.002256.xyz/v1',
apiKey: '', apiKey: '',
model: 'grok-chat-fast', model: 'grok-chat-fast',
llmTimeoutMs: 20000, llmTimeoutMs: 12000,
retries: 1, retries: 1,
maxOutputTokens: 2000, // 推理模型需要 headroom,否则 JSON 被截断(参照 CC 给思考模型留 2048) maxOutputTokens: 2000, // 推理模型需要 headroom,否则 JSON 被截断(参照 CC 给思考模型留 2048)
onReviewerError: 'deny', // deny | ask | allow —— 审查器故障时(CC 同款 fail-closed 默认 deny) onReviewerError: 'deny', // deny | ask | allow —— 审查器故障时(CC 同款 fail-closed 默认 deny)
@@ -91,6 +91,12 @@ export function loadAll() {
if (process.env.GATEKEEPER_ON_UNCERTAIN) { if (process.env.GATEKEEPER_ON_UNCERTAIN) {
config.onUncertain = String(process.env.GATEKEEPER_ON_UNCERTAIN).toLowerCase(); config.onUncertain = String(process.env.GATEKEEPER_ON_UNCERTAIN).toLowerCase();
} }
// 超时预算钳制:LLM 最坏耗时(超时×(重试+1))+ 5s 余量不得超过 35s,
// 否则内联降级会撞上 hook 超时(PreToolUse 50s / PermissionRequest 45s)被掐成无决策。
const worst = (config.llmTimeoutMs | 0) * ((config.retries | 0) + 1);
if (worst > 35000) {
config.llmTimeoutMs = Math.max(3000, Math.floor(35000 / ((config.retries | 0) + 1)));
}
const POLICY = (() => { const POLICY = (() => {
try { return readFileSync(path.join(SCRIPT_DIR, 'policy.md'), 'utf8'); } try { return readFileSync(path.join(SCRIPT_DIR, 'policy.md'), 'utf8'); }
catch { return '你是安全审查员,判断工具调用是否在当前任务授权范围内且安全。'; } catch { return '你是安全审查员,判断工具调用是否在当前任务授权范围内且安全。'; }
@@ -119,7 +125,10 @@ export function buildSets(config) {
}; };
} }
const COMPOUND = /&&|\|\||;|`|\$\(|\|/; // 复合命令不享受命令白名单,交给审查层 // 复合命令不享受命令白名单,交给审查层。
// 覆盖:&(含单个&与&&)、|、;、反引号、$、换行/回车(多行命令)、> >> <(重定向可写文件/投毒状态文件)。
// 任何包含这些字符的命令一律不进白名单,宁可达灰区也不静默放行。
const COMPOUND = /[&|;`$\n\r><]/;
function isBashAllowed(cmd, ALLOW_RE) { function isBashAllowed(cmd, ALLOW_RE) {
if (COMPOUND.test(cmd)) return false; if (COMPOUND.test(cmd)) return false;
return ALLOW_RE.some(re => re.test(cmd)); return ALLOW_RE.some(re => re.test(cmd));
@@ -342,7 +351,8 @@ function saveApproved(list) {
export function approvedConsume(cmd) { export function approvedConsume(cmd) {
const list = loadApproved(); const list = loadApproved();
const now = Date.now(); const now = Date.now();
const idx = list.findIndex(e => e && e.cmd === cmd && now - (e.ts || 0) <= APPROVE_TTL); // ts 必须在过去(防未来时间戳投毒使批准永不过期)且在 TTL 内
const idx = list.findIndex(e => e && e.cmd === cmd && (e.ts || 0) <= now && now - (e.ts || 0) <= APPROVE_TTL);
if (idx < 0) return false; if (idx < 0) return false;
list.splice(idx, 1); list.splice(idx, 1);
saveApproved(list); saveApproved(list);
@@ -369,7 +379,7 @@ function recordDenial(tool, cmd) {
if (!cmd) return; if (!cmd) return;
try { try {
const list = loadJson(DENIED_FILE, []); const list = loadJson(DENIED_FILE, []);
list.push({ tool, cmd: String(cmd).slice(0, 2000), ts: Date.now() }); list.push({ tool, cmd: String(cmd).slice(0, 8000), ts: Date.now() });
writeFileSync(DENIED_FILE, JSON.stringify(list.slice(-20))); writeFileSync(DENIED_FILE, JSON.stringify(list.slice(-20)));
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
} }
@@ -495,6 +505,22 @@ export async function decide({ mode, payload }, deps, all) {
} }
} }
// 第 0.7 层:确定性硬拒 —— 状态文件读写与自批命令。
// 防两条攻击链:① 白名单放行的 `echo … > approved.json` 投毒批准列表(绕过灾难黑名单);
// ② 模型代跑 `gatekeeper.mjs approve … --yes` 自我放行。
// 这类命令只能由用户在终端执行(终端不走 hook,不受本规则影响)。
const stateFileHit = /approved\.json|denied\.json|cache\.json|audit\.jsonl|daemon\.log/i.test(
cmd + ' ' + String(toolInput.file_path ?? toolInput.path ?? ''));
if (stateFileHit) {
return deny('该命令试图读写 gatekeeper 的状态文件(批准列表/审计/缓存/日志)。' +
'这些文件只能由 gatekeeper 自身或用户在终端管理;如需查看审计,请在你的终端运行 tail 命令。',
rec('deny', 'state-file'));
}
if (/gatekeeper(\.mjs)?["']?\s+(approve|--last)|control\.mjs["']?\s+approve/i.test(cmd)) {
return deny('检测到试图运行 gatekeeper 的 approve 自批命令。' +
'approve 只能由用户亲自在终端执行(模型不得代跑,防自我放行)。', rec('deny', 'self-approve'));
}
// 第 0.8 层:用户一次性批准(deny 逃生舱)—— 精确匹配原命令,命中即消费放行 // 第 0.8 层:用户一次性批准(deny 逃生舱)—— 精确匹配原命令,命中即消费放行
if (cmd && approvedConsume(cmd)) { if (cmd && approvedConsume(cmd)) {
return allow('用户已一次性批准该命令(批准已消费)', rec('allow', 'approved')); return allow('用户已一次性批准该命令(批准已消费)', rec('allow', 'approved'));
@@ -528,7 +554,7 @@ export async function decide({ mode, payload }, deps, all) {
// 第 3 层:审查模型(任务感知判定,CC 同款二值判定) // 第 3 层:审查模型(任务感知判定,CC 同款二值判定)
const reviewerFail = (msg, r) => { const reviewerFail = (msg, r) => {
if (config.onReviewerError === 'allow') return allow('审查器故障放行:' + msg, rec('allow', 'error', 0, msg)); if (config.onReviewerError === 'allow') return allow('审查器故障放行:' + msg, rec('allow', 'error', 0, msg));
if (config.onReviewerError === 'ask') return ask(rec('ask', 'error', 0, msg)); if (config.onReviewerError === 'ask' || config.onReviewerError === 'prompt') return ask(rec('ask', 'error', 0, msg));
return deny( return deny(
'审查器不可用,为安全起见拒绝执行本次调用(' + msg + ')。' + '审查器不可用,为安全起见拒绝执行本次调用(' + msg + ')。' +
'请检查 gatekeeper/config.json 的审查端点配置,或将 onReviewerError 临时改为 "ask"。', '请检查 gatekeeper/config.json 的审查端点配置,或将 onReviewerError 临时改为 "ask"。',
@@ -536,10 +562,6 @@ export async function decide({ mode, payload }, deps, all) {
); );
}; };
if (!config.apiKey || config.apiKey === 'PENDING') {
return reviewerFail('未配置审查端点 apiKey');
}
const task = deps.loadTaskContext(sid); const task = deps.loadTaskContext(sid);
const { actions } = deps.loadState(sid); const { actions } = deps.loadState(sid);
const cacheKey = crypto.createHash('sha256') const cacheKey = crypto.createHash('sha256')
@@ -552,6 +574,11 @@ export async function decide({ mode, payload }, deps, all) {
return ask(rec('ask', 'llm-cache', 0, hit.reason)); return ask(rec('ask', 'llm-cache', 0, hit.reason));
} }
// apiKey 校验放在缓存之后:缓存命中不需要审查端点
if (!config.apiKey || config.apiKey === 'PENDING') {
return reviewerFail('未配置审查端点 apiKey');
}
const t0 = Date.now(); const t0 = Date.now();
let verdict; let verdict;
try { try {
+4 -3
View File
@@ -9,10 +9,10 @@
\brm\b(?=[^;&|\n]*\s-[a-zA-Z]*[rR])(?=[^;&|\n]*\s(/|~|\$HOME)(\s|$)) \brm\b(?=[^;&|\n]*\s-[a-zA-Z]*[rR])(?=[^;&|\n]*\s(/|~|\$HOME)(\s|$))
# PowerShell 递归删除:盘根、系统目录、用户主目录(盘内普通子目录不算) # PowerShell 递归删除:盘根、系统目录、用户主目录(盘内普通子目录不算)
Remove-Item\s+[^;&|\n]*-Recurse[^;&|\n]*\s"?[a-zA-Z]:[\\/]?"?\s*$ Remove-Item\s+[^;&|\n]*-Recurse[^;&|\n]*\s"?[a-zA-Z]:[\\/]?\*?"?\s*$
Remove-Item\s+[^;&|\n]*-Recurse[^;&|\n]*\s"?[a-zA-Z]:\\(Windows|Users|Program Files) Remove-Item\s+[^;&|\n]*-Recurse[^;&|\n]*\s"?[a-zA-Z]:\\(Windows|Users|Program Files)
Remove-Item\s+[^;&|\n]*-Recurse[^;&|\n]*\s(~|\$HOME|%USERPROFILE%|\$env:USERPROFILE)(\s|$) Remove-Item\s+[^;&|\n]*-Recurse[^;&|\n]*\s(~|\$HOME|%USERPROFILE%|\$env:USERPROFILE)(\s|$)
(rd|rmdir|del)\s+/s[^;&|\n]*[a-zA-Z]:\\(\s|$|Windows|Users|Program) (rd|rmdir|del)\b[^;&|\n]*\/s\b[^;&|\n]*[a-zA-Z]:\\((\*|\\)?(\s|$)|Windows|Users|Program)
del\s+/[a-z]*q[a-z]*\s+/[a-z]*s[a-z]*\s+"?[a-zA-Z]:[\\/]?"?\s*$ del\s+/[a-z]*q[a-z]*\s+/[a-z]*s[a-z]*\s+"?[a-zA-Z]:[\\/]?"?\s*$
# 磁盘 / 文件系统级破坏 # 磁盘 / 文件系统级破坏
@@ -22,7 +22,8 @@ shred\s+[^;&|\n]*/dev/
blkdiscard blkdiscard
wipefs\s+[^;&|\n]*/dev/ wipefs\s+[^;&|\n]*/dev/
\bdiskpart\b[\s\S]*\bclean\b \bdiskpart\b[\s\S]*\bclean\b
\bformat\s+[a-zA-Z]: \bformat(\.com)?\s+[a-zA-Z]:
\bFormat-Volume\b
Clear-Disk Clear-Disk
Initialize-Disk Initialize-Disk
Remove-Partition Remove-Partition
+13 -3
View File
@@ -73,7 +73,9 @@ const server = net.createServer({ allowHalfOpen: true }, (socket) => {
socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时 socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时
const dispatch = async (line) => { const dispatch = async (line) => {
let res = { exitCode: 0, stdout: '', stderr: '' }; // 协议异常/未知 op 一律返回 {ok:false}(无 exitCode 字段)——客户端据此降级内联做完整审查,
// 绝不能返回 {exitCode:0}(会被当作"明确放行"决策,形成 fail-open)。
let res = { ok: false, error: 'unknown op' };
try { try {
const req = line.trim() ? JSON.parse(line.trim()) : null; const req = line.trim() ? JSON.parse(line.trim()) : null;
if (!req || req.op === 'ping') { if (!req || req.op === 'ping') {
@@ -85,7 +87,7 @@ const server = net.createServer({ allowHalfOpen: true }, (socket) => {
res = await handleHook(req); res = await handleHook(req);
} }
} catch (e) { } catch (e) {
res = { exitCode: 0, stdout: '', stderr: '' }; // 协议异常不放行也不拦截,交回内联/原生流程 res = { ok: false, error: 'request error: ' + (e?.message || e) };
log('request error: ' + (e?.message || e)); log('request error: ' + (e?.message || e));
} }
socket.write(JSON.stringify(res) + '\n', () => { try { socket.end(); } catch { /* 忽略 */ } }); socket.write(JSON.stringify(res) + '\n', () => { try { socket.end(); } catch { /* 忽略 */ } });
@@ -127,6 +129,7 @@ server.listen(PORT, '127.0.0.1', () => {
body: JSON.stringify({ model: all.config.model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }), body: JSON.stringify({ model: all.config.model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }),
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
if (!res.ok) throw new Error('HTTP ' + res.status);
await res.json().catch(() => {}); await res.json().catch(() => {});
llmWarm = true; llmWarm = true;
log('llm connection warmed in ' + (Date.now() - t0) + 'ms'); log('llm connection warmed in ' + (Date.now() - t0) + 'ms');
@@ -144,7 +147,14 @@ async function ping(port, timeoutMs) {
s.setEncoding('utf8'); s.setEncoding('utf8');
const to = setTimeout(() => { s.destroy(); reject(new Error('timeout')); }, timeoutMs); const to = setTimeout(() => { s.destroy(); reject(new Error('timeout')); }, timeoutMs);
s.on('connect', () => s.write(JSON.stringify({ op: 'ping' }) + '\n')); s.on('connect', () => s.write(JSON.stringify({ op: 'ping' }) + '\n'));
s.on('data', (c) => { buf += c; if (buf.includes('\n')) { clearTimeout(to); resolve(JSON.parse(buf)); s.end(); } }); s.on('data', (c) => {
buf += c;
if (buf.includes('\n')) {
clearTimeout(to);
try { resolve(JSON.parse(buf)); } catch { resolve(null); }
s.end();
}
});
s.on('error', (e) => { clearTimeout(to); reject(e); }); s.on('error', (e) => { clearTimeout(to); reject(e); });
}); });
} }
+13 -3
View File
@@ -108,8 +108,10 @@ async function proxyOrStartDaemon(port, mode, payload) {
return tryProxy(port, { op: 'hook', mode, payload, sigs: sigs(), env: { fullAccessGuard: ['1', 'true', 'yes', 'on'].includes(String(process.env.GATEKEEPER_FULL_ACCESS_GUARD || '').toLowerCase()) ? true : undefined } }, 42_000); return tryProxy(port, { op: 'hook', mode, payload, sigs: sigs(), env: { fullAccessGuard: ['1', 'true', 'yes', 'on'].includes(String(process.env.GATEKEEPER_FULL_ACCESS_GUARD || '').toLowerCase()) ? true : undefined } }, 42_000);
} }
const HOOK_MODE = process.argv[2] || '';
async function main() { async function main() {
const mode = process.argv[2] || ''; const mode = HOOK_MODE;
// ---- 管理命令(不读 stdin,即秒响应)---- // ---- 管理命令(不读 stdin,即秒响应)----
if (['install', 'uninstall', 'enable', 'disable', 'status', 'approve', 'shutdown-daemon'].includes(mode)) { if (['install', 'uninstall', 'enable', 'disable', 'status', 'approve', 'shutdown-daemon'].includes(mode)) {
@@ -123,6 +125,12 @@ async function main() {
let payload = null; let payload = null;
try { payload = JSON.parse(raw); } catch { payload = null; } try { payload = JSON.parse(raw); } catch { payload = null; }
// review 模式下输入必须可解析(fail-closed):schema 漂移/截断时宁可拒绝也不放行
if (mode === 'review' && payload == null) {
exitWith(2, '', '【Auto 审批员·拒绝】hook 输入不是有效 JSON,安全起见拒绝执行(fail-closed)。\n');
return;
}
const cfg = loadJsonFile(path.join(SCRIPT_DIR, 'config.json'), {}); const cfg = loadJsonFile(path.join(SCRIPT_DIR, 'config.json'), {});
const daemonEnabled = cfg.daemon !== false && !process.env.GATEKEEPER_NO_DAEMON; const daemonEnabled = cfg.daemon !== false && !process.env.GATEKEEPER_NO_DAEMON;
const port = cfg.daemonPort | 0 || 47811; const port = cfg.daemonPort | 0 || 47811;
@@ -142,9 +150,11 @@ async function main() {
} }
main().catch(e => { main().catch(e => {
// review 模式下基础设施崩溃按"拒绝"处理(fail-closed);其余模式按普通错误
const code = HOOK_MODE === 'review' ? 2 : 1;
process.stderr.write('【Auto 审批员·异常】' + (e?.message || String(e)) + '\n'); process.stderr.write('【Auto 审批员·异常】' + (e?.message || String(e)) + '\n');
try { process.stdin.destroy(); } catch { /* 忽略 */ } try { process.stdin.destroy(); } catch { /* 忽略 */ }
process.exitCode = 1; process.exitCode = code;
const t = setTimeout(() => { try { process.exit(1); } catch { /* 忽略 */ } }, 250); const t = setTimeout(() => { try { process.exit(code); } catch { /* 忽略 */ } }, 250);
if (typeof t.unref === 'function') t.unref(); if (typeof t.unref === 'function') t.unref();
}); });
+26
View File
@@ -138,6 +138,32 @@ check "approve --last → 放行并消费" 0 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"l1","tool_name":"Bash","tool_input":{"command":"rm -rf ~"},"cwd":"C:/x"}' run hard '{"hook_event_name":"PreToolUse","session_id":"l1","tool_name":"Bash","tool_input":{"command":"rm -rf ~"},"cwd":"C:/x"}'
check "消费后再执行仍拦截" 2 "$RC" "" "$OUT" check "消费后再执行仍拦截" 2 "$RC" "" "$OUT"
# ---------- 安全修复回归:复合命令缺口 / 状态文件投毒 / 自批 / dangerlist 变体 ----------
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f1","tool_name":"Bash","tool_input":{"command":"echo hi\nrm -rf ~/important"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "换行拼接 → 不再进白名单(灰区→fail-closed)" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f1","tool_name":"Bash","tool_input":{"command":"echo hi & rm -rf ~/important"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "单个 & 拼接 → 不再进白名单" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f1","tool_name":"Bash","tool_input":{"command":"echo hacked > /tmp/pwned.txt"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "重定向 → 不再进白名单" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f1","tool_name":"Bash","tool_input":{"command":"find / -delete"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "find / -delete → 移出白名单(灰区)" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"f2","tool_name":"Bash","tool_input":{"command":"echo x > approved.json"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "状态文件写入 → 确定性拒绝" 2 "$RC" "状态文件" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"f2","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"C:/x"}'
check "投毒失败后 rm -rf / 仍被黑名单拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"f3","tool_name":"Bash","tool_input":{"command":"node gatekeeper.mjs approve \"rm -rf /\" --yes"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "模型自批 approve → 确定性拒绝" 2 "$RC" "自批" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f4","tool_name":"Bash","tool_input":{"command":"del /f /s /q C:\\"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "del /f /s /q C:\\ → 拦截(flag 顺序无关)" 2 "$RC" "灾难命令黑名单" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"f4","tool_name":"Bash","tool_input":{"command":"Remove-Item -Recurse -Force C:\\*"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "Remove-Item C:\\* → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"f4","tool_name":"Bash","tool_input":{"command":"Format-Volume -DriveLetter C"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "Format-Volume → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f5","tool_name":"Bash","tool_input":{"command":"del /s /q C:/x/project/build"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "负例:项目子目录 del /s → 灰区(不误拦)" 2 "$RC" "审查器不可用" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"f6","tool_name":"Bash","tool_input":{"command":"git status"},"cwd":"C:/x"}'
check "白名单健康:git status 仍直通" 0 "$RC" "" "$OUT"
echo "==============================" echo "=============================="
echo "PASS=$pass FAIL=$fail" echo "PASS=$pass FAIL=$fail"
exit $fail exit $fail