fix: harden gatekeeper security boundaries
This commit is contained in:
+153
-46
@@ -6,7 +6,7 @@
|
||||
// node gatekeeper.mjs uninstall [--purge] # 一键卸载:反注册 hooks(--purge 连文件一起删)
|
||||
// node gatekeeper.mjs enable / disable # 只切换 hooks.enabled 开关
|
||||
// node gatekeeper.mjs status # 查看安装/运行状态
|
||||
// node gatekeeper.mjs approve "<命令>" # 一次性放行(精确匹配、用后即焚、10 分钟 TTL)
|
||||
// node gatekeeper.mjs approve "<命令>" # 一次性放行(命令+cwd 绑定、用后即焚、10 分钟 TTL)
|
||||
// ============================================================================
|
||||
|
||||
import net from 'node:net';
|
||||
@@ -16,20 +16,30 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { makeDaemonRequest, verifyDaemonResponse, daemonInstanceIdFor, resolveStateDir } from './daemon-auth.mjs';
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const HOME_ZCODE = path.join(os.homedir(), '.zcode');
|
||||
const TARGET_DIR = path.join(HOME_ZCODE, 'gatekeeper');
|
||||
const USER_CONFIG = path.join(HOME_ZCODE, 'cli', 'config.json');
|
||||
const HOOK_EVENTS = ['UserPromptSubmit', 'PostToolUse', 'PreToolUse', 'PermissionRequest'];
|
||||
const INSTALL_FILES = ['gatekeeper.mjs', 'gatekeeper-daemon.mjs', 'core.mjs', 'control.mjs', 'policy.md', 'allowlist.txt', 'dangerlist.txt', 'config.json', 'config.example.json'];
|
||||
const INSTALL_FILES = ['gatekeeper.mjs', 'gatekeeper-daemon.mjs', 'daemon-auth.mjs', 'core.mjs', 'control.mjs', 'policy.md', 'allowlist.txt', 'dangerlist.txt', 'config.json', 'config.example.json'];
|
||||
// 判定某 hook 条目是否属于本工具(安装/卸载只动自己的条目,不碰用户其他 hooks)
|
||||
const MARKER = /[\\/]gatekeeper[\\/]gatekeeper\.mjs$/i;
|
||||
|
||||
const out = (m) => console.log(m);
|
||||
const loadJson = (f, fb) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return fb; } };
|
||||
const saveJson = (f, o) => { mkdirSync(path.dirname(f), { recursive: true }); writeFileSync(f, JSON.stringify(o, null, 2) + '\n'); };
|
||||
const backup = (f) => { try { const b = f + '.bak-gk-' + new Date().toISOString().slice(0, 19).replace(/[:T]/g, ''); writeFileSync(b, readFileSync(f)); return b; } catch { return null; } };
|
||||
const loadJsonObjectStrict = (f) => {
|
||||
const v = JSON.parse(readFileSync(f, 'utf8'));
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('顶层必须是 JSON 对象');
|
||||
return v;
|
||||
};
|
||||
const saveJson = (f, o) => { mkdirSync(path.dirname(f), { recursive: true }); writeFileSync(f, JSON.stringify(o, null, 2) + '\n', { mode: 0o600 }); };
|
||||
const backup = (f) => { try { const b = f + '.bak-gk-' + Date.now(); writeFileSync(b, readFileSync(f), { flag: 'wx', mode: 0o600 }); return b; } catch { return null; } };
|
||||
const samePath = (a, b) => {
|
||||
const pa = path.resolve(a), pb = path.resolve(b);
|
||||
return process.platform === 'win32' ? pa.toLowerCase() === pb.toLowerCase() : pa === pb;
|
||||
};
|
||||
|
||||
// hooks 实际运行位置:已安装优先,否则当前脚本目录
|
||||
function runtimeDir() {
|
||||
@@ -51,39 +61,108 @@ function mkEntries(script) {
|
||||
};
|
||||
}
|
||||
|
||||
function pingDaemon(port, timeoutMs = 400) {
|
||||
function daemonIdentity(dir) {
|
||||
try {
|
||||
const stateDir = resolveStateDir(dir, process.env.GATEKEEPER_STATE_DIR || '');
|
||||
const secret = readFileSync(path.join(stateDir, 'daemon-secret'), 'utf8').trim();
|
||||
if (!/^[a-f0-9]{64}$/i.test(secret)) return null;
|
||||
return { secret, instanceId: daemonInstanceIdFor(dir) };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function daemonRequest(port, dir, payload, timeoutMs = 400) {
|
||||
return new Promise((resolve) => {
|
||||
const identity = daemonIdentity(dir);
|
||||
if (!identity) { resolve(null); return; }
|
||||
const s = net.connect(port, '127.0.0.1');
|
||||
let buf = '';
|
||||
s.setEncoding('utf8');
|
||||
const to = setTimeout(() => { s.destroy(); resolve(null); }, timeoutMs);
|
||||
s.on('connect', () => s.write(JSON.stringify({ op: 'ping' }) + '\n'));
|
||||
s.on('data', d => { buf += d; if (buf.includes('\n')) { clearTimeout(to); s.destroy(); try { resolve(JSON.parse(buf)); } catch { resolve(null); } } });
|
||||
const envelope = makeDaemonRequest(identity.secret, { ...payload, instanceId: identity.instanceId });
|
||||
s.on('connect', () => s.write(JSON.stringify(envelope) + '\n'));
|
||||
s.on('data', d => {
|
||||
buf += d;
|
||||
if (buf.includes('\n')) {
|
||||
clearTimeout(to);
|
||||
s.destroy();
|
||||
try {
|
||||
const parsed = JSON.parse(buf.slice(0, buf.indexOf('\n')));
|
||||
resolve(verifyDaemonResponse(identity.secret, parsed, envelope.nonce) ? parsed.result : null);
|
||||
} catch { resolve(null); }
|
||||
}
|
||||
});
|
||||
s.on('error', () => { clearTimeout(to); resolve(null); });
|
||||
});
|
||||
}
|
||||
const pingDaemon = (port, dir, timeoutMs = 400) => daemonRequest(port, dir, { op: 'ping' }, timeoutMs);
|
||||
|
||||
// ---------------------------------------------------------------- 安装 ------
|
||||
async function cmdInstall() {
|
||||
const sameDir = SCRIPT_DIR.toLowerCase() === TARGET_DIR.toLowerCase();
|
||||
const sameDir = samePath(SCRIPT_DIR, TARGET_DIR);
|
||||
if (sameDir) out('提示:当前就在安装目录内运行,文件视为最新,仅重新注册 hooks。');
|
||||
mkdirSync(TARGET_DIR, { recursive: true });
|
||||
let copied = 0;
|
||||
const tx = '.gk-install-' + process.pid + '-' + Date.now();
|
||||
const staged = [];
|
||||
const failures = [];
|
||||
const cleanupStaged = () => {
|
||||
for (const e of staged) {
|
||||
try { rmSync(e.stage, { force: true }); } catch { /* 忽略 */ }
|
||||
try { rmSync(e.backup, { force: true }); } catch { /* 忽略 */ }
|
||||
}
|
||||
};
|
||||
for (const f of INSTALL_FILES) {
|
||||
const src = path.join(SCRIPT_DIR, f);
|
||||
if (!existsSync(src)) continue;
|
||||
if (!existsSync(src)) {
|
||||
if (f !== 'config.json') failures.push(f + '(源文件缺失)');
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(TARGET_DIR, f);
|
||||
// 升级安装时保留用户已调好的配置,只在首次安装时带入;从安装目录运行时跳过自身复制
|
||||
if (f === 'config.json' && existsSync(dest)) continue;
|
||||
if (path.resolve(src).toLowerCase() === path.resolve(dest).toLowerCase()) continue;
|
||||
if (samePath(src, dest)) continue;
|
||||
const stage = path.join(TARGET_DIR, tx + '-' + f);
|
||||
const backupFile = stage + '.bak';
|
||||
try {
|
||||
cpSync(src, dest);
|
||||
copied++;
|
||||
cpSync(src, stage);
|
||||
const hadDest = existsSync(dest);
|
||||
if (hadDest) cpSync(dest, backupFile);
|
||||
staged.push({ f, dest, stage, backup: backupFile, hadDest });
|
||||
} catch (e) {
|
||||
out(`⚠ 复制失败(文件被占用?关掉相关程序后重跑 install):${f} — ${e?.message || e}`);
|
||||
try { rmSync(stage, { force: true }); } catch { /* 忽略 */ }
|
||||
try { rmSync(backupFile, { force: true }); } catch { /* 忽略 */ }
|
||||
out(`⚠ 暂存失败(磁盘/权限问题?处理后重跑 install):${f} — ${e?.message || e}`);
|
||||
failures.push(f);
|
||||
}
|
||||
}
|
||||
out(`✓ 已复制 ${copied} 个文件 → ${TARGET_DIR}`);
|
||||
if (failures.length) {
|
||||
cleanupStaged();
|
||||
out('✗ 安装未完成,未注册 hooks。失败文件:' + failures.join(', '));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 所有源文件均已成功暂存后才停止旧 daemon 并提交,失败时尽力回滚已替换文件。
|
||||
if (!sameDir && existsSync(path.join(TARGET_DIR, 'gatekeeper.mjs'))) {
|
||||
try { spawnSync(process.execPath, [path.join(TARGET_DIR, 'gatekeeper.mjs'), 'shutdown-daemon'], { stdio: 'ignore', timeout: 8000 }); } catch { /* 提交仍可继续 */ }
|
||||
}
|
||||
const committed = [];
|
||||
try {
|
||||
for (const e of staged) {
|
||||
committed.push(e);
|
||||
cpSync(e.stage, e.dest);
|
||||
}
|
||||
} catch (e) {
|
||||
for (const done of committed.reverse()) {
|
||||
try {
|
||||
if (done.hadDest) cpSync(done.backup, done.dest);
|
||||
else rmSync(done.dest, { force: true });
|
||||
} catch { /* 回滚失败会在下方明确告警 */ }
|
||||
}
|
||||
cleanupStaged();
|
||||
out('✗ 安装提交失败,已尝试回滚,未注册 hooks:' + (e?.message || e));
|
||||
return 1;
|
||||
}
|
||||
cleanupStaged();
|
||||
out(`✓ 已复制 ${staged.length} 个文件 → ${TARGET_DIR}`);
|
||||
|
||||
// 首次安装没有 config.json(仓库不含真实密钥)→ 从模板生成
|
||||
if (!existsSync(path.join(TARGET_DIR, 'config.json'))) {
|
||||
@@ -95,8 +174,11 @@ async function cmdInstall() {
|
||||
out(`✗ 未找到用户配置 ${USER_CONFIG} —— 请确认 ZCode 已安装过并至少启动过一次`);
|
||||
return 1;
|
||||
}
|
||||
let cfg;
|
||||
try { cfg = loadJsonObjectStrict(USER_CONFIG); }
|
||||
catch (e) { out('✗ 用户配置不是有效 JSON,已停止安装且不会覆盖:' + (e?.message || e)); return 1; }
|
||||
const bak = backup(USER_CONFIG);
|
||||
const cfg = loadJson(USER_CONFIG, {});
|
||||
if (!bak) { out('✗ 无法备份用户配置,已停止安装且不会覆盖'); return 1; }
|
||||
const hooks = cfg.hooks || { enabled: true };
|
||||
hooks.enabled = true;
|
||||
const events = hooks.events = hooks.events || {};
|
||||
@@ -116,7 +198,7 @@ async function cmdInstall() {
|
||||
if (!targetCfg.apiKey || targetCfg.apiKey === 'PENDING') {
|
||||
out('⚠ 尚未配置审查端点:请编辑 ' + path.join(TARGET_DIR, 'config.json') + ' 填入 baseUrl / apiKey / model');
|
||||
}
|
||||
if (SCRIPT_DIR.toLowerCase() !== TARGET_DIR.toLowerCase()) {
|
||||
if (!samePath(SCRIPT_DIR, TARGET_DIR)) {
|
||||
try { spawnSelfShutdown(); } catch { /* 忽略 */ }
|
||||
}
|
||||
out('');
|
||||
@@ -142,8 +224,11 @@ async function cmdUninstall(purge) {
|
||||
}
|
||||
}
|
||||
if (!existsSync(USER_CONFIG)) { out('✗ 未找到用户配置 ' + USER_CONFIG); return 1; }
|
||||
let cfg;
|
||||
try { cfg = loadJsonObjectStrict(USER_CONFIG); }
|
||||
catch (e) { out('✗ 用户配置不是有效 JSON,已停止卸载且不会覆盖:' + (e?.message || e)); return 1; }
|
||||
const bak = backup(USER_CONFIG);
|
||||
const cfg = loadJson(USER_CONFIG, {});
|
||||
if (!bak) { out('✗ 无法备份用户配置,已停止卸载且不会覆盖'); return 1; }
|
||||
if (cfg.hooks?.events) {
|
||||
for (const ev of Object.keys(cfg.hooks.events)) {
|
||||
cfg.hooks.events[ev] = (cfg.hooks.events[ev] || [])
|
||||
@@ -159,7 +244,7 @@ async function cmdUninstall(purge) {
|
||||
out('未发现本工具的 hooks,无需反注册');
|
||||
}
|
||||
if (purge) {
|
||||
if (existsSync(TARGET_DIR) && TARGET_DIR.toLowerCase() !== SCRIPT_DIR.toLowerCase()) {
|
||||
if (existsSync(TARGET_DIR) && !samePath(TARGET_DIR, SCRIPT_DIR)) {
|
||||
rmSync(TARGET_DIR, { recursive: true, force: true });
|
||||
out('✓ 已删除安装目录 ' + TARGET_DIR);
|
||||
} else {
|
||||
@@ -174,12 +259,15 @@ async function cmdUninstall(purge) {
|
||||
|
||||
// ---------------------------------------------------------------- 开关 ------
|
||||
async function cmdSetEnabled(on) {
|
||||
const cfg = loadJson(USER_CONFIG, {});
|
||||
let cfg;
|
||||
try { cfg = loadJsonObjectStrict(USER_CONFIG); }
|
||||
catch (e) { out('✗ 用户配置不是有效 JSON,未做修改:' + (e?.message || e)); return 1; }
|
||||
if (!cfg.hooks?.events) {
|
||||
out(on ? '✗ 尚未安装(先运行 install)' : '✗ 尚未安装,无需禁用');
|
||||
return 1;
|
||||
}
|
||||
const bak = backup(USER_CONFIG);
|
||||
if (!bak) { out('✗ 无法备份用户配置,未做修改'); return 1; }
|
||||
cfg.hooks.enabled = on;
|
||||
saveJson(USER_CONFIG, cfg);
|
||||
out(`✓ hooks.enabled = ${on}${bak ? '(备份:' + bak + ')' : ''}。重启 ZCode 会话后生效。`);
|
||||
@@ -190,6 +278,7 @@ async function cmdSetEnabled(on) {
|
||||
async function cmdStatus() {
|
||||
const cfg = loadJson(USER_CONFIG, {});
|
||||
const rd = runtimeDir();
|
||||
const stateDir = resolveStateDir(rd, process.env.GATEKEEPER_STATE_DIR || '');
|
||||
const rcfg = loadJson(path.join(rd, 'config.json'), {});
|
||||
out('── 安装状态 ──────────────────────────');
|
||||
out('安装目录 : ' + (existsSync(path.join(TARGET_DIR, 'gatekeeper.mjs')) ? TARGET_DIR : '(未安装,运行目录 ' + SCRIPT_DIR + ')'));
|
||||
@@ -200,19 +289,22 @@ async function cmdStatus() {
|
||||
out('── 审查端点 ──────────────────────────');
|
||||
out('endpoint : ' + (rcfg.baseUrl || '(未配置)'));
|
||||
out('model : ' + (rcfg.model || '(未配置)'));
|
||||
out('apiKey : ' + (rcfg.apiKey ? String(rcfg.apiKey).slice(0, 6) + '…(已配置)' : '(未配置)'));
|
||||
out('apiKey : ' + (rcfg.apiKey && rcfg.apiKey !== 'PENDING' ? '(已配置,内容不显示)' : '(未配置)'));
|
||||
out('fullAccessGuard: ' + (rcfg.fullAccessGuard === true ? '开(完全访问守卫)' : '关(确认模式)'));
|
||||
out('── 运行时 ────────────────────────────');
|
||||
const pong = rcfg.daemon === false ? null : await pingDaemon(rcfg.daemonPort | 0 || 47811);
|
||||
const daemonPort = parseInt(process.env.GATEKEEPER_DAEMON_PORT || rcfg.daemonPort, 10) || 47811;
|
||||
const pong = rcfg.daemon === false ? null : await pingDaemon(daemonPort, rd);
|
||||
out('daemon : ' + (pong?.ok ? '运行中 (llmWarm=' + pong.llmWarm + ')' : '未运行(调用时自动拉起)'));
|
||||
let approved = 0;
|
||||
try {
|
||||
const list = JSON.parse(readFileSync(path.join(rd, 'state', 'approved.json'), 'utf8'));
|
||||
approved = list.filter(e => Date.now() - (e.ts || 0) <= 10 * 60 * 1000).length;
|
||||
const list = JSON.parse(readFileSync(path.join(stateDir, 'approved.json'), 'utf8'));
|
||||
approved = list.filter(e => e?.version === 2 && e.id && typeof e.cmd === 'string' && path.isAbsolute(e.cwd || '') && (e.ts || 0) <= Date.now() &&
|
||||
Date.now() - (e.ts || 0) <= 10 * 60 * 1000 &&
|
||||
!existsSync(path.join(stateDir, 'approval-claim-' + e.id))).length;
|
||||
} catch { /* 无文件 */ }
|
||||
out('待用批准 : ' + approved + ' 条(10 分钟 TTL)');
|
||||
try {
|
||||
const lines = readFileSync(path.join(rd, 'state', 'audit.jsonl'), 'utf8').trim().split('\n').slice(-3);
|
||||
const lines = readFileSync(path.join(stateDir, 'audit.jsonl'), 'utf8').trim().split('\n').slice(-3);
|
||||
out('最近审计 :');
|
||||
for (const l of lines) out(' ' + l.slice(0, 150));
|
||||
} catch { out('最近审计 : (无)'); }
|
||||
@@ -224,14 +316,18 @@ async function cmdApprove(args) {
|
||||
const yes = args.includes('--yes');
|
||||
const isLast = args.includes('--last');
|
||||
let cmd = args.filter(a => a !== '--yes' && a !== '--last').join(' ').trim();
|
||||
const stateDir = process.env.GATEKEEPER_STATE_DIR || path.join(runtimeDir(), 'state');
|
||||
const stateDir = resolveStateDir(runtimeDir(), process.env.GATEKEEPER_STATE_DIR || '');
|
||||
let deniedList = null;
|
||||
let deniedEntry = null;
|
||||
if (isLast) {
|
||||
// 批准最近一条被拒命令(从队列弹出,避免重复批准陈旧条目)
|
||||
const list = loadJson(path.join(stateDir, 'denied.json'), []);
|
||||
if (!Array.isArray(list) || !list.length) { out('没有可批准的最近被拒命令'); return 1; }
|
||||
cmd = String(list[list.length - 1].cmd || '');
|
||||
list.pop();
|
||||
saveJson(path.join(stateDir, 'denied.json'), list);
|
||||
deniedList = loadJson(path.join(stateDir, 'denied.json'), []);
|
||||
if (!Array.isArray(deniedList) || !deniedList.length) { out('没有可批准的最近被拒命令'); return 1; }
|
||||
deniedEntry = deniedList[deniedList.length - 1];
|
||||
if (deniedEntry?.version !== 2 || !deniedEntry.cwd || !deniedEntry.actionHash) {
|
||||
out('最近拒绝记录来自旧版本,缺少安全上下文;请让模型重试一次后再运行 approve --last。');
|
||||
return 1;
|
||||
}
|
||||
cmd = String(deniedEntry?.cmd || '');
|
||||
if (!cmd) { out('最近被拒记录为空,无法批准'); return 1; }
|
||||
}
|
||||
if (!cmd) { out('用法: node gatekeeper.mjs approve "<完整命令>" [--last] [--yes]'); return 1; }
|
||||
@@ -246,10 +342,20 @@ async function cmdApprove(args) {
|
||||
rl.close();
|
||||
if (!/^y(es)?$/i.test((ans || '').trim())) { out('已取消'); return 1; }
|
||||
}
|
||||
process.env.GATEKEEPER_STATE_DIR = process.env.GATEKEEPER_STATE_DIR || path.join(runtimeDir(), 'state');
|
||||
process.env.GATEKEEPER_STATE_DIR = stateDir;
|
||||
const { approvedAdd } = await import('./core.mjs');
|
||||
approvedAdd(cmd);
|
||||
out('✓ 已一次性批准(10 分钟内有效、精确匹配原命令、用后即焚):' + cmd.slice(0, 120));
|
||||
const scope = deniedEntry ? {
|
||||
cwd: deniedEntry.cwd,
|
||||
tool: deniedEntry.tool,
|
||||
session: deniedEntry.session,
|
||||
actionHash: deniedEntry.actionHash,
|
||||
} : { cwd: process.cwd() };
|
||||
approvedAdd(cmd, scope);
|
||||
if (deniedList) {
|
||||
deniedList.pop();
|
||||
saveJson(path.join(stateDir, 'denied.json'), deniedList);
|
||||
}
|
||||
out('✓ 已一次性批准(10 分钟内有效、绑定执行目录、用后即焚):' + cmd.slice(0, 120));
|
||||
out(' 现在让模型重试该命令即可。');
|
||||
return 0;
|
||||
}
|
||||
@@ -265,17 +371,18 @@ export async function runControl(argv) {
|
||||
case 'status': return cmdStatus();
|
||||
case 'approve': return cmdApprove(argv.slice(1));
|
||||
case 'shutdown-daemon': {
|
||||
const rd = runtimeDir();
|
||||
const port = loadJson(path.join(rd, 'config.json'), {}).daemonPort | 0 || 47811;
|
||||
const pong = await pingDaemon(port, 600);
|
||||
if (!pong?.ok) { out('daemon 未在运行'); return 0; }
|
||||
await new Promise(res => {
|
||||
const s = net.connect(port, '127.0.0.1');
|
||||
s.on('connect', () => s.write(JSON.stringify({ op: 'shutdown' }) + '\n'));
|
||||
s.on('data', () => { out('✓ daemon 已停止'); res(); });
|
||||
s.on('error', () => { out('daemon 连接失败'); res(); });
|
||||
setTimeout(res, 2000);
|
||||
});
|
||||
let found = false;
|
||||
const dirs = [runtimeDir(), SCRIPT_DIR].map(d => path.resolve(d)).filter((d, i, all) =>
|
||||
all.findIndex(x => process.platform === 'win32' ? x.toLowerCase() === d.toLowerCase() : x === d) === i);
|
||||
for (const rd of dirs) {
|
||||
const port = parseInt(process.env.GATEKEEPER_DAEMON_PORT || loadJson(path.join(rd, 'config.json'), {}).daemonPort, 10) || 47811;
|
||||
const pong = await pingDaemon(port, rd, 600);
|
||||
if (!pong?.ok) continue;
|
||||
found = true;
|
||||
const stopped = await daemonRequest(port, rd, { op: 'shutdown' }, 2000);
|
||||
out(stopped?.ok ? '✓ daemon 已停止' : 'daemon 连接失败');
|
||||
}
|
||||
if (!found) out('daemon 未在运行');
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user