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
+84 -32
View File
@@ -3,66 +3,83 @@
// gatekeeper daemon —— 常驻守卫进程
//
// 收益:LLM 连接常驻(免每次冷启动的 DNS/TLS 握手)、决策缓存与任务状态驻内存、
// 配置文件热重载(客户端每次请求带上四个配置文件的 mtime 签名,不一致即重载)。
// 配置文件热重载(每次请求比较四个配置文件的内容签名,不一致即重载)。
//
// 由 gatekeeper.mjs 客户端按需拉起(detached),无需手工运行;手工调试:
// node gatekeeper-daemon.mjs # 前台运行,日志同时打到 stdout
// node gatekeeper.mjs shutdown-daemon # 通过客户端停止
//
// 协议(127.0.0.1:daemonPort,行分隔 JSON):
// 请求: {"op":"ping"} | {"op":"shutdown"} |
// {"op":"hook","mode":"review","payload":{...hook输入...},"sigs":[...mtime],"env":{"fullAccessGuard":bool?}}
// 响应: {"ok":true} | {"exitCode":0|2,"stdout":"...","stderr":"..."}
// 请求/响应均使用 daemon-auth.mjs 的 nonce + timestamp + HMAC envelope;
// 业务 payload: {"op":"ping"} | {"op":"shutdown"} |
// {"op":"hook","mode":"review","layer":"hard|full","payload":{...hook输入...}}
// ============================================================================
import net from 'node:net';
import { spawn } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { appendFileSync, existsSync, statSync, renameSync } from 'node:fs';
import path from 'node:path';
import { SCRIPT_DIR, STATE_DIR, loadAll, configSigs, memDeps, decide } from './core.mjs';
import {
SCRIPT_DIR, STATE_DIR, loadAll, configSigs, memDeps, decide,
} from './core.mjs';
import {
daemonSecretFor, makeDaemonRequest, verifyDaemonRequest, makeDaemonResponse,
verifyDaemonResponse, daemonInstanceIdFor,
} from './daemon-auth.mjs';
const LOG = path.join(STATE_DIR, 'daemon.log');
function log(msg) {
const line = '[' + new Date().toISOString() + '] ' + msg;
try { appendFileSync(LOG, line + '\n'); } catch { /* 忽略 */ }
try {
if (existsSync(LOG) && statSync(LOG).size > 2 * 1024 * 1024) renameSync(LOG, LOG + '.' + Date.now());
appendFileSync(LOG, line + '\n', { mode: 0o600 });
} catch { /* 忽略 */ }
if (!process.env.GATEKEEPER_DAEMON_QUIET) console.log(line);
}
let all = loadAll();
let deps = memDeps();
const PORT = all.config.daemonPort | 0 || 47811;
const SECRET = daemonSecretFor(STATE_DIR);
const DAEMON_INSTANCE_ID = daemonInstanceIdFor(SCRIPT_DIR);
let loadedSigs = configSigs();
let llmWarm = false;
const seenNonces = new Map();
let lastRejectedLog = 0;
function logRejected(msg) {
const now = Date.now();
if (now - lastRejectedLog < 5000) return;
lastRejectedLog = now;
log(msg);
}
function reloadIfStale(sigs) {
if (!Array.isArray(sigs)) return;
function claimNonce(nonce) {
const now = Date.now();
for (const [n, ts] of seenNonces) if (now - ts > 60_000) seenNonces.delete(n);
if (seenNonces.has(nonce)) return false;
seenNonces.set(nonce, now);
return true;
}
function reloadIfStale() {
const cur = configSigs();
if (sigs.some((v, i) => v !== cur[i])) {
if (cur.some((v, i) => v !== loadedSigs[i])) {
all = loadAll();
loadedSigs = cur;
llmWarm = false;
log('config hot-reloaded (sigs changed)');
}
}
// 并发去重:同一请求(同 mode+payload+env)并发到达时共享一次决策,避免重复打 LLM
const inflight = new Map();
async function handleHook(req) {
reloadIfStale(req.sigs);
const env = req.env || {};
const effective = env.fullAccessGuard != null
? { ...all, config: { ...all.config, fullAccessGuard: all.config.fullAccessGuard || env.fullAccessGuard === true } }
: all;
const key = req.mode + '|' + JSON.stringify(req.payload) + '|' + (env.fullAccessGuard ? 1 : 0);
if (inflight.has(key)) {
log('hook dedup (in-flight shared)');
return inflight.get(key);
}
const t0 = Date.now();
const p = (async () => {
const result = await decide({ mode: req.mode, payload: req.payload }, deps, effective);
log(`hook ${req.mode}/${req.payload?.tool_name || '?'} -> exit=${result.exitCode} (${Date.now() - t0}ms, llmWarm=${llmWarm})`);
return result;
})();
inflight.set(key, p);
try { return await p; } finally { inflight.delete(key); }
// 不共享 review 决策:一次性批准必须严格只消费并放行一个请求。
const result = await decide({ mode: req.mode, payload: req.payload, layer: req.layer }, deps, effective);
log(`hook ${req.mode}/${req.payload?.tool_name || '?'} -> exit=${result.exitCode} (${Date.now() - t0}ms, llmWarm=${llmWarm})`);
return result;
}
// allowHalfOpen: true —— 客户端会半关闭(write+end),hook 判定是异步的(LLM 数秒),
@@ -78,20 +95,38 @@ function armIdle() {
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
let buf = '';
let bufferedBytes = 0;
socket.setEncoding('utf8');
socket.setTimeout(42_000, () => socket.destroy()); // 兜底:不允许超过 hook 超时
const dispatch = async (line) => {
armIdle(); // 任何请求(含 ping)都重置空闲计时
// 协议异常/未知 op 一律返回 {ok:false}(无 exitCode 字段)——客户端据此降级内联做完整审查,
// 绝不能返回 {exitCode:0}(会被当作"明确放行"决策,形成 fail-open)。
let res = { ok: false, error: 'unknown op' };
let nonce = '';
try {
const req = line.trim() ? JSON.parse(line.trim()) : null;
const envelope = line.trim() ? JSON.parse(line.trim()) : null;
if (!verifyDaemonRequest(SECRET, envelope)) {
logRejected('rejected unauthenticated local request');
socket.destroy();
return;
}
if (!claimNonce(envelope.nonce)) {
logRejected('rejected replayed local request');
socket.destroy();
return;
}
nonce = envelope.nonce;
const req = envelope.payload;
if (req.instanceId !== DAEMON_INSTANCE_ID) throw new Error('daemon instance mismatch');
reloadIfStale();
armIdle(); // 只有通过认证的请求(含 ping)才重置空闲计时
if (!req || req.op === 'ping') {
res = { ok: true, llmWarm, version: 1 };
} else if (req.op === 'shutdown') {
socket.write(JSON.stringify({ ok: true, bye: true }) + '\n', () => { log('shutdown by request'); process.exit(0); });
socket.write(JSON.stringify(makeDaemonResponse(SECRET, nonce, { ok: true, bye: true })) + '\n', () => {
log('shutdown by authenticated request'); process.exit(0);
});
return;
} else if (req.op === 'hook') {
res = await handleHook(req);
@@ -100,15 +135,23 @@ const server = net.createServer({ allowHalfOpen: true }, (socket) => {
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 { /* 忽略 */ } });
if (!nonce) { socket.destroy(); return; }
socket.write(JSON.stringify(makeDaemonResponse(SECRET, nonce, res)) + '\n', () => { try { socket.end(); } catch { /* 忽略 */ } });
};
socket.on('data', (chunk) => { // 行分帧:收到完整一行立即处理
buf += chunk;
bufferedBytes += Buffer.byteLength(chunk, 'utf8');
if (bufferedBytes > 2 * 1024 * 1024) {
logRejected('rejected oversized local request');
socket.destroy();
return;
}
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
bufferedBytes = Buffer.byteLength(buf, 'utf8');
if (line.trim()) dispatch(line.trim());
}
});
@@ -133,6 +176,11 @@ server.listen(PORT, '127.0.0.1', () => {
// 预热:开一条到审查端点的 TLS 连接,让第一次真实审查免握手
setTimeout(async () => {
try {
if (!all.config.apiKey || all.config.apiKey === 'PENDING' ||
!/^https?:\/\//i.test(String(all.config.baseUrl || '')) || !all.config.model) {
log('llm warmup skipped (endpoint not configured)');
return;
}
const t0 = Date.now();
const res = await fetch(all.config.baseUrl.replace(/\/+$/, '') + '/chat/completions', {
method: 'POST',
@@ -157,12 +205,16 @@ async function ping(port, timeoutMs) {
let buf = '';
s.setEncoding('utf8');
const to = setTimeout(() => { s.destroy(); reject(new Error('timeout')); }, timeoutMs);
s.on('connect', () => s.write(JSON.stringify({ op: 'ping' }) + '\n'));
const envelope = makeDaemonRequest(SECRET, { op: 'ping', instanceId: DAEMON_INSTANCE_ID });
s.on('connect', () => s.write(JSON.stringify(envelope) + '\n'));
s.on('data', (c) => {
buf += c;
if (buf.includes('\n')) {
clearTimeout(to);
try { resolve(JSON.parse(buf)); } catch { resolve(null); }
try {
const parsed = JSON.parse(buf.slice(0, buf.indexOf('\n')));
resolve(verifyDaemonResponse(SECRET, parsed, envelope.nonce) ? parsed.result : null);
} catch { resolve(null); }
s.end();
}
});