#!/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 };