Files
zcode-gatekeeper/test.sh
T
luoxiaoxin123 d1188c8eef ZCode Auto 审批员 v1:对标 Claude Code auto mode 的外置审查 hook
- 任务感知 LLM 审查(UserPromptSubmit 捕获任务 + PostToolUse 组合风险)
- 四层流水线:工具白名单 / cwd 内编辑快速路径 / 命令白名单 / 灾难命令黑名单确定性拦截
- 防提示注入:授权只认键盘捕获段,伪造授权直接拒绝
- onUncertain 三值可配(deny/prompt/allow)+ ZCode 原生弹窗升级(已实测)
- approve 一次性放行逃生舱(精确匹配、用后即焚、审计留痕)
- 常驻 daemon:TLS 连接热复用、决策缓存、配置热重载、内联降级兜底
- 一键 install/uninstall/status、extraBody 请求体注入、29 项回归测试
2026-09-13 20:11:49 +08:00

144 lines
11 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 (在本文件所在目录执行)
# 说明:决策缓存按「任务+工具+参数」全局共享,因此每个 LLM 用例使用独立命令避免碰撞;
# 依赖真实审查模型的灰区判定只作为观察项打印,不做硬断言。
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 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) ----------
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 &
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=$?
check "daemon 通道:死端点 → fail-closed 拒绝" 2 "$RC" "审查器不可用" "$OUT"
node gatekeeper.mjs shutdown-daemon >/dev/null 2>&1
# ---------- 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=$?
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
kill $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"}'
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=$?
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=$?
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=$?
check "onUncertain=allow → 放行 JSON" 0 "$RC" "permissionDecision" "$OUT"
# ---------- 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"
echo "=============================="
echo "PASS=$pass FAIL=$fail"
exit $fail