Files
zcode-gatekeeper/test.sh
T

248 lines
25 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# gatekeeper 决策路径单测:直接以管道喂 hook 样例输入,校验退出码与输出
# 用法: bash test.sh (在本文件所在目录执行)
# 说明:决策缓存绑定会话、任务、cwd、近期动作、工具参数与策略版本;
# 依赖真实审查模型的灰区判定只作为观察项打印,不做硬断言。
set -u
cd "$(dirname "$0")/gatekeeper" || cd "$(dirname "$0")"
rm -rf state # 清掉决策缓存与会话状态,保证测试确定性
GK="node gatekeeper.mjs"
pass=0; fail=0
check() { # $1=用例名 $2=期望退出码 $3=实际退出码 $4=附加检查(grep模式,可空) $5=实际输出
local ok=1
[ "$2" = "$3" ] || ok=0
if [ -n "$4" ]; then echo "$5" | grep -q "$4" || ok=0; fi
if [ "$ok" = 1 ]; then pass=$((pass+1)); echo "PASS $1 (exit=$3)"
else fail=$((fail+1)); echo "FAIL $1 (期望 exit=$2 $( [ -n "$4" ] && echo "/ 输出含 '$4'" ), 实际 exit=$3)"; echo "---- 输出 ----"; echo "$5" | head -5; echo "--------------"; fi
}
observe() { # $1=用例名 $2=实际退出码 $3=实际输出 —— 只打印不判分
echo "OBSERVE $1 (exit=$2)"; echo "$3" | head -2
}
run() { # $1=layer $2=json → 设置 OUT/RC(强制内联模式,保证确定性)
OUT=$(printf '%s' "$2" | GATEKEEPER_NO_DAEMON=1 $GK review --layer="$1" 2>&1); RC=$?
}
# ---------- 硬层(PreToolUse --layer=hard) ----------
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Read","tool_input":{"file_path":"a.txt"},"cwd":"C:/x"}'
check "只读工具 Read → 静默放行" 0 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"ls -la"},"cwd":"C:/x"}'
check "硬层 ls → 静默放行" 0 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm -rf /"},"cwd":"C:/x"}'
check "硬层 rm -rf / → 拦截" 2 "$RC" "灾难命令黑名单" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"rm -rf ~"},"cwd":"C:/x"}'
check "硬层 rm -rf ~ → 拦截" 2 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"cat log && rm -rf /"},"cwd":"C:/x"}'
check "复合命令夹带 rm -rf / → 拦截" 2 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"Remove-Item -Recurse -Force C:\\"},"cwd":"C:/x"}'
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"}'
check "rm -rf 家目录子目录 → 不拦" 0 "$RC" "" "$OUT"
run hard '{"hook_event_name":"PreToolUse","session_id":"t1","tool_name":"Bash","tool_input":{"command":"Remove-Item -Recurse -Force C:\\temp\\myapp\\build"},"cwd":"C:/x"}'
check "PowerShell 删项目内 build → 不拦" 0 "$RC" "" "$OUT"
# ---------- 完整层(PermissionRequest --layer=full) ----------
run full '{"hook_event_name":"PermissionRequest","session_id":"t2","tool_name":"Bash","tool_input":{"command":"git status"},"cwd":"C:/x"}'
check "完整层 git status → allow JSON" 0 "$RC" "permissionDecision" "$OUT"
run full '{"hook_event_name":"PermissionRequest","session_id":"t2","tool_name":"Write","tool_input":{"file_path":"C:/x/src/a.ts","content":"x"},"cwd":"C:/x"}'
check "完整层 cwd 内写文件 → allow JSON" 0 "$RC" "permissionDecision" "$OUT"
# 死端点 fail-closed:每个用例独立命令,避免共享缓存碰撞
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"t3","tool_name":"Bash","tool_input":{"command":"npm run build-dead-a"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "死端点 A → fail-closed 拒绝" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"t4","tool_name":"Bash","tool_input":{"command":"npm run build-dead-b"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=full 2>&1); RC=$?
check "死端点 B(未改配置,仍应拒绝)" 2 "$RC" "" "$OUT"
# 真实端点灰区判定:只观察不判分(LLM 结论随任务/模型可能合理变化)
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"t5","tool_name":"Bash","tool_input":{"command":"npm run build-real"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
observe "真实端点·灰区命令" "$RC" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PermissionRequest","session_id":"t5","tool_name":"Write","tool_input":{"file_path":"C:/Users/luo/other/b.ts","content":"x"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=full 2>&1); RC=$?
observe "真实端点·工作区外写入" "$RC" "$OUT"
# ---------- 完全访问守卫(fullAccessGuard) ----------
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"g1","tool_name":"Bash","tool_input":{"command":"npm run build-dead-c"},"cwd":"C:/x"}' | GATEKEEPER_FULL_ACCESS_GUARD=1 GATEKEEPER_NO_DAEMON=1 GATEKEEPER_BASE_URL="http://127.0.0.1:9" $GK review --layer=hard 2>&1); RC=$?
check "守卫开启:PreToolUse 灰区+死端点 → fail-closed 拒绝" 2 "$RC" "审查器不可用" "$OUT"
OUT=$(printf '%s' '{"hook_event_name":"PreToolUse","session_id":"g2","tool_name":"Bash","tool_input":{"command":"npm run build-off"},"cwd":"C:/x"}' | GATEKEEPER_NO_DAEMON=1 $GK review --layer=hard 2>&1); RC=$?
check "守卫关闭:PreToolUse 灰区 → 静默放行无 LLM" 0 "$RC" "" "$OUT"
# ---------- 守护进程冒烟(daemon 模式:client → 127.0.0.1 → 热连接 LLM) ----------
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"}' | GATEKEEPER_DAEMON_PORT=47994 node gatekeeper.mjs review --layer=hard 2>&1); RC=$?
check "daemon 通道:死端点 → fail-closed 拒绝" 2 "$RC" "审查器不可用" "$OUT"
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_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 逃生舱) ----------
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"
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"
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"
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"}'
check "deny 先行(进入 --last 队列)" 2 "$RC" "" "$OUT"
GATEKEEPER_STATE_DIR="$PWD/state" node gatekeeper.mjs approve --last --yes >/dev/null 2>&1
run hard '{"hook_event_name":"PreToolUse","session_id":"l1","tool_name":"Bash","tool_input":{"command":"rm -rf ~"},"cwd":"C:/x"}'
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"}'
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 > 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=$?
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"
# ---------- 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