Files
zcode-gatekeeper/gatekeeper/control.mjs
T

394 lines
19 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
// ============================================================================
// gatekeeper 管理命令 —— install / uninstall / enable / disable / status / approve
// 由 gatekeeper.mjs 分发调用,例如:
// node gatekeeper.mjs install # 一键安装:复制文件到 ~/.zcode/gatekeeper 并注册 hooks
// node gatekeeper.mjs uninstall [--purge] # 一键卸载:反注册 hooks(--purge 连文件一起删)
// node gatekeeper.mjs enable / disable # 只切换 hooks.enabled 开关
// node gatekeeper.mjs status # 查看安装/运行状态
// node gatekeeper.mjs approve "<命令>" # 一次性放行(命令+cwd 绑定、用后即焚、10 分钟 TTL)
// ============================================================================
import net from 'node:net';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, cpSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
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', '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 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() {
return existsSync(path.join(TARGET_DIR, 'gatekeeper.mjs')) ? TARGET_DIR : SCRIPT_DIR;
}
function isOurHook(h) {
return h && typeof h.command === 'string' && /node/i.test(path.basename(h.command))
&& Array.isArray(h.args) && h.args[0] && MARKER.test(h.args[0]);
}
function mkEntries(script) {
const mk = (args, timeoutMs) => ({ type: 'process', command: process.execPath, args: [script, ...args], timeoutMs });
return {
UserPromptSubmit: [{ hooks: [mk(['capture-prompt'], 5000)] }],
PostToolUse: [{ hooks: [mk(['capture-tool'], 5000)] }],
PreToolUse: [{ hooks: [mk(['review', '--layer=hard'], 50000)] }],
PermissionRequest: [{ hooks: [mk(['review', '--layer=full'], 45000)] }],
};
}
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);
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 = samePath(SCRIPT_DIR, TARGET_DIR);
if (sameDir) out('提示:当前就在安装目录内运行,文件视为最新,仅重新注册 hooks。');
mkdirSync(TARGET_DIR, { recursive: true });
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)) {
if (f !== 'config.json') failures.push(f + '(源文件缺失)');
continue;
}
const dest = path.join(TARGET_DIR, f);
// 升级安装时保留用户已调好的配置,只在首次安装时带入;从安装目录运行时跳过自身复制
if (f === 'config.json' && existsSync(dest)) continue;
if (samePath(src, dest)) continue;
const stage = path.join(TARGET_DIR, tx + '-' + f);
const backupFile = stage + '.bak';
try {
cpSync(src, stage);
const hadDest = existsSync(dest);
if (hadDest) cpSync(dest, backupFile);
staged.push({ f, dest, stage, backup: backupFile, hadDest });
} catch (e) {
try { rmSync(stage, { force: true }); } catch { /* 忽略 */ }
try { rmSync(backupFile, { force: true }); } catch { /* 忽略 */ }
out(`⚠ 暂存失败(磁盘/权限问题?处理后重跑 install):${f} — ${e?.message || e}`);
failures.push(f);
}
}
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'))) {
cpSync(path.join(SCRIPT_DIR, 'config.example.json'), path.join(TARGET_DIR, 'config.json'));
out('✓ 已从模板生成 config.json —— 请编辑填入你的审查端点(baseUrl/apiKey/model)');
}
if (!existsSync(USER_CONFIG)) {
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);
if (!bak) { out('✗ 无法备份用户配置,已停止安装且不会覆盖'); return 1; }
const hooks = cfg.hooks || { enabled: true };
hooks.enabled = true;
const events = hooks.events = hooks.events || {};
const entries = mkEntries(path.join(TARGET_DIR, 'gatekeeper.mjs'));
for (const ev of HOOK_EVENTS) {
// 先清掉本工具旧条目(支持从旧路径升级),再追加
events[ev] = (events[ev] || [])
.map(g => ({ ...g, hooks: (g.hooks || []).filter(h => !isOurHook(h)) }))
.filter(g => (g.hooks || []).length > 0);
events[ev] = [...events[ev], ...entries[ev]];
}
cfg.hooks = hooks;
saveJson(USER_CONFIG, cfg);
out(`✓ hooks 已注册(备份:${bak})`);
const targetCfg = loadJson(path.join(TARGET_DIR, 'config.json'), {});
if (!targetCfg.apiKey || targetCfg.apiKey === 'PENDING') {
out('⚠ 尚未配置审查端点:请编辑 ' + path.join(TARGET_DIR, 'config.json') + ' 填入 baseUrl / apiKey / model');
}
if (!samePath(SCRIPT_DIR, TARGET_DIR)) {
try { spawnSelfShutdown(); } catch { /* 忽略 */ }
}
out('');
out('安装完成。使用前两件事:');
out(' 1. 重启 ZCode 会话(hooks 在会话启动时加载)');
out(' 2. 确认 ' + path.join(TARGET_DIR, 'config.json') + ' 里的审查端点可用');
out('卸载:node ' + path.join(TARGET_DIR, 'gatekeeper.mjs') + ' uninstall --purge');
return 0;
}
function spawnSelfShutdown() {
// 从旧位置拉起的 daemon 需要停掉,避免双实例占端口(新实例会自动拉起)
try { spawnSync(process.execPath, [path.join(SCRIPT_DIR, 'gatekeeper.mjs'), 'shutdown-daemon'], { stdio: 'ignore', timeout: 8000 }); } catch { /* 忽略 */ }
}
// ---------------------------------------------------------------- 卸载 ------
async function cmdUninstall(purge) {
// 停 daemon(新旧位置都试一次)
for (const dir of [TARGET_DIR, SCRIPT_DIR]) {
const script = path.join(dir, 'gatekeeper.mjs');
if (existsSync(script)) {
try { spawnSync(process.execPath, [script, 'shutdown-daemon'], { stdio: 'ignore', timeout: 8000 }); } catch { /* 忽略 */ }
}
}
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);
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] || [])
.map(g => ({ ...g, hooks: (g.hooks || []).filter(h => !isOurHook(h)) }))
.filter(g => (g.hooks || []).length > 0);
if (!cfg.hooks.events[ev].length) delete cfg.hooks.events[ev];
}
if (!Object.keys(cfg.hooks.events).length) delete cfg.hooks.events;
if (!Object.keys(cfg.hooks).filter(k => k !== 'enabled').length) delete cfg.hooks;
saveJson(USER_CONFIG, cfg);
out(`✓ hooks 已反注册(备份:${bak})`);
} else {
out('未发现本工具的 hooks,无需反注册');
}
if (purge) {
if (existsSync(TARGET_DIR) && !samePath(TARGET_DIR, SCRIPT_DIR)) {
rmSync(TARGET_DIR, { recursive: true, force: true });
out('✓ 已删除安装目录 ' + TARGET_DIR);
} else {
out('跳过删除:安装目录与当前脚本目录相同');
}
} else {
out('文件保留在 ' + (existsSync(TARGET_DIR) ? TARGET_DIR : SCRIPT_DIR) + '(加 --purge 可一并删除)');
}
out('重启 ZCode 会话后彻底生效。');
return 0;
}
// ---------------------------------------------------------------- 开关 ------
async function cmdSetEnabled(on) {
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 会话后生效。`);
return 0;
}
// ---------------------------------------------------------------- 状态 ------
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 + ')'));
out('hooks 注册 : ' + (cfg.hooks?.events
? HOOK_EVENTS.map(ev => ev + (cfg.hooks.events[ev] ? ' ✓' : ' ✗')).join(' ') + ' enabled=' + cfg.hooks.enabled
: '未注册'));
out('运行目录 : ' + rd);
out('── 审查端点 ──────────────────────────');
out('endpoint : ' + (rcfg.baseUrl || '(未配置)'));
out('model : ' + (rcfg.model || '(未配置)'));
out('apiKey : ' + (rcfg.apiKey && rcfg.apiKey !== 'PENDING' ? '(已配置,内容不显示)' : '(未配置)'));
out('fullAccessGuard: ' + (rcfg.fullAccessGuard === true ? '开(完全访问守卫)' : '关(确认模式)'));
out('── 运行时 ────────────────────────────');
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(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(stateDir, 'audit.jsonl'), 'utf8').trim().split('\n').slice(-3);
out('最近审计 :');
for (const l of lines) out(' ' + l.slice(0, 150));
} catch { out('最近审计 : (无)'); }
return 0;
}
// ---------------------------------------------------------------- 批准 ------
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 = resolveStateDir(runtimeDir(), process.env.GATEKEEPER_STATE_DIR || '');
let deniedList = null;
let deniedEntry = null;
if (isLast) {
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; }
if (!process.stdin.isTTY && !yes) {
out('✗ 非交互环境拒绝批准(防止模型自行给自己放行)。');
out(' 如用户已明确授权,请由用户在终端亲自执行本命令,或追加 --yes。');
return 1;
}
if (!yes && process.stdin.isTTY) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ans = await new Promise(res => rl.question('确认一次性放行该命令?[y/N] ', res));
rl.close();
if (!/^y(es)?$/i.test((ans || '').trim())) { out('已取消'); return 1; }
}
process.env.GATEKEEPER_STATE_DIR = stateDir;
const { approvedAdd } = await import('./core.mjs');
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;
}
// ---------------------------------------------------------------- 入口 ------
export async function runControl(argv) {
const cmd = argv[0];
switch (cmd) {
case 'install': return cmdInstall();
case 'uninstall': return cmdUninstall(argv.includes('--purge'));
case 'enable': return cmdSetEnabled(true);
case 'disable': return cmdSetEnabled(false);
case 'status': return cmdStatus();
case 'approve': return cmdApprove(argv.slice(1));
case 'shutdown-daemon': {
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:
out('未知管理命令: ' + cmd);
out('可用: install | uninstall [--purge] | enable | disable | status | approve "<命令>" | shutdown-daemon');
return 1;
}
}