fix: harden gatekeeper security boundaries

This commit is contained in:
rzy
2026-09-14 18:03:50 +08:00
parent 6d8dcd0b69
commit 2e3aea4eab
7 changed files with 946 additions and 255 deletions
+100 -22
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# gatekeeper 决策路径单测:直接以管道喂 hook 样例输入,校验退出码与输出
# 用法: bash test.sh (在本文件所在目录执行)
# 说明:决策缓存按「任务+工具+参数」全局共享,因此每个 LLM 用例使用独立命令避免碰撞;
# 说明:决策缓存绑定会话、任务、cwd、近期动作、工具参数与策略版本;
# 依赖真实审查模型的灰区判定只作为观察项打印,不做硬断言。
set -u
cd "$(dirname "$0")/gatekeeper" || cd "$(dirname "$0")"
@@ -47,6 +47,15 @@ check "PowerShell 盘根递归删除 → 拦截" 2 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"mkfs.ext4 /dev/sda1"},"cwd":"C:/x"}'
check "mkfs → 拦截" 2 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm -rf ${HOME}"},"cwd":"C:/x"}'
check "rm -rf \${HOME} → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm -rf /; echo bypass"},"cwd":"C:/x"}'
check "根删除后紧跟分号 → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm --recursive --force --no-preserve-root /"},"cwd":"C:/x"}'
check "rm 长参数删除根 → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"Remove-Item C:\\ -Recurse -Force"},"cwd":"C:/x"}'
check "PowerShell 路径在 flags 前 → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm -rf node_modules/.cache"},"cwd":"C:/x"}'
check "rm -rf 项目子目录 → 不拦(灰区交 LLM)" 0 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm -rf ~/projects/demo"},"cwd":"C:/x"}'
@@ -83,51 +92,103 @@ OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"g2","tool_name"
check "守卫关闭:PreToolUse 灰区 → 静默放行无 LLM" 0 "$RC" "" "$OUT"
# ---------- 守护进程冒烟(daemon 模式:client → 127.0.0.1 → 热连接 LLM) ----------
node gatekeeper.mjs shutdown-daemon >/dev/null 2>&1
GATEKEEPER_BASE_URL="http://127.0.0.1:9" node gatekeeper-daemon.mjs >/dev/null 2>&1 &
GATEKEEPER_DAEMON_PORT=47994 node gatekeeper.mjs shutdown-daemon >/dev/null 2>&1
GATEKEEPER_DAEMON_PORT=47994 GATEKEEPER_BASE_URL="http://127.0.0.1:9" node gatekeeper-daemon.mjs >/dev/null 2>&1 &
sleep 1.5
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"d1","tool_name":"Bash","tool_input":{"command":"npm run daemon-dead"},"cwd":"C:/x"}' | node gatekeeper.mjs review --layer=hard 2>&1); RC=$?
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"d1","tool_name":"Bash","tool_input":{"command":"npm run daemon-dead"},"cwd":"C:/x"}' | GATEKEEPER_DAEMON_PORT=47994 node gatekeeper.mjs review --layer=hard 2>&1); RC=$?
check "daemon 通道:死端点 → fail-closed 拒绝" 2 "$RC" "审查器不可用" "$OUT"
node gatekeeper.mjs shutdown-daemon >/dev/null 2>&1
GATEKEEPER_DAEMON_PORT=47994 node gatekeeper.mjs shutdown-daemon >/dev/null 2>&1
# 未认证的本地进程即使抢占端口并返回 exitCode=0,也不能冒充 daemon
node -e 'const net=require("net");net.createServer(s=>{s.on("error",()=>{});let b="";s.setEncoding("utf8");s.on("data",c=>{b+=c;if(b.includes("\n"))s.end(JSON.stringify({ok:true,exitCode:0,stdout:"FAKE_ALLOW",stderr:""})+"\n")})}).listen(47992,"127.0.0.1")' >/dev/null 2>&1 &
FAKE_SRV=$!
sleep 0.5
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"fake","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"C:/x"}' | GATEKEEPER_DAEMON_PORT=47992 $GK review --layer=hard 2>&1); RC=$?
check "伪 daemon 返回 allow → 客户端拒信并内联拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
kill $FAKE_SRV 2>/dev/null
# daemon 必须比较“上次已加载签名”与当前文件内容签名
HOT_DIR=$(mktemp -d)
cp ./*.mjs ./*.md ./*.txt ./config.example.json "$HOT_DIR/"
printf '%s' '{"apiKey":"PENDING","daemonPort":47988,"daemon":true,"toolAllowlist":[]}' > "$HOT_DIR/config.json"
GATEKEEPER_STATE_DIR="$HOT_DIR/state" node "$HOT_DIR/gatekeeper-daemon.mjs" >/dev/null 2>&1 &
HOT_DAEMON=$!
sleep 0.7
printf '%s' '{"hook_event_name":"PreToolUse","session_id":"hot","tool_name":"Read","tool_input":{"file_path":"C:/x/a"},"cwd":"C:/x"}' | GATEKEEPER_STATE_DIR="$HOT_DIR/state" node "$HOT_DIR/gatekeeper.mjs" review --layer=hard >/dev/null 2>&1
sleep 0.1
printf '%s' '{"apiKey":"PENDING","daemonPort":47988,"daemon":true,"toolAllowlist":["CustomSafe"]}' > "$HOT_DIR/config.json"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"hot","tool_name":"CustomSafe","tool_input":{"value":1},"cwd":"C:/x"}' | GATEKEEPER_STATE_DIR="$HOT_DIR/state" node "$HOT_DIR/gatekeeper.mjs" review --layer=full 2>&1); RC=$?
check "daemon 配置修改后即时热重载" 0 "$RC" "permissionDecision" "$OUT"
GATEKEEPER_STATE_DIR="$HOT_DIR/state" node "$HOT_DIR/gatekeeper.mjs" shutdown-daemon >/dev/null 2>&1
wait $HOT_DAEMON 2>/dev/null
printf '%s' '{invalid-json' > "$HOT_DIR/config.json"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"bad-config","tool_name":"Read","tool_input":{"file_path":"C:/x/a"},"cwd":"C:/x"}' | GATEKEEPER_STATE_DIR="$HOT_DIR/state" GATEKEEPER_NO_DAEMON=1 node "$HOT_DIR/gatekeeper.mjs" review --layer=hard 2>&1); RC=$?
check "损坏 config.json → fail-closed" 2 "$RC" "config.json" "$OUT"
printf '%s' '{"apiKey":"PENDING"}' > "$HOT_DIR/config.json"
rm "$HOT_DIR/dangerlist.txt"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"bad-dangerlist","tool_name":"Read","tool_input":{"file_path":"C:/x/a"},"cwd":"C:/x"}' | GATEKEEPER_STATE_DIR="$HOT_DIR/state" GATEKEEPER_NO_DAEMON=1 node "$HOT_DIR/gatekeeper.mjs" review --layer=hard 2>&1); RC=$?
check "缺失 dangerlist.txt → fail-closed" 2 "$RC" "灾难命令规则文件" "$OUT"
rm -rf "$HOT_DIR"
# ---------- extraBody 注入(自定义思考程度等厂商私有参数) ----------
node -e 'const http=require("http"),fs=require("fs");http.createServer((q,r)=>{let b="";q.on("data",c=>b+=c);q.on("end",()=>{fs.writeFileSync("state/echo-body.json",b);r.setHeader("Content-Type","application/json");r.end(JSON.stringify({choices:[{message:{content:JSON.stringify({shouldBlock:false,reason:""})}}]}))})}).listen(47991,()=>console.log("up"))' >/dev/null 2>&1 &
SRV=$!
sleep 1
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"eb","tool_name":"Bash","tool_input":{"command":"npm run echo-test"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_EXTRA_BODY='{"chat_template_kwargs":{"enable_thinking":false}}' GATEKEEPER_BASE_URL="http://127.0.0.1:47991/v1" $GK review --layer=full 2>&1); RC=$?
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"eb","tool_name":"Bash","tool_input":{"command":"npm run echo-test"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_EXTRA_BODY='{"chat_template_kwargs":{"enable_thinking":false}}' GATEKEEPER_BASE_URL="http://127.0.0.1:47991/v1" GATEKEEPER_MODEL=test-model $GK review --layer=full 2>&1); RC=$?
check "extraBody 注入 → 正常判定放行" 0 "$RC" "permissionDecision" "$OUT"
if grep -q '"enable_thinking":false' state/echo-body.json 2>/dev/null; then pass=$((pass+1)); echo "PASS extraBody 字段已进入请求体"; else fail=$((fail+1)); echo "FAIL extraBody 字段未进入请求体"; fi
if grep -q "从用户键盘输入直接捕获" state/echo-body.json 2>/dev/null; then pass=$((pass+1)); echo "PASS 防注入溯源标记已进入提示词"; else fail=$((fail+1)); echo "FAIL 防注入溯源标记缺失"; fi
for i in 1 2 3 4; do node -e 'process.stdout.write(JSON.stringify({hook_event_name:"UserPromptSubmit",session_id:"ctx",prompt:"OLD-"+process.argv[1]+"-"+"x".repeat(1900)}))' "$i" | GATEKEEPER_NO_DAEMON=1 $GK capture-prompt >/dev/null 2>&1; done
node -e 'process.stdout.write(JSON.stringify({hook_event_name:"UserPromptSubmit",session_id:"ctx",prompt:"LATEST_AUTH_MARKER-"+"y".repeat(1900)}))' | GATEKEEPER_NO_DAEMON=1 $GK capture-prompt >/dev/null 2>&1
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"ctx","tool_name":"Bash","tool_input":{"command":"npm run context-tail"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_BASE_URL="http://127.0.0.1:47991/v1" GATEKEEPER_MODEL=test-model $GK review --layer=full 2>&1); RC=$?
check "长任务上下文仍可正常审查" 0 "$RC" "permissionDecision" "$OUT"
if grep -q "LATEST_AUTH_MARKER" state/echo-body.json 2>/dev/null; then pass=$((pass+1)); echo "PASS 截断后仍保留最新用户输入"; else fail=$((fail+1)); echo "FAIL 截断丢失最新用户输入"; fi
kill $SRV 2>/dev/null
OUT=$(node -e 'process.stdout.write(JSON.stringify({hook_event_name:"PermissionRequest",session_id:"long",tool_name:"Bash",tool_input:{command:"x".repeat(7000)},cwd:"C:/x"}))' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
check "超长动作不能截尾后送审 → fail-closed" 2 "$RC" "maxInputChars" "$OUT"
# 缓存必须绑定 cwd;mock 在第二个目录返回 deny
node -e 'const http=require("http");http.createServer((q,r)=>{let b="";q.on("data",c=>b+=c);q.on("end",()=>{const blocked=b.includes("blocked");r.setHeader("Content-Type","application/json");r.end(JSON.stringify({choices:[{message:{content:JSON.stringify({shouldBlock:blocked,reason:blocked?"blocked cwd":""})}}]}))})}).listen(47989)' >/dev/null 2>&1 &
CACHE_SRV=$!
sleep 0.5
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"cache-cwd","tool_name":"Bash","tool_input":{"command":"npm run same-command"},"cwd":"C:/safe"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_MODEL=test-model GATEKEEPER_BASE_URL="http://127.0.0.1:47989/v1" $GK review --layer=full 2>&1); RC=$?
check "缓存上下文:安全 cwd 首次放行" 0 "$RC" "permissionDecision" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"cache-cwd","tool_name":"Bash","tool_input":{"command":"npm run same-command"},"cwd":"C:/blocked"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_MODEL=test-model GATEKEEPER_BASE_URL="http://127.0.0.1:47989/v1" $GK review --layer=full 2>&1); RC=$?
check "缓存不能跨 cwd 复用 allow" 2 "$RC" "blocked cwd" "$OUT"
kill $CACHE_SRV 2>/dev/null
# ---------- dangerlist 收紧回归:常见绝对路径子目录不拦 ----------
run hard '{"hook_event_name":"PreToolUse","session_id":"t9","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build"},"cwd":"C:/x"}'
check "rm -rf /tmp/build → 不拦(灰区交 LLM)" 0 "$RC" "" "$OUT"
# ---------- approve 一次性放行(deny 逃生舱) ----------
GATEKEEPER_STATE_DIR="$PWD/state" node gatekeeper.mjs approve "rm -rf /" --yes >/dev/null 2>&1
run hard '{"hook_event_name":"PreToolUse","session_id":"ap","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"C:/x"}'
GATEKEEPER_STATE_DIR="$PWD/state" node gatekeeper.mjs approve --last --yes >/dev/null 2>&1
run hard '{"hook_event_name":"PreToolUse","session_id":"ap","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"C:/x"}'
check "已批准命令 → 放行并消费" 0 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"ap","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"C:/x"}'
check "批准用后即焚 → 再执行仍拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
# ---------- onUncertain:拿不准的三种落地(种子缓存 ask 判定) ----------
seedask(){ node -e '
const crypto=require("crypto");
const task="(未捕获到任务上下文)";
const cmd="npm run uncertain-"+process.argv[1];
const key=crypto.createHash("sha256").update(task+"|Bash|"+JSON.stringify({command:cmd})).digest("hex").slice(0,24);
require("fs").writeFileSync("state/cache.json",JSON.stringify({[key]:{decision:"ask",reason:"模拟拿不准",ts:Date.now()}}));
' "$1"; }
seedask a
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"u1","tool_name":"Bash","tool_input":{"command":"npm run uncertain-a"},"cwd":"C:/x"}' | GATEKEEPER_ON_UNCERTAIN=deny GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"scope","tool_name":"Bash","tool_input":{"command":"npm run scoped-approval"},"cwd":"C:/safe-project"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
check "目录绑定批准:先产生拒绝" 2 "$RC" "审查器不可用" "$OUT"
GATEKEEPER_STATE_DIR="$PWD/state" node gatekeeper.mjs approve --last --yes >/dev/null 2>&1
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"scope","tool_name":"Bash","tool_input":{"command":"npm run scoped-approval"},"cwd":"C:/other-project"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
check "批准不能跨 cwd 使用" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"scope","tool_name":"Bash","tool_input":{"command":"npm run scoped-approval"},"cwd":"C:/safe-project"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
check "批准在原 cwd 可消费" 0 "$RC" "permissionDecision" "$OUT"
# ---------- onUncertain:拿不准的三种落地(本地 mock 返回 ask) ----------
node -e 'const http=require("http");http.createServer((q,r)=>{q.resume();q.on("end",()=>{r.setHeader("Content-Type","application/json");r.end(JSON.stringify({choices:[{message:{content:JSON.stringify({decision:"ask",reason:"模拟拿不准"})}}]}))})}).listen(47990)' >/dev/null 2>&1 &
ASK_SRV=$!
sleep 0.5
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"u1","tool_name":"Bash","tool_input":{"command":"npm run uncertain-a"},"cwd":"C:/x"}' | GATEKEEPER_ON_UNCERTAIN=deny GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_MODEL=test-model GATEKEEPER_BASE_URL="http://127.0.0.1:47990/v1" $GK review --layer=full 2>&1); RC=$?
check "onUncertain=deny → 拿不准也硬拒" 2 "$RC" "拒绝" "$OUT"
seedask b
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"u1","tool_name":"Bash","tool_input":{"command":"npm run uncertain-b"},"cwd":"C:/x"}' | GATEKEEPER_ON_UNCERTAIN=prompt GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"u1","tool_name":"Bash","tool_input":{"command":"npm run uncertain-b"},"cwd":"C:/x"}' | GATEKEEPER_ON_UNCERTAIN=prompt GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_MODEL=test-model GATEKEEPER_BASE_URL="http://127.0.0.1:47990/v1" $GK review --layer=full 2>&1); RC=$?
check "onUncertain=prompt → 静默交原生弹窗" 0 "$RC" "" "$OUT"
seedask c
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"u1","tool_name":"Bash","tool_input":{"command":"npm run uncertain-c"},"cwd":"C:/x"}' | GATEKEEPER_ON_UNCERTAIN=allow GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"u1","tool_name":"Bash","tool_input":{"command":"npm run uncertain-c"},"cwd":"C:/x"}' | GATEKEEPER_ON_UNCERTAIN=allow GATEKEEPER_NO_DAEMON=1 GATEKEEPER_API_KEY=test GATEKEEPER_MODEL=test-model GATEKEEPER_BASE_URL="http://127.0.0.1:47990/v1" $GK review --layer=full 2>&1); RC=$?
check "onUncertain=allow → 放行 JSON" 0 "$RC" "permissionDecision" "$OUT"
kill $ASK_SRV 2>/dev/null
# ---------- approve --last(批准最近被拒命令) ----------
run hard '{"hook_event_name":"PreToolUse","session_id":"l1","tool_name":"Bash","tool_input":{"command":"rm -rf ~"},"cwd":"C:/x"}'
@@ -147,8 +208,12 @@ OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f1","too
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=$?
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"f2","tool_name":"Bash","tool_input":{"command":"echo x > state/approved.json"},"cwd":"."}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "状态文件写入 → 确定性拒绝" 2 "$RC" "状态文件" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"f2","tool_name":"Write","tool_input":{"file_path":"state/approved.json","content":"[]"},"cwd":"."}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
check "Write 快速路径不能覆盖批准列表" 2 "$RC" "状态文件" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"f2","tool_name":"Read","tool_input":{"file_path":"state/daemon-secret"},"cwd":"."}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "Read 白名单不能读取 daemon secret" 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=$?
@@ -164,6 +229,19 @@ check "负例:项目子目录 del /s → 灰区(不误拦)" 2 "$RC" "审
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"
# ---------- fail-closed / layer / 审查器 prompt 回归 ----------
OUT=$(printf '%s' '{}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "有效 JSON 但 schema 未知 → fail-closed" 2 "$RC" "hook 输入" "$OUT"
OUT=$(printf '%s' '{"tool_name":"Bash","tool_input":{"command":"npm run layer-full"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
check "缺事件字段但 --layer=full → 仍完整审查" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"fp","tool_name":"Bash","tool_input":{"command":"npm run reviewer-prompt"},"cwd":"C:/x"}' | GATEKEEPER_FULL_ACCESS_GUARD=1 GATEKEEPER_ON_REVIEWER_ERROR=prompt GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "守卫模式 reviewer prompt → PreToolUse ask JSON" 0 "$RC" '"permissionDecision":"ask"' "$OUT"
# 拒绝提示只允许固定的 --last,不能把模型提供的 $() 嵌进可复制命令
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"hint","tool_name":"Bash","tool_input":{"command":"rm -rf / ; $(echo injected-marker)"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "approve 提示不回显可执行命令替换" 2 "$RC" "approve --last" "$OUT"
if echo "$OUT" | grep -q 'injected-marker'; then fail=$((fail+1)); echo "FAIL approve 提示仍含命令替换"; else pass=$((pass+1)); echo "PASS approve 提示未嵌入命令替换"; fi
echo "=============================="
echo "PASS=$pass FAIL=$fail"
exit $fail