fix: harden gatekeeper security boundaries

This commit is contained in:
rzy
2026-09-14 18:03:50 +08:00
parent 6d8dcd0b69
commit 2e3aea4eab
7 changed files with 946 additions and 255 deletions
+67 -31
View File
@@ -5,7 +5,7 @@
//
// 本文件是 hook 入口(哑客户端):优先把请求转发给常驻守护进程
// gatekeeper-daemon.mjs(热连接 + 内存缓存,按需自动拉起);
// 守护进程不可用时自动降级为内联模式(core.mjs 原逻辑,行为不变)。
// 守护进程不可用且请求尚未发送时,自动降级为内联模式。
//
// 由 ZCode hooks 注册调用(四个入口,见 ../README.md):
// node gatekeeper.mjs capture-prompt # UserPromptSubmit:捕获任务上下文
@@ -19,12 +19,16 @@
import net from 'node:net';
import { spawn } from 'node:child_process';
import { statSync, readFileSync } from 'node:fs';
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 CONFIG_FILES = ['config.json', 'policy.md', 'allowlist.txt', 'dangerlist.txt'];
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; }
@@ -41,43 +45,58 @@ function exitWith(code, stdout = '', stderr = '') {
if (typeof t.unref === 'function') t.unref();
}
function readStdin(ms = 5000) {
function readStdin(ms = 5000, maxBytes = 2 * 1024 * 1024) {
return new Promise(resolve => {
let data = '';
const t = setTimeout(() => resolve(data), ms);
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; });
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; }
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) {
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 to = setTimeout(() => { dbg('timeout, buf=' + JSON.stringify(buf).slice(0, 120)); s.destroy(); reject(new Error('proxy timeout')); }, timeoutMs);
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); };
s.on('connect', () => { dbg('connected'); s.write(JSON.stringify(reqObj) + '\n'); s.end(); });
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 { finish(resolve, JSON.parse(buf)); } catch (e) { finish(reject, e); }
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, new Error('connection closed before response')); });
s.on('error', e => finish(reject, e));
s.on('close', () => { if (!done) finish(reject, fail(new Error('connection closed before response'))); });
s.on('error', e => finish(reject, fail(e)));
});
}
@@ -91,24 +110,33 @@ function spawnDaemon(daemonPath) {
} catch { return false; }
}
async function proxyOrStartDaemon(port, mode, payload) {
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' }, ms).catch(() => null);
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'));
for (let waited = 0; waited <= 2500; waited += 150) {
const deadline = Date.now() + 2500;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 150));
if ((await ping(400))?.ok) { up = true; dbg('daemon ready after ' + (waited + 150) + 'ms'); break; }
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', mode, payload, sigs: sigs(), env: { fullAccessGuard: ['1', 'true', 'yes', 'on'].includes(String(process.env.GATEKEEPER_FULL_ACCESS_GUARD || '').toLowerCase()) ? true : undefined } }, 42_000);
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;
@@ -123,7 +151,7 @@ async function main() {
const raw = await readStdin();
let payload = null;
try { payload = JSON.parse(raw); } catch { payload = null; }
try { payload = typeof raw === 'string' ? JSON.parse(raw) : null; } catch { payload = null; }
// review 模式下输入必须可解析(fail-closed):schema 漂移/截断时宁可拒绝也不放行
if (mode === 'review' && payload == null) {
@@ -133,19 +161,27 @@ async function main() {
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;
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);
const r = await proxyOrStartDaemon(port, mode, payload, HOOK_LAYER);
if (r && typeof r.exitCode === 'number') return exitWith(r.exitCode, r.stdout || '', r.stderr || '');
// ping 应答等非 hook 结果 → 视为通道异常,走内联
} catch { /* 落入内联 */ }
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 }, fileDeps(), loadAll());
const result = await decide({ mode, payload, layer: HOOK_LAYER }, fileDeps(), loadAll());
exitWith(result.exitCode, result.stdout, result.stderr);
}