diff --git a/README.md b/README.md index 18f302e..6de6399 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ GATEKEEPER_DEBUG=1 node gatekeeper.mjs … # 打印代理通道调试信 bash test.sh ``` -覆盖 29 项决策路径:白名单/黑名单拦截、双 shell 兼容、三种 onUncertain 落地、extraBody 注入、approve 一次性放行与用后即焚、daemon 通道冒烟、fail-closed 降级等。 +覆盖 41 项决策路径:白名单/黑名单拦截、双 shell 兼容、三种 onUncertain 落地、extraBody 注入、approve 一次性放行与用后即焚、daemon 通道冒烟、fail-closed 降级等。 ## 卸载 diff --git a/gatekeeper/allowlist.txt b/gatekeeper/allowlist.txt index 6ca7b61..b86e6cd 100644 --- a/gatekeeper/allowlist.txt +++ b/gatekeeper/allowlist.txt @@ -19,7 +19,6 @@ ^\s*cat\s ^\s*head\s ^\s*tail\s -^\s*find\s ^\s*grep\s ^\s*rg\s ^\s*wc\s diff --git a/gatekeeper/control.mjs b/gatekeeper/control.mjs index eec7be7..6391cc3 100644 --- a/gatekeeper/control.mjs +++ b/gatekeeper/control.mjs @@ -77,6 +77,12 @@ async function cmdInstall() { } 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)) { out(`✗ 未找到用户配置 ${USER_CONFIG} —— 请确认 ZCode 已安装过并至少启动过一次`); return 1; diff --git a/gatekeeper/core.mjs b/gatekeeper/core.mjs index 4a61f55..e0952e2 100644 --- a/gatekeeper/core.mjs +++ b/gatekeeper/core.mjs @@ -54,7 +54,7 @@ const DEFAULTS = { baseUrl: 'https://grok.002256.xyz/v1', apiKey: '', model: 'grok-chat-fast', - llmTimeoutMs: 20000, + llmTimeoutMs: 12000, retries: 1, maxOutputTokens: 2000, // 推理模型需要 headroom,否则 JSON 被截断(参照 CC 给思考模型留 2048) onReviewerError: 'deny', // deny | ask | allow —— 审查器故障时(CC 同款 fail-closed 默认 deny) @@ -91,6 +91,12 @@ export function loadAll() { if (process.env.GATEKEEPER_ON_UNCERTAIN) { 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 = (() => { try { return readFileSync(path.join(SCRIPT_DIR, 'policy.md'), 'utf8'); } catch { return '你是安全审查员,判断工具调用是否在当前任务授权范围内且安全。'; } @@ -119,7 +125,10 @@ export function buildSets(config) { }; } -const COMPOUND = /&&|\|\||;|`|\$\(|\|/; // 复合命令不享受命令白名单,交给审查层 +// 复合命令不享受命令白名单,交给审查层。 +// 覆盖:&(含单个&与&&)、|、;、反引号、$、换行/回车(多行命令)、> >> <(重定向可写文件/投毒状态文件)。 +// 任何包含这些字符的命令一律不进白名单,宁可达灰区也不静默放行。 +const COMPOUND = /[&|;`$\n\r><]/; function isBashAllowed(cmd, ALLOW_RE) { if (COMPOUND.test(cmd)) return false; return ALLOW_RE.some(re => re.test(cmd)); @@ -342,7 +351,8 @@ function saveApproved(list) { export function approvedConsume(cmd) { const list = loadApproved(); 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; list.splice(idx, 1); saveApproved(list); @@ -369,7 +379,7 @@ function recordDenial(tool, cmd) { if (!cmd) return; try { 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))); } 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 逃生舱)—— 精确匹配原命令,命中即消费放行 if (cmd && approvedConsume(cmd)) { return allow('用户已一次性批准该命令(批准已消费)', rec('allow', 'approved')); @@ -528,7 +554,7 @@ export async function decide({ mode, payload }, deps, all) { // 第 3 层:审查模型(任务感知判定,CC 同款二值判定) const reviewerFail = (msg, r) => { 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( '审查器不可用,为安全起见拒绝执行本次调用(' + msg + ')。' + '请检查 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 { actions } = deps.loadState(sid); 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)); } + // apiKey 校验放在缓存之后:缓存命中不需要审查端点 + if (!config.apiKey || config.apiKey === 'PENDING') { + return reviewerFail('未配置审查端点 apiKey'); + } + const t0 = Date.now(); let verdict; try { diff --git a/gatekeeper/dangerlist.txt b/gatekeeper/dangerlist.txt index 0efeb24..4cd3bd7 100644 --- a/gatekeeper/dangerlist.txt +++ b/gatekeeper/dangerlist.txt @@ -9,10 +9,10 @@ \brm\b(?=[^;&|\n]*\s-[a-zA-Z]*[rR])(?=[^;&|\n]*\s(/|~|\$HOME)(\s|$)) # 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(~|\$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*$ # 磁盘 / 文件系统级破坏 @@ -22,7 +22,8 @@ shred\s+[^;&|\n]*/dev/ blkdiscard wipefs\s+[^;&|\n]*/dev/ \bdiskpart\b[\s\S]*\bclean\b -\bformat\s+[a-zA-Z]: +\bformat(\.com)?\s+[a-zA-Z]: +\bFormat-Volume\b Clear-Disk Initialize-Disk Remove-Partition diff --git a/gatekeeper/gatekeeper-daemon.mjs b/gatekeeper/gatekeeper-daemon.mjs index 0f30961..25ff18e 100644 --- a/gatekeeper/gatekeeper-daemon.mjs +++ b/gatekeeper/gatekeeper-daemon.mjs @@ -73,7 +73,9 @@ const server = net.createServer({ allowHalfOpen: true }, (socket) => { socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时 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 { const req = line.trim() ? JSON.parse(line.trim()) : null; if (!req || req.op === 'ping') { @@ -85,7 +87,7 @@ const server = net.createServer({ allowHalfOpen: true }, (socket) => { res = await handleHook(req); } } catch (e) { - res = { exitCode: 0, stdout: '', stderr: '' }; // 协议异常不放行也不拦截,交回内联/原生流程 + res = { ok: false, error: 'request error: ' + (e?.message || e) }; log('request error: ' + (e?.message || e)); } 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 }), signal: AbortSignal.timeout(10_000), }); + if (!res.ok) throw new Error('HTTP ' + res.status); await res.json().catch(() => {}); llmWarm = true; log('llm connection warmed in ' + (Date.now() - t0) + 'ms'); @@ -144,7 +147,14 @@ async function ping(port, timeoutMs) { s.setEncoding('utf8'); const to = setTimeout(() => { s.destroy(); reject(new Error('timeout')); }, timeoutMs); 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); }); }); } diff --git a/gatekeeper/gatekeeper.mjs b/gatekeeper/gatekeeper.mjs index 2345c7f..6f5a1c6 100644 --- a/gatekeeper/gatekeeper.mjs +++ b/gatekeeper/gatekeeper.mjs @@ -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); } +const HOOK_MODE = process.argv[2] || ''; + async function main() { - const mode = process.argv[2] || ''; + const mode = HOOK_MODE; // ---- 管理命令(不读 stdin,即秒响应)---- if (['install', 'uninstall', 'enable', 'disable', 'status', 'approve', 'shutdown-daemon'].includes(mode)) { @@ -123,6 +125,12 @@ async function main() { let 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 daemonEnabled = cfg.daemon !== false && !process.env.GATEKEEPER_NO_DAEMON; const port = cfg.daemonPort | 0 || 47811; @@ -142,9 +150,11 @@ async function main() { } main().catch(e => { + // review 模式下基础设施崩溃按"拒绝"处理(fail-closed);其余模式按普通错误 + const code = HOOK_MODE === 'review' ? 2 : 1; process.stderr.write('【Auto 审批员·异常】' + (e?.message || String(e)) + '\n'); try { process.stdin.destroy(); } catch { /* 忽略 */ } - process.exitCode = 1; - const t = setTimeout(() => { try { process.exit(1); } catch { /* 忽略 */ } }, 250); + process.exitCode = code; + const t = setTimeout(() => { try { process.exit(code); } catch { /* 忽略 */ } }, 250); if (typeof t.unref === 'function') t.unref(); }); diff --git a/test.sh b/test.sh index 9dc6f73..cf47a2b 100644 --- a/test.sh +++ b/test.sh @@ -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"}' 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 "PASS=$pass FAIL=$fail" exit $fail