320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Phase 2 Hook Patches 应用脚本 (v6.0 F2-2, F2-4, F2-5)
|
||
*
|
||
* 由于 block-sensitive-files.js 保护 .claude/hooks/*.js 不允许 AI 直接写入,
|
||
* 此脚本由用户手动执行,应用三个 hook 修改:
|
||
*
|
||
* F2-2: route-interceptor.js — L2 域内精排 (高置信度隔离域外技能)
|
||
* F2-4: constitution-precheck.js — 新建 PreToolUse 宪法预检钩子
|
||
* F2-5: constitution-guard.js — 扩展 CODE_EXTENSIONS 覆盖多语言
|
||
*
|
||
* 用法:
|
||
* node scripts/apply-phase2-hook-patches.js → 预览 (dry-run)
|
||
* node scripts/apply-phase2-hook-patches.js --apply → 应用所有变更
|
||
* node scripts/apply-phase2-hook-patches.js --f2-2 → 仅 F2-2
|
||
* node scripts/apply-phase2-hook-patches.js --f2-4 → 仅 F2-4
|
||
* node scripts/apply-phase2-hook-patches.js --f2-5 → 仅 F2-5
|
||
*
|
||
* 安全: 应用前备份原文件到 hooks/*.js.bak-phase2
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
function detectClaudeRoot() {
|
||
if (process.env.CLAUDE_HOME) return process.env.CLAUDE_HOME;
|
||
const selfDir = path.dirname(__filename);
|
||
if (selfDir.includes('.claude')) return selfDir.replace(/[/\\]scripts$/, '');
|
||
return (process.env.USERPROFILE || process.env.HOME || '').replace(/\\/g, '/') + '/.claude';
|
||
}
|
||
|
||
const CLAUDE_ROOT = detectClaudeRoot();
|
||
const HOOKS_DIR = path.join(CLAUDE_ROOT, 'hooks');
|
||
|
||
// ─── 工具函数 ─────────────────────────────────────────
|
||
|
||
function readHook(filename) {
|
||
const fp = path.join(HOOKS_DIR, filename);
|
||
if (!fs.existsSync(fp)) throw new Error('\u6587\u4ef6\u4e0d\u5b58\u5728: ' + fp);
|
||
return fs.readFileSync(fp, 'utf8');
|
||
}
|
||
|
||
function writeHook(filename, content) {
|
||
const fp = path.join(HOOKS_DIR, filename);
|
||
const bakPath = fp + '.bak-phase2';
|
||
if (!fs.existsSync(bakPath) && fs.existsSync(fp)) {
|
||
fs.copyFileSync(fp, bakPath);
|
||
console.log(' \u5df2\u5907\u4efd: ' + bakPath);
|
||
}
|
||
fs.writeFileSync(fp, content, 'utf8');
|
||
console.log(' \u5df2\u5199\u5165: ' + fp);
|
||
}
|
||
|
||
// ─── F2-2: route-interceptor.js L2 域内精排 ──────────
|
||
|
||
function applyF22(dryRun) {
|
||
console.log('\n[F2-2] route-interceptor.js \u2014 L2 \u57df\u5185\u7cbe\u6392');
|
||
|
||
let content;
|
||
try {
|
||
content = readHook('route-interceptor.js');
|
||
} catch (e) {
|
||
console.error(' [ERROR] ' + e.message);
|
||
return false;
|
||
}
|
||
|
||
// 文件使用 CRLF — 先规范化再匹配
|
||
const hasCRLF = content.includes('\r\n');
|
||
const norm = hasCRLF ? content.replace(/\r\n/g, '\n') : content;
|
||
|
||
// 搜索锚点 (单行,CRLF 不影响)
|
||
const ANCHOR = ' // v5.9: L1 \u57df\u5916\u964d\u6743 (\u975e\u5019\u9009\u57df\u6280\u80fd\u5206\u6570 \u00d70.5)';
|
||
if (!norm.includes(ANCHOR)) {
|
||
console.error(' [ERROR] \u672a\u627e\u5230\u9524\u70b9: "v5.9: L1 \u57df\u5916\u964d\u6743"\u3002\u8bf7\u624b\u52a8\u68c0\u67e5 route-interceptor.js \u7b2c244\u884c\u9644\u8fd1\u3002');
|
||
return false;
|
||
}
|
||
|
||
// 构建 OLD/NEW (用 join 避免嵌入字面换行)
|
||
const NL = '\n';
|
||
const OLD = [
|
||
' // v5.9: L1 \u57df\u5916\u964d\u6743 (\u975e\u5019\u9009\u57df\u6280\u80fd\u5206\u6570 \u00d70.5)',
|
||
' if (candidateSkillSet && finalScore > 0 && !candidateSkillSet.has(skill.name)) {',
|
||
' finalScore *= 0.5;',
|
||
' }',
|
||
].join(NL);
|
||
|
||
const NEW = [
|
||
' // v6.0 F2-2: L2 \u57df\u5185\u7cbe\u6392 \u2014 \u9ad8\u7f6e\u4fe1\u5ea6\u57df\u5206\u7c7b\u65f6\u5b8c\u5168\u9694\u79bb\u57df\u5916\u6280\u80fd',
|
||
' // \u5f53 L1 \u7f6e\u4fe1\u5ea6 > 0.6 \u65f6: \u57df\u5916\u6280\u80fd\u76f4\u63a5\u5f52\u96f6 (\u4e0d\u53c2\u4e0e BM25 \u8bc4\u5206\u7ade\u4e89)',
|
||
' // \u5f53 L1 \u7f6e\u4fe1\u5ea6 <= 0.6 \u65f6: \u4fdd\u6301\u539f\u6709\u964d\u6743\u7b56\u7565 (\u00d70.5) \u5411\u540e\u517c\u5bb9',
|
||
' if (candidateSkillSet && finalScore > 0 && !candidateSkillSet.has(skill.name)) {',
|
||
' if (domainInfo && domainInfo.confidence > 0.6) {',
|
||
' // \u9ad8\u7f6e\u4fe1\u5ea6: \u5b8c\u5168\u9694\u79bb\u57df\u5916\u6280\u80fd (\u7f6e\u96f6)',
|
||
' finalScore = 0;',
|
||
' } else {',
|
||
' // \u4f4e\u7f6e\u4fe1\u5ea6: \u964d\u6743\u4fdd\u7559 (\u5411\u540e\u517c\u5bb9)',
|
||
' finalScore *= 0.5;',
|
||
' }',
|
||
' }',
|
||
].join(NL);
|
||
|
||
if (!norm.includes(OLD)) {
|
||
console.error(' [ERROR] \u5b8c\u6574\u4ee3\u7801\u5757\u4e0d\u5339\u914d\u3002\u8bf7\u624b\u52a8\u5c06 v5.9 \u964d\u6743\u5757\u66ff\u6362\u4e3a v6.0 L2 \u9694\u79bb\u5757\u3002');
|
||
return false;
|
||
}
|
||
|
||
let updated = norm.replace(OLD, NEW);
|
||
updated = updated.replace(" version: '5.9',", " version: '6.0',");
|
||
const final = hasCRLF ? updated.replace(/\n/g, '\r\n') : updated;
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY-RUN] \u5c06\u5e94\u7528 L2 \u9694\u79bb\u903b\u8f91 | \u884c\u5c3e: ' + (hasCRLF ? 'CRLF' : 'LF') + ' | version: 5.9\u21926.0');
|
||
} else {
|
||
writeHook('route-interceptor.js', final);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ─── F2-4: constitution-precheck.js 新建 PreToolUse 钩子 ──
|
||
|
||
function buildPrecheckContent() {
|
||
// 逐行构建,避免任何模板字符串或嵌入换行的歧义
|
||
const L = [];
|
||
L.push('#!/usr/bin/env node');
|
||
L.push('/**');
|
||
L.push(' * PreToolUse Hook: \u5bbf\u6cd5 ERROR \u7ea7\u89c4\u5219\u9884\u68c0 (v6.0 F2-4)');
|
||
L.push(' * Matcher: Write|Edit');
|
||
L.push(' *');
|
||
L.push(' * constitution-guard.js \u5728 PostToolUse \u963b\u4e0d\u4f4f\u5199\u5165\u524d\u7684\u9519\u8bef\u3002');
|
||
L.push(' * \u672c\u9489\u5b50\u5728 PreToolUse \u9636\u6bb5\u5bf9\u6700\u9ad8\u98ce\u9669 ERROR \u89c4\u5219\u8fdb\u884c\u524d\u7f6e\u62e6\u622a\u3002');
|
||
L.push(' *');
|
||
L.push(' * \u89c4\u5219:');
|
||
L.push(' * exec-injection \u2014 child_process \u53c2\u6570\u542b\u53d8\u91cf\u62fc\u63a5');
|
||
L.push(' * hardcoded-secret \u2014 \u786c\u7f16\u7801 API Key / Token');
|
||
L.push(' *');
|
||
L.push(' * \u9000\u51fa\u7801: 0=\u653e\u884c | 2=\u963b\u65ad Fail-open: \u5f02\u5e38\u65f6 exit(0)');
|
||
L.push(' */');
|
||
L.push("'use strict';");
|
||
L.push("const fs = require('fs');");
|
||
L.push("const path = require('path');");
|
||
L.push('const MAX_STDIN_SIZE = 512 * 1024;');
|
||
L.push('try {');
|
||
L.push(" const { isEnabled } = require('../scripts/feature-flags.js');");
|
||
L.push(" if (!isEnabled('constitution-precheck')) process.exit(0);");
|
||
L.push('} catch {}');
|
||
L.push('const CODE_EXTENSIONS = /\\.(?:js|ts|jsx|tsx|mjs|cjs|py|go|rs|java|rb|php|sh|bash|zsh|ps1)$/i;');
|
||
L.push('const PRECHECK_RULES = [');
|
||
L.push(' {');
|
||
L.push(" id: 'exec-injection',");
|
||
L.push(" label: '\u547d\u4ee4\u6ce8\u5165: child_process \u53c2\u6570\u542b\u53d8\u91cf\u62fc\u63a5',");
|
||
// The regex pattern — written as a string value, not as a regex literal to avoid confusing the outer file
|
||
L.push(" pattern: /(?:exec|execSync|spawn|spawnSync)\\s*\\(\\s*(?:`[^`]*\\$\\{|['\"][^'\"]*['\"]\\s*\\+)/,");
|
||
L.push(' },');
|
||
L.push(' {');
|
||
L.push(" id: 'hardcoded-secret',");
|
||
L.push(" label: '\u786c\u7f16\u7801\u5bc6\u9470: \u5305\u542b\u9ad8\u71b5\u5bc6\u9470\u5b57\u7b26\u4e32',");
|
||
L.push(" pattern: /(?:api[_-]?key|api[_-]?secret|access[_-]?token|secret[_-]?key|auth[_-]?token|private[_-]?key)\\s*[=:]\\s*['\"`][A-Za-z0-9+\\/=_\\-]{20,}['\"`]/i,");
|
||
L.push(' },');
|
||
L.push('];');
|
||
L.push('function extractContent(ti) {');
|
||
L.push(" return ti ? (ti.content || '') + (ti.new_string || '') : '';");
|
||
L.push('}');
|
||
L.push('function detectViolation(content) {');
|
||
L.push(" const lines = (content || '').split('\\n');");
|
||
L.push(' for (const rule of PRECHECK_RULES) {');
|
||
L.push(' for (let i = 0; i < lines.length; i++) {');
|
||
L.push(' if (rule.pattern && rule.pattern.test(lines[i])) {');
|
||
L.push(' return { id: rule.id, label: rule.label, line: i + 1 };');
|
||
L.push(' }');
|
||
L.push(' }');
|
||
L.push(' }');
|
||
L.push(' return null;');
|
||
L.push('}');
|
||
L.push('function logEvent(ruleId, filePath, lineNo) {');
|
||
L.push(' try {');
|
||
L.push(" const root = path.dirname(__filename).replace(/[\\/\\\\]hooks$/, '');");
|
||
L.push(" const dir = path.join(root, 'debug');");
|
||
L.push(' if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });');
|
||
L.push(' const f = path.join(dir, `security-${new Date().toISOString().slice(0,10)}.jsonl`);');
|
||
L.push(" fs.appendFileSync(f, JSON.stringify({ ts: new Date().toISOString(), decision: 'deny', hook: 'constitution-precheck', rule: ruleId, line: lineNo }) + '\\n');");
|
||
L.push(' } catch {}');
|
||
L.push('}');
|
||
L.push('function main() {');
|
||
L.push(" let raw = '';");
|
||
L.push(" process.stdin.setEncoding('utf8');");
|
||
L.push(" process.stdin.on('data', c => { raw += c; if (raw.length > MAX_STDIN_SIZE) process.exit(0); });");
|
||
L.push(" process.stdin.on('end', () => {");
|
||
L.push(' try {');
|
||
L.push(' const input = JSON.parse(raw);');
|
||
L.push(" const fp = input.tool_input?.file_path || '';");
|
||
L.push(' if (!fp || !CODE_EXTENSIONS.test(fp)) { process.exit(0); return; }');
|
||
L.push(' const content = extractContent(input.tool_input);');
|
||
L.push(' if (!content) { process.exit(0); return; }');
|
||
L.push(' const v = detectViolation(content);');
|
||
L.push(' if (v) {');
|
||
L.push(' logEvent(v.id, fp, v.line);');
|
||
L.push(' process.stderr.write(JSON.stringify({');
|
||
L.push(" hookSpecificOutput: { permissionDecision: 'deny' },");
|
||
L.push(' systemMessage: [');
|
||
L.push(" '[constitution-precheck] ERROR \u7ea7\u8fdd\u89c4\uff0c\u5df2\u963b\u65ad\u5199\u5165',");
|
||
L.push(' `\u6587\u4ef6: ${path.basename(fp)}, \u884c: ${v.line}`,');
|
||
L.push(' `\u89c4\u5219: [${v.id}] ${v.label}`,');
|
||
L.push(" '\u8bf7\u4fee\u590d\u540e\u91cd\u8bd5\u3002\u53c2\u8003: constitution/AI-CONSTITUTION.md \u7b2c\u5341\u4e00\u7ae0',");
|
||
L.push(" ].join('\\n'),");
|
||
L.push(' }));');
|
||
L.push(' process.exit(2); return;');
|
||
L.push(' }');
|
||
L.push(' process.exit(0);');
|
||
L.push(' } catch { process.exit(0); }');
|
||
L.push(' });');
|
||
L.push('}');
|
||
L.push("if (typeof module !== 'undefined') {");
|
||
L.push(' module.exports = { PRECHECK_RULES, detectViolation, extractContent, CODE_EXTENSIONS };');
|
||
L.push('}');
|
||
L.push('if (require.main === module) main();');
|
||
return L.join('\n') + '\n';
|
||
}
|
||
|
||
function applyF24(dryRun) {
|
||
console.log('\n[F2-4] constitution-precheck.js \u2014 \u65b0\u5efa PreToolUse \u5bbf\u6cd5\u9884\u68c0\u9489\u5b50');
|
||
|
||
const fp = path.join(HOOKS_DIR, 'constitution-precheck.js');
|
||
if (fs.existsSync(fp)) {
|
||
console.log(' [SKIP] \u6587\u4ef6\u5df2\u5b58\u5728\uff0c\u8df3\u8fc7\u3002\u5982\u9700\u91cd\u5efa\uff0c\u8bf7\u5148\u5220\u9664\u8be5\u6587\u4ef6\u3002');
|
||
return true;
|
||
}
|
||
|
||
const content = buildPrecheckContent();
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY-RUN] \u5c06\u65b0\u5efa: ' + fp);
|
||
console.log(' \u89c4\u5219: exec-injection + hardcoded-secret | \u9636\u6bb5: PreToolUse | \u963b\u65ad\u65f6 exit(2)');
|
||
} else {
|
||
fs.writeFileSync(fp, content, 'utf8');
|
||
console.log(' \u5df2\u521b\u5efa: ' + fp);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ─── F2-5: constitution-guard.js 扩展 CODE_EXTENSIONS ──
|
||
|
||
function applyF25(dryRun) {
|
||
console.log('\n[F2-5] constitution-guard.js \u2014 \u6269\u5c55 CODE_EXTENSIONS');
|
||
|
||
let content;
|
||
try {
|
||
content = readHook('constitution-guard.js');
|
||
} catch (e) {
|
||
console.error(' [ERROR] ' + e.message);
|
||
return false;
|
||
}
|
||
|
||
const OLD_EXT = 'const CODE_EXTENSIONS = /\\.(?:js|ts|jsx|tsx|mjs|cjs)$/i;';
|
||
const NEW_EXT = 'const CODE_EXTENSIONS = /\\.(?:js|ts|jsx|tsx|mjs|cjs|py|go|rs|java|rb|php|sh|bash|zsh|ps1)$/i;';
|
||
|
||
if (!content.includes(OLD_EXT)) {
|
||
console.error(' [ERROR] \u672a\u627e\u5230 CODE_EXTENSIONS \u5b9a\u4e49\u3002\u624b\u52a8\u5b9a\u4f4d\u7b2c30\u884c\u9644\u8fd1\u5e76\u66ff\u6362\u3002');
|
||
console.log(' \u5e94\u6709: ' + OLD_EXT);
|
||
console.log(' \u662f: ' + NEW_EXT);
|
||
return false;
|
||
}
|
||
|
||
const newContent = content.replace(OLD_EXT, NEW_EXT);
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY-RUN] \u5c06\u66ff\u6362 CODE_EXTENSIONS:');
|
||
console.log(' ' + OLD_EXT);
|
||
console.log(' \u2192 ' + NEW_EXT);
|
||
console.log(' \u65b0\u589e: .py .go .rs .java .rb .php .sh .bash .zsh .ps1');
|
||
} else {
|
||
writeHook('constitution-guard.js', newContent);
|
||
console.log(' \u65b0\u589e\u8986\u76d6: .py .go .rs .java .rb .php .sh .bash .zsh .ps1');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ─── 主入口 ──────────────────────────────────────────
|
||
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
const applyMode = args.includes('--apply');
|
||
const onlyF22 = args.includes('--f2-2');
|
||
const onlyF24 = args.includes('--f2-4');
|
||
const onlyF25 = args.includes('--f2-5');
|
||
const specificMode = onlyF22 || onlyF24 || onlyF25;
|
||
const dryRun = !applyMode;
|
||
|
||
console.log('=== Phase 2 Hook Patches ===');
|
||
console.log('\u6a21\u5f0f: ' + (dryRun ? 'DRY-RUN' : 'APPLY'));
|
||
console.log('\u76ee\u6807: ' + HOOKS_DIR + '\n');
|
||
|
||
const results = {};
|
||
if (!specificMode || onlyF22) results.f22 = applyF22(dryRun);
|
||
if (!specificMode || onlyF24) results.f24 = applyF24(dryRun);
|
||
if (!specificMode || onlyF25) results.f25 = applyF25(dryRun);
|
||
|
||
console.log('\n=== \u6267\u884c\u6458\u8981 ===');
|
||
const labels = { f22: 'F2-2 L2\u7cbe\u6392', f24: 'F2-4 \u9884\u68c0\u9489\u5b50', f25: 'F2-5 \u591a\u8bed\u8a00' };
|
||
for (const [k, ok] of Object.entries(results)) {
|
||
console.log(' ' + (ok ? '[OK] ' : '[FAIL] ') + (labels[k] || k));
|
||
}
|
||
|
||
if (dryRun) {
|
||
console.log('\n\u5e94\u7528: node scripts/apply-phase2-hook-patches.js --apply\n');
|
||
return;
|
||
}
|
||
|
||
if (results.f24) {
|
||
console.log('\n--- \u540e\u7eed\u6b65\u9aa4: \u5728 settings.json \u6ce8\u518c constitution-precheck.js (PreToolUse Write|Edit) ---');
|
||
console.log(' "command": "node ' + path.join(HOOKS_DIR, 'constitution-precheck.js').replace(/\\/g, '/') + '"');
|
||
}
|
||
console.log('\n\u5b8c\u6210\u3002\u56de\u6eda: \u4f7f\u7528 .bak-phase2 \u6587\u4ef6\u3002');
|
||
}
|
||
|
||
if (require.main === module) main();
|
||
|
||
module.exports = { applyF22, applyF24, applyF25, buildPrecheckContent };
|