#!/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(); });