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

325 lines
10 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
/**
* 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');
const DISK_CACHE_FILE = require('path').join((function(){ try { return require('../scripts/paths.config.js').PATHS.root; } catch { return require('path').join(process.env.USERPROFILE || process.env.HOME, '.claude'); } })(), '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();
}