bookworm-smart-assistant/hooks/route-compliance-gate.js

330 lines
11 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* PreToolUse Hook: Skill 合规校验门控 (v5.2 Neural Gateway)
* Matcher: Skill
*
* 校验 Skill 调用是否在路由推荐集合内
* 不匹配 exit(2) 拦截 + 注入纠正提示
*
* stdin: { tool_name: "Skill", tool_input: { skill, args } }
* 退出码: 0=放行, 2=拦截(deny)
*
* Fail-close: 解析异常 exit(2) ask (安全组件不可因故障静默放行)
*/
const fs = require('fs');
const path = require('path');
const { safeAppendJsonl } = require('./lib/safe-append.js');
const readStdin = require('./lib/read-stdin.js');
const CLAUDE_ROOT = require('./lib/root.js');
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
const STATE_FILE = path.join(DEBUG_DIR, 'route-state-current.json');
const os = require('os');
// W6_DISK_CACHE_RESOLVE_v1: 拆解 resolve 路径,提升可读性
function _resolveClaudeRootForCache() {
try { return require('../scripts/paths.config.js').PATHS.root; }
catch { return require('path').join(process.env.USERPROFILE || process.env.HOME, '.claude'); }
}
const DISK_CACHE_FILE = require('path').join(_resolveClaudeRootForCache(), 'debug', '.disk-cache.json');
const DISK_CACHE_TTL = 5 * 60 * 1000; // 5 分钟缓存 TTL
/**
* 磁盘空间断路器: 可用空间 < 100MB 时返回 true
* 防止磁盘满导致状态文件写入失败 安全门控级联失效
* v5.9: 优先 PowerShell Get-CimInstancefallback wmic (兼容旧系统)
*/
function isDiskCritical() {
// 先检查磁盘检测缓存5 分钟有效)
try {
if (require('fs').existsSync(DISK_CACHE_FILE)) {
const cache = JSON.parse(require('fs').readFileSync(DISK_CACHE_FILE, 'utf8'));
if (Date.now() - cache.ts < DISK_CACHE_TTL) return cache.critical;
}
} catch {}
const THRESHOLD = 100 * 1024 * 1024; // 100MB
let result = false;
try {
const { spawnSync } = require('child_process');
const drive = CLAUDE_ROOT.charAt(0) + ':';
// XC2 修复: driveLetter 输入校验,防止命令注入
const driveLetter = drive.charAt(0);
if (!/^[A-Za-z]$/.test(driveLetter)) {
return false; // 非法驱动器字母,默认不阻断
}
// 优先 PowerShell Get-PSDrive (Windows 10/11 通用,无弃用风险)
let psSuccess = false;
try {
const psResult = spawnSync('powershell', ['-NoProfile', '-Command', `(Get-PSDrive ${driveLetter}).Free`], { encoding: 'utf8', timeout: 3000 });
const psOut = (psResult.stdout || '').trim();
const freeSpace = parseInt(psOut, 10);
if (!isNaN(freeSpace)) {
result = freeSpace < THRESHOLD;
psSuccess = true;
// XC2 修复: PowerShell 成功后立即写缓存并返回,不再执行 wmic
try {
require('fs').writeFileSync(DISK_CACHE_FILE, JSON.stringify({ ts: Date.now(), critical: result }));
} catch {}
return result;
}
} catch {}
// XC2 修复: Fallback wmic 仅在 PowerShell 失败时执行
if (!psSuccess) {
const wmicResult = spawnSync('wmic', ['logicaldisk', 'where', `DeviceID='${driveLetter}:'`, 'get', 'FreeSpace', '/value'], { encoding: 'utf8', timeout: 2000 });
const wmicOut = wmicResult.stdout || '';
const match = wmicOut.match(/FreeSpace=(\d+)/);
if (match) { result = parseInt(match[1], 10) < THRESHOLD; }
}
} catch {}
// 写入缓存(无论检测成功还是失败默认 false 都缓存)
try {
require('fs').writeFileSync(DISK_CACHE_FILE, JSON.stringify({ ts: Date.now(), critical: result }));
} catch {}
return result; // 检测失败时 result=false不触发断路器
}
/**
* 加载路由状态
* @returns {Object|null}
*/
function loadRouteState(currentSessionId) {
try {
if (!fs.existsSync(STATE_FILE)) return null;
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch {
return null;
}
}
/**
* 构建允许的技能集合
* @param {Object} state - route-state-current.json
* @param {string} prompt - 原始用户 prompt (用于检测显式调用)
* @returns {Set<string>}
*/
function buildAllowedSet(state, prompt) {
const allowed = new Set();
// 主路由
if (state.routing?.primary) {
allowed.add(state.routing.primary);
}
// 候选 (置信度 ≥ 0.3)
for (const c of (state.routing?.candidates || [])) {
if (c.confidence >= 0.3) {
allowed.add(c.name);
}
}
// 技能链中所有技能
for (const s of (state.routing?.chain || [])) {
allowed.add(s);
}
// 始终允许 developer-expert (通用回退)
allowed.add('developer-expert');
// 用户 prompt 中显式调用 /skill-name → 放行
if (prompt) {
const explicitMatch = prompt.match(/^\/([\w-]+)/);
if (explicitMatch) {
allowed.add(explicitMatch[1]);
}
}
return allowed;
}
/**
* 记录 compliance 事件
* @param {Object} entry
*/
function logCompliance(entry) {
try {
const dateStr = new Date().toISOString().slice(0, 10);
const logFile = path.join(DEBUG_DIR, `compliance-${dateStr}.jsonl`);
safeAppendJsonl(logFile, entry);
} catch (e) {
// v5.9: 记录日志写入失败到 stderr (可通过 hook-errors.log 追踪)
try { process.stderr.write(`[compliance-gate] log write failed: ${e.message}\n`); } catch {}
}
}
// === 主流程 ===
function main() {
readStdin({ maxSize: 128 * 1024 }).then(input => {
const skillName = input.tool_input?.skill;
if (!skillName) {
process.exit(0);
return;
}
// === Phase 0: 逃生舱覆盖检查 ===
try {
const userOverrides = require('../scripts/user-overrides.js');
// /force 检查: 放行 + 记录 + 清除 force
const forceState = userOverrides.isForceActive();
if (forceState.active) {
logCompliance({
ts: new Date().toISOString(),
traceId: 'override',
event: 'gate-force-bypass',
skill: skillName,
override: 'force',
forceSkill: forceState.skill || null,
});
userOverrides.clearForce(); // 单次生效
process.exit(0);
return;
}
// /checks off 检查: 直接放行
if (userOverrides.isChecksDisabled()) {
logCompliance({
ts: new Date().toISOString(),
traceId: 'override',
event: 'gate-checks-bypass',
skill: skillName,
override: 'checksOff',
});
process.exit(0);
return;
}
} catch (e) {
// v5.9: 记录 user-overrides 加载失败 (模块不存在是预期情况,其他错误需排查)
if (e && e.code !== 'MODULE_NOT_FOUND') {
try { process.stderr.write(`[compliance-gate] user-overrides error: ${e.message}\n`); } catch {}
}
}
// v5.9: 磁盘空间断路器 — 防止状态文件不可写导致安全门控级联失效
if (isDiskCritical()) {
logCompliance({
ts: new Date().toISOString(),
traceId: 'circuit-breaker',
event: 'gate-disk-critical',
skill: skillName,
});
process.stderr.write(JSON.stringify({
hookSpecificOutput: { permissionDecision: 'ask' },
systemMessage: '[合规门控] 磁盘空间不足(<100MB),安全门控无法正常工作,请用户确认操作。',
}));
process.exit(2);
return;
}
// 加载路由状态
const state = loadRouteState();
// 无 route-state → 放行 (可能是非用户消息触发的 Skill 调用)
if (!state) {
process.exit(0);
return;
}
// simple 复杂度 → 放行 (不需要路由)
if (state.intent?.complexity === 'simple') {
process.exit(0);
return;
}
// 构建允许集合 (传递 promptRaw 用于检测显式 /skill-name 调用)
const allowed = buildAllowedSet(state, state.promptRaw || '');
// 校验
if (allowed.has(skillName)) {
// 合规: 放行并记录
logCompliance({
ts: new Date().toISOString(),
traceId: state.traceId,
event: 'gate-pass',
skill: skillName,
primary: state.routing?.primary,
compliant: true,
});
// v5.9: 去耦合 — 不再回写 route-state-current.json (消除共享可变状态)
// actualSkill 记录到独立的 compliance 日志中,供 route-auditor 通过 traceId 关联
try {
// H1 修复: append-only jsonl 防 TOCTOU 污染 (原 actual-skill.json 单文件 lastWriter-wins)
const actualJsonl = path.join(DEBUG_DIR, 'actual-skills.jsonl');
const line = JSON.stringify({
traceId: state.traceId,
actualSkill: skillName,
ts: new Date().toISOString(),
}) + '\n';
fs.appendFileSync(actualJsonl, line);
// W2 (2026-04-16): 旧 actual-skill.json 默认不写,由 feature flag 恢复
// 回滚: set BOOKWORM_LEGACY_ACTUAL_SKILL=1
if (process.env.BOOKWORM_LEGACY_ACTUAL_SKILL === '1') {
const actualFile = path.join(DEBUG_DIR, 'actual-skill.json');
fs.writeFileSync(actualFile, line);
}
} catch {}
process.exit(0);
return;
}
// 不合规: 拦截
const primary = state.routing?.primary || 'developer-expert';
logCompliance({
ts: new Date().toISOString(),
traceId: state.traceId,
event: 'gate-block',
skill: skillName,
primary,
allowed: Array.from(allowed),
compliant: false,
});
// 输出 deny 决策
const output = {
hookSpecificOutput: {
permissionDecision: 'deny',
},
systemMessage: `[BWR 合规校验] ${skillName} 不在路由推荐集合中。推荐: ${primary}。请使用 /${primary} 或候选技能处理此请求。`,
};
process.stderr.write(JSON.stringify(output));
process.exit(2);
}).catch((e) => {
// Fail-close: 安全组件解析异常 → 请求用户确认而非静默放行
try {
logCompliance({
ts: new Date().toISOString(),
traceId: 'error',
event: 'gate-parse-error',
error: (e && e.message) || 'unknown',
});
} catch {}
process.stderr.write(JSON.stringify({
hookSpecificOutput: { permissionDecision: 'ask' },
systemMessage: `[合规门控] 校验遇到异常(${(e && e.message) || 'unknown'}),请用户确认是否继续。`,
}));
process.exit(2);
});
}
// 模块导出 (供测试)
if (typeof module !== 'undefined') {
module.exports = { detectClaudeRoot: require('./lib/root.js'), loadRouteState, buildAllowedSet, logCompliance };
}
if (require.main === module) {
main();
}