Files
zcode-gatekeeper/gatekeeper/gatekeeper.mjs
T

197 lines
8.9 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(热连接 + 内存缓存,按需自动拉起);
// 守护进程不可用且请求尚未发送时,自动降级为内联模式。
//
// 由 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 { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
daemonSecretFor, makeDaemonRequest, verifyDaemonResponse, daemonInstanceIdFor, resolveStateDir,
} from './daemon-auth.mjs';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CLIENT_STATE_DIR = resolveStateDir(SCRIPT_DIR, process.env.GATEKEEPER_STATE_DIR || '');
const DAEMON_INSTANCE_ID = daemonInstanceIdFor(SCRIPT_DIR);
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, maxBytes = 2 * 1024 * 1024) {
return new Promise(resolve => {
let data = '';
let bytes = 0;
let done = false;
const finish = (value) => { if (done) return; done = true; clearTimeout(t); resolve(value); };
const t = setTimeout(() => finish(data), ms);
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => {
data += c;
bytes += Buffer.byteLength(c, 'utf8');
if (bytes > maxBytes) {
try { process.stdin.destroy(); } catch { /* 忽略 */ }
finish(null);
}
});
process.stdin.on('end', () => finish(data));
process.stdin.on('error', () => finish(data));
});
}
// ---- 守护进程通道 -----------------------------------------------------------
function tryProxy(port, reqObj, timeoutMs, secret) {
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 = '';
let sent = false;
s.setEncoding('utf8');
const fail = (e) => { e.requestSent = sent; return e; };
const to = setTimeout(() => {
dbg('timeout, buf=' + JSON.stringify(buf).slice(0, 120));
s.destroy();
reject(fail(new Error('proxy timeout')));
}, timeoutMs);
let done = false;
const finish = (fn, arg) => { if (done) return; done = true; clearTimeout(to); s.destroy(); fn(arg); };
const envelope = makeDaemonRequest(secret, reqObj);
s.on('connect', () => { dbg('connected'); sent = true; s.write(JSON.stringify(envelope) + '\n'); s.end(); });
s.on('data', c => {
dbg('data: ' + JSON.stringify(String(c)).slice(0, 120));
buf += c;
if (buf.includes('\n')) {
try {
const parsed = JSON.parse(buf.slice(0, buf.indexOf('\n')));
if (!verifyDaemonResponse(secret, parsed, envelope.nonce)) throw new Error('unauthenticated daemon response');
finish(resolve, parsed.result);
} catch (e) { finish(reject, fail(e)); }
}
});
s.on('close', () => { if (!done) finish(reject, fail(new Error('connection closed before response'))); });
s.on('error', e => finish(reject, fail(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, layer) {
const dbg = (m) => { if (process.env.GATEKEEPER_DEBUG) process.stderr.write('[gk-debug] ' + m + '\n'); };
const secret = daemonSecretFor(CLIENT_STATE_DIR);
// 探测一律用无副作用的 ping;完整 hook 请求只发一次(避免重复触发 LLM)
const ping = (ms) => tryProxy(port, { op: 'ping', instanceId: DAEMON_INSTANCE_ID }, ms, secret).catch(() => null);
let up = (await ping(300))?.ok;
if (!up) {
dbg('daemon not reachable, spawning…');
spawnDaemon(path.join(SCRIPT_DIR, 'gatekeeper-daemon.mjs'));
const deadline = Date.now() + 2500;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 150));
if ((await ping(Math.min(400, Math.max(50, deadline - Date.now()))))?.ok) { up = true; dbg('daemon ready'); break; }
}
}
if (!up) { dbg('daemon never came up → 内联降级'); throw new Error('daemon not reachable'); }
return tryProxy(port, {
op: 'hook', instanceId: DAEMON_INSTANCE_ID, mode, layer, payload,
env: { fullAccessGuard: ['1', 'true', 'yes', 'on'].includes(String(process.env.GATEKEEPER_FULL_ACCESS_GUARD || '').toLowerCase()) ? true : undefined },
}, 40_000, secret);
}
const HOOK_MODE = process.argv[2] || '';
const HOOK_LAYER = (() => {
const arg = process.argv.slice(3).find(a => /^--layer=(hard|full)$/i.test(a));
return arg ? arg.split('=', 2)[1].toLowerCase() : '';
})();
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 = typeof raw === 'string' ? JSON.parse(raw) : null; } 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 = parseInt(process.env.GATEKEEPER_DAEMON_PORT || cfg.daemonPort, 10) || 47811;
if (daemonEnabled && (mode === 'review' || mode === 'capture-prompt' || mode === 'capture-tool')) {
try {
const r = await proxyOrStartDaemon(port, mode, payload, HOOK_LAYER);
if (r && typeof r.exitCode === 'number') return exitWith(r.exitCode, r.stdout || '', r.stderr || '');
const invalid = new Error('authenticated daemon returned no hook decision');
invalid.requestSent = true;
throw invalid;
} catch (e) {
// 请求可能已被 daemon 执行时不再重复审查;通道/认证异常直接 fail-closed,避免超时叠加。
if (mode === 'review' && e?.requestSent) {
return exitWith(2, '', '【Auto 审批员·拒绝】daemon 响应丢失或身份校验失败,安全起见拒绝执行。\n');
}
// 尚未发送完整 hook 请求,可安全落入内联。
}
}
// ---- 内联模式(daemon 关闭/不可达时的原逻辑)----
const { loadAll, fileDeps, decide } = await import('./core.mjs');
const result = await decide({ mode, payload, layer: HOOK_LAYER }, 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();
});