325 lines
10 KiB
JavaScript
325 lines
10 KiB
JavaScript
|
|
#!/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-CimInstance,fallback 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();
|
|||
|
|
}
|