- 任务感知 LLM 审查(UserPromptSubmit 捕获任务 + PostToolUse 组合风险) - 四层流水线:工具白名单 / cwd 内编辑快速路径 / 命令白名单 / 灾难命令黑名单确定性拦截 - 防提示注入:授权只认键盘捕获段,伪造授权直接拒绝 - onUncertain 三值可配(deny/prompt/allow)+ ZCode 原生弹窗升级(已实测) - approve 一次性放行逃生舱(精确匹配、用后即焚、审计留痕) - 常驻 daemon:TLS 连接热复用、决策缓存、配置热重载、内联降级兜底 - 一键 install/uninstall/status、extraBody 请求体注入、29 项回归测试
144 lines
11 KiB
Bash
144 lines
11 KiB
Bash
#!/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
|