279 lines
8.4 KiB
JavaScript
279 lines
8.4 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* 漂移防护守卫 (v5.9)
|
||
|
|
*
|
||
|
|
* 三层防漂移机制:
|
||
|
|
* L1: 完整性校验 — checksums.json + HMAC (已有 integrity-check.js)
|
||
|
|
* L2: 配置快照 — 关键配置文件的版本指纹 (本模块)
|
||
|
|
* L3: 权重漂移检测 — 融合权重与关键词权重的偏移量监控
|
||
|
|
*
|
||
|
|
* 用法:
|
||
|
|
* node drift-guard.js --snapshot # 生成当前配置快照
|
||
|
|
* node drift-guard.js --check # 与上次快照对比
|
||
|
|
* node drift-guard.js --weights # 检测权重漂移
|
||
|
|
* node drift-guard.js --full # 全面检查
|
||
|
|
* node drift-guard.js --json # JSON 输出
|
||
|
|
*
|
||
|
|
* 文件:
|
||
|
|
* debug/config-snapshot.json 配置快照
|
||
|
|
*/
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const crypto = require('crypto');
|
||
|
|
|
||
|
|
const detectClaudeRoot = () => require('./paths.config.js').PATHS.root;
|
||
|
|
|
||
|
|
const ROOT = detectClaudeRoot();
|
||
|
|
const DEBUG_DIR = path.join(ROOT, 'debug');
|
||
|
|
const SNAPSHOT_FILE = path.join(DEBUG_DIR, 'config-snapshot.json');
|
||
|
|
|
||
|
|
// 需要监控的配置文件
|
||
|
|
const MONITORED_CONFIGS = [
|
||
|
|
'CLAUDE.md',
|
||
|
|
'SKILL-REGISTRY.md',
|
||
|
|
'skills-index.json',
|
||
|
|
'hooks/checksums.json',
|
||
|
|
'scripts/disambiguation-rules.json',
|
||
|
|
'scripts/synonyms.json',
|
||
|
|
];
|
||
|
|
|
||
|
|
// 需要监控的运行时状态文件
|
||
|
|
const MONITORED_STATE = [
|
||
|
|
'debug/fusion-weights.json',
|
||
|
|
'debug/route-weights.json',
|
||
|
|
];
|
||
|
|
|
||
|
|
function sha256(filePath) {
|
||
|
|
try {
|
||
|
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||
|
|
} catch { return null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 生成当前配置快照
|
||
|
|
*/
|
||
|
|
function generateSnapshot() {
|
||
|
|
const snapshot = {
|
||
|
|
generated: new Date().toISOString(),
|
||
|
|
version: '5.9',
|
||
|
|
configs: {},
|
||
|
|
state: {},
|
||
|
|
counts: {},
|
||
|
|
};
|
||
|
|
|
||
|
|
// 配置文件哈希
|
||
|
|
for (const file of MONITORED_CONFIGS) {
|
||
|
|
const fp = path.join(ROOT, file);
|
||
|
|
snapshot.configs[file] = sha256(fp);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 状态文件哈希
|
||
|
|
for (const file of MONITORED_STATE) {
|
||
|
|
const fp = path.join(ROOT, file);
|
||
|
|
snapshot.state[file] = sha256(fp);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 关键计数
|
||
|
|
try {
|
||
|
|
const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills-index.json'), 'utf8'));
|
||
|
|
snapshot.counts.skills = (index.skills || []).length;
|
||
|
|
} catch { snapshot.counts.skills = 0; }
|
||
|
|
|
||
|
|
try {
|
||
|
|
const rules = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'disambiguation-rules.json'), 'utf8'));
|
||
|
|
snapshot.counts.disambiguationRules = (rules.rules || []).length;
|
||
|
|
} catch { snapshot.counts.disambiguationRules = 0; }
|
||
|
|
|
||
|
|
try {
|
||
|
|
const synonyms = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'synonyms.json'), 'utf8'));
|
||
|
|
snapshot.counts.synonymGroups = (synonyms.groups || []).length;
|
||
|
|
} catch { snapshot.counts.synonymGroups = 0; }
|
||
|
|
|
||
|
|
snapshot.counts.hooks = fs.readdirSync(path.join(ROOT, 'hooks')).filter(f => f.endsWith('.js')).length;
|
||
|
|
snapshot.counts.scripts = fs.readdirSync(path.join(ROOT, 'scripts')).filter(f => f.endsWith('.js')).length;
|
||
|
|
|
||
|
|
return snapshot;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 保存快照
|
||
|
|
*/
|
||
|
|
function saveSnapshot(snapshot) {
|
||
|
|
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
||
|
|
fs.writeFileSync(SNAPSHOT_FILE, JSON.stringify(snapshot, null, 2) + '\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 加载上一次快照
|
||
|
|
*/
|
||
|
|
function loadSnapshot() {
|
||
|
|
try {
|
||
|
|
if (fs.existsSync(SNAPSHOT_FILE)) {
|
||
|
|
return JSON.parse(fs.readFileSync(SNAPSHOT_FILE, 'utf8'));
|
||
|
|
}
|
||
|
|
} catch {}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 对比当前状态与快照
|
||
|
|
* @returns {{ drifts: Array, summary: Object }}
|
||
|
|
*/
|
||
|
|
function checkDrift() {
|
||
|
|
const prev = loadSnapshot();
|
||
|
|
if (!prev) return { drifts: [], summary: { status: 'no-baseline', message: '无快照基线,运行 --snapshot 生成' } };
|
||
|
|
|
||
|
|
const current = generateSnapshot();
|
||
|
|
const drifts = [];
|
||
|
|
|
||
|
|
// 配置文件变更检测
|
||
|
|
for (const file of MONITORED_CONFIGS) {
|
||
|
|
if (prev.configs[file] && current.configs[file] && prev.configs[file] !== current.configs[file]) {
|
||
|
|
drifts.push({ type: 'config', file, severity: 'high', detail: '哈希不匹配' });
|
||
|
|
}
|
||
|
|
if (prev.configs[file] && !current.configs[file]) {
|
||
|
|
drifts.push({ type: 'config', file, severity: 'critical', detail: '文件缺失' });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 计数异常检测 (±10% 阈值)
|
||
|
|
for (const [key, prevCount] of Object.entries(prev.counts || {})) {
|
||
|
|
const currCount = current.counts[key];
|
||
|
|
if (currCount === undefined) continue;
|
||
|
|
const threshold = Math.max(2, Math.round(prevCount * 0.1));
|
||
|
|
if (Math.abs(currCount - prevCount) > threshold) {
|
||
|
|
drifts.push({
|
||
|
|
type: 'count', key, severity: 'medium',
|
||
|
|
detail: `${prevCount} → ${currCount} (变化 ${currCount - prevCount})`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const age = Date.now() - new Date(prev.generated).getTime();
|
||
|
|
const ageHours = Math.round(age / 3600000);
|
||
|
|
|
||
|
|
return {
|
||
|
|
drifts,
|
||
|
|
summary: {
|
||
|
|
status: drifts.length === 0 ? 'clean' : 'drift-detected',
|
||
|
|
driftCount: drifts.length,
|
||
|
|
criticals: drifts.filter(d => d.severity === 'critical').length,
|
||
|
|
highs: drifts.filter(d => d.severity === 'high').length,
|
||
|
|
snapshotAge: `${ageHours}h`,
|
||
|
|
message: drifts.length === 0
|
||
|
|
? `无漂移 (快照 ${ageHours}h 前)`
|
||
|
|
: `检测到 ${drifts.length} 处漂移`,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 检测权重漂移
|
||
|
|
*/
|
||
|
|
function checkWeightDrift() {
|
||
|
|
const findings = [];
|
||
|
|
|
||
|
|
// 融合权重偏移
|
||
|
|
try {
|
||
|
|
const fwl = require('./fusion-weight-learner.js');
|
||
|
|
const learned = fwl.loadWeights();
|
||
|
|
const defaults = fwl.DEFAULT_WEIGHTS;
|
||
|
|
for (const [key, defVal] of Object.entries(defaults)) {
|
||
|
|
const delta = Math.abs(learned[key] - defVal);
|
||
|
|
if (delta > 0.15) {
|
||
|
|
findings.push({
|
||
|
|
type: 'fusion-weight', key, severity: 'warning',
|
||
|
|
detail: `${defVal} → ${learned[key]} (偏移 ${delta.toFixed(3)})`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {}
|
||
|
|
|
||
|
|
// 关键词权重偏移
|
||
|
|
try {
|
||
|
|
const weightsFile = path.join(DEBUG_DIR, 'route-weights.json');
|
||
|
|
if (fs.existsSync(weightsFile)) {
|
||
|
|
const weights = JSON.parse(fs.readFileSync(weightsFile, 'utf8'));
|
||
|
|
const deltas = weights.deltas || {};
|
||
|
|
let maxDelta = 0, maxSkill = '', maxKw = '';
|
||
|
|
for (const [skill, kws] of Object.entries(deltas)) {
|
||
|
|
for (const [kw, d] of Object.entries(kws)) {
|
||
|
|
if (Math.abs(d) > maxDelta) { maxDelta = Math.abs(d); maxSkill = skill; maxKw = kw; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (maxDelta > 0.3) {
|
||
|
|
findings.push({
|
||
|
|
type: 'keyword-weight', severity: 'warning',
|
||
|
|
detail: `最大偏移: ${maxSkill}/${maxKw} = ${maxDelta.toFixed(2)}`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {}
|
||
|
|
|
||
|
|
return findings;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 模块导出
|
||
|
|
if (typeof module !== 'undefined') {
|
||
|
|
module.exports = {
|
||
|
|
generateSnapshot, saveSnapshot, loadSnapshot,
|
||
|
|
checkDrift, checkWeightDrift,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// CLI 入口
|
||
|
|
if (require.main === module) {
|
||
|
|
const args = process.argv.slice(2);
|
||
|
|
const jsonMode = args.includes('--json');
|
||
|
|
|
||
|
|
if (args.includes('--snapshot')) {
|
||
|
|
const snap = generateSnapshot();
|
||
|
|
saveSnapshot(snap);
|
||
|
|
if (jsonMode) {
|
||
|
|
console.log(JSON.stringify(snap, null, 2));
|
||
|
|
} else {
|
||
|
|
console.log('配置快照已生成:');
|
||
|
|
console.log(` 配置文件: ${Object.keys(snap.configs).length}`);
|
||
|
|
console.log(` 状态文件: ${Object.keys(snap.state).length}`);
|
||
|
|
console.log(` 计数: skills=${snap.counts.skills}, hooks=${snap.counts.hooks}, scripts=${snap.counts.scripts}`);
|
||
|
|
}
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (args.includes('--weights')) {
|
||
|
|
const findings = checkWeightDrift();
|
||
|
|
if (jsonMode) { console.log(JSON.stringify(findings, null, 2)); }
|
||
|
|
else {
|
||
|
|
console.log(`权重漂移检测: ${findings.length} 个告警`);
|
||
|
|
for (const f of findings) console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
|
||
|
|
}
|
||
|
|
process.exit(findings.length > 0 ? 1 : 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --check or --full
|
||
|
|
const drift = checkDrift();
|
||
|
|
const weightFindings = args.includes('--full') ? checkWeightDrift() : [];
|
||
|
|
|
||
|
|
if (jsonMode) {
|
||
|
|
console.log(JSON.stringify({ ...drift, weightDrift: weightFindings }, null, 2));
|
||
|
|
} else {
|
||
|
|
console.log('=== 漂移防护检查 ===');
|
||
|
|
console.log(`状态: ${drift.summary.status}`);
|
||
|
|
console.log(`快照年龄: ${drift.summary.snapshotAge || 'N/A'}`);
|
||
|
|
if (drift.drifts.length > 0) {
|
||
|
|
console.log(`\n漂移项:`);
|
||
|
|
for (const d of drift.drifts) {
|
||
|
|
console.log(` [${d.severity}] ${d.type}: ${d.file || d.key} — ${d.detail}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (weightFindings.length > 0) {
|
||
|
|
console.log(`\n权重漂移:`);
|
||
|
|
for (const f of weightFindings) console.log(` [${f.severity}] ${f.detail}`);
|
||
|
|
}
|
||
|
|
if (drift.drifts.length === 0 && weightFindings.length === 0) {
|
||
|
|
console.log('系统无漂移 ✓');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|