Files
zcode-gatekeeper/gatekeeper/gatekeeper.mjs
T
luoxiaoxin123 b207dbcaa0 安全修复:复合命令缺口、状态文件投毒、自批命令、协议 fail-open、dangerlist 漏报
- COMPOUND 补全换行/单个&/重定向(白名单不再被多行/重定向命令绕过),find 移出白名单
- gatekeeper 状态文件读写确定性硬拒(防 approved.json 投毒绕过灾难黑名单)
- 模型自批 approve 命令确定性硬拒(防 --yes 自我放行)
- approvedConsume 校验时间戳必须在过去(防未来时间戳使批准永不过期)
- daemon 协议错误/未知 op 返回 {ok:false},客户端降级内联完整审查(消除 fail-open)
- onReviewerError 支持 prompt 别名;llmTimeoutMs 超时预算钳制(防 hook 超时 fail-open)
- dangerlist 补漏:del/rd flag 顺序无关、Remove-Item 盘根星号、Format-Volume、format.com
- review 输入 JSON 解析失败与基础设施崩溃改为 fail-closed
- install 首次安装从模板生成 config.json;回归扩展至 41 项
2026-09-13 20:59:36 +08:00

161 lines
7.3 KiB
JavaScript
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 node
// ============================================================================
// ZCode「Auto 审批员」gatekeeper —— 对标 Claude Code auto mode 的外置审查 hook
// 审查提示词与判定结构参照 Claude Code 真实源码(yolo-classifier-prompts)。
//
// 本文件是 hook 入口(哑客户端):优先把请求转发给常驻守护进程
// gatekeeper-daemon.mjs(热连接 + 内存缓存,按需自动拉起);
// 守护进程不可用时自动降级为内联模式(core.mjs 原逻辑,行为不变)。
//
// 由 ZCode hooks 注册调用(四个入口,见 ../README.md):
// node gatekeeper.mjs capture-prompt # UserPromptSubmit:捕获任务上下文
// node gatekeeper.mjs capture-tool # PostToolUse:记录近期动作
// node gatekeeper.mjs review # PreToolUse / PermissionRequest
// node gatekeeper.mjs shutdown-daemon # 停止守护进程(维护用)
//
// 决策协议:deny → exit 2 + stderr 理由;allow → exit 0(PermissionRequest 输出决策 JSON);
// ask → exit 0 无输出(回落 ZCode 原生权限弹窗)
// ============================================================================
import net from 'node:net';
import { spawn } from 'node:child_process';
import { statSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_FILES = ['config.json', 'policy.md', 'allowlist.txt', 'dangerlist.txt'];
function loadJsonFile(file, fallback) {
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
}
// 安全退出:销毁 stdin → 设 exitCode → 事件循环自然排空(Windows 直接 process.exit
// 会触发 libuv 断言崩溃,exit=127 会被 ZCode 误判为 hook 错误)
function exitWith(code, stdout = '', stderr = '') {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
try { process.stdin.destroy(); } catch { /* 已关闭 */ }
process.exitCode = code;
const t = setTimeout(() => { try { process.exit(process.exitCode || 0); } catch { /* 忽略 */ } }, 250);
if (typeof t.unref === 'function') t.unref();
}
function readStdin(ms = 5000) {
return new Promise(resolve => {
let data = '';
const t = setTimeout(() => resolve(data), ms);
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => { data += c; });
process.stdin.on('end', () => { clearTimeout(t); resolve(data); });
process.stdin.on('error', () => { clearTimeout(t); resolve(data); });
});
}
function sigs() {
return CONFIG_FILES.map(f => {
try { return statSync(path.join(SCRIPT_DIR, f)).mtimeMs; } catch { return -1; }
});
}
// ---- 守护进程通道 -----------------------------------------------------------
function tryProxy(port, reqObj, timeoutMs) {
return new Promise((resolve, reject) => {
const dbg = (m) => { if (process.env.GATEKEEPER_DEBUG) process.stderr.write('[gk-debug|sock] ' + m + '\n'); };
const s = net.connect(port, '127.0.0.1');
let buf = '';
s.setEncoding('utf8');
const to = setTimeout(() => { dbg('timeout, buf=' + JSON.stringify(buf).slice(0, 120)); s.destroy(); reject(new Error('proxy timeout')); }, timeoutMs);
let done = false;
const finish = (fn, arg) => { if (done) return; done = true; clearTimeout(to); s.destroy(); fn(arg); };
s.on('connect', () => { dbg('connected'); s.write(JSON.stringify(reqObj) + '\n'); s.end(); });
s.on('data', c => {
dbg('data: ' + JSON.stringify(String(c)).slice(0, 120));
buf += c;
if (buf.includes('\n')) {
try { finish(resolve, JSON.parse(buf)); } catch (e) { finish(reject, e); }
}
});
s.on('close', () => { if (!done) finish(reject, new Error('connection closed before response')); });
s.on('error', e => finish(reject, e));
});
}
function spawnDaemon(daemonPath) {
try {
const child = spawn(process.execPath, [daemonPath], {
detached: true, stdio: 'ignore', windowsHide: true, env: process.env, cwd: SCRIPT_DIR,
});
child.unref();
return true;
} catch { return false; }
}
async function proxyOrStartDaemon(port, mode, payload) {
const dbg = (m) => { if (process.env.GATEKEEPER_DEBUG) process.stderr.write('[gk-debug] ' + m + '\n'); };
// 探测一律用无副作用的 ping;完整 hook 请求只发一次(避免重复触发 LLM)
const ping = (ms) => tryProxy(port, { op: 'ping' }, ms).catch(() => null);
let up = (await ping(300))?.ok;
if (!up) {
dbg('daemon not reachable, spawning…');
spawnDaemon(path.join(SCRIPT_DIR, 'gatekeeper-daemon.mjs'));
for (let waited = 0; waited <= 2500; waited += 150) {
await new Promise(r => setTimeout(r, 150));
if ((await ping(400))?.ok) { up = true; dbg('daemon ready after ' + (waited + 150) + 'ms'); break; }
}
}
if (!up) { dbg('daemon never came up → 内联降级'); throw new Error('daemon not reachable'); }
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 = HOOK_MODE;
// ---- 管理命令(不读 stdin,即秒响应)----
if (['install', 'uninstall', 'enable', 'disable', 'status', 'approve', 'shutdown-daemon'].includes(mode)) {
const { runControl } = await import('./control.mjs');
const code = await runControl(process.argv.slice(2));
exitWith(code ?? 0);
return;
}
const raw = await readStdin();
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;
if (daemonEnabled && (mode === 'review' || mode === 'capture-prompt' || mode === 'capture-tool')) {
try {
const r = await proxyOrStartDaemon(port, mode, payload);
if (r && typeof r.exitCode === 'number') return exitWith(r.exitCode, r.stdout || '', r.stderr || '');
// ping 应答等非 hook 结果 → 视为通道异常,走内联
} catch { /* 落入内联 */ }
}
// ---- 内联模式(daemon 关闭/不可达时的原逻辑)----
const { loadAll, fileDeps, decide } = await import('./core.mjs');
const result = await decide({ mode, payload }, fileDeps(), loadAll());
exitWith(result.exitCode, result.stdout, result.stderr);
}
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 = code;
const t = setTimeout(() => { try { process.exit(code); } catch { /* 忽略 */ } }, 250);
if (typeof t.unref === 'function') t.unref();
});