#!/usr/bin/env node /** * Bookworm v6.0 Round-2 Critical Patches — hooks/ 补丁脚本 * * 涵盖: * XC2 — route-compliance-gate.js: PowerShell/wmic 双执行 + driveLetter 校验 * XC3 — block-sensitive-files.js: 安全日志 detail 字段未脱敏 * XC11 — block-sensitive-files.js: isSettingsSafeWrite 只查键不查值 * XC14 — route-interceptor.js: task-notification 污染 route-state * * 用法: node scripts/apply-r2-critical-patches.js [--dry-run] [--patch=XC2,XC3,...] * * 幂等性: 每个补丁通过 sentinel 字符串检测是否已应用,已修复则跳过。 * Fail-safe: 任何单个补丁失败不影响其他补丁继续执行。 * 原子写入: 先写 .tmp 再 rename,防止写入中断导致文件损坏。 */ 'use strict'; const fs = require('fs'); const path = require('path'); // ─── 路径检测 ──────────────────────────────────────────── function detectClaudeRoot() { const selfDir = __dirname; if (selfDir.includes('.claude')) return selfDir.replace(/[/\\]scripts$/, ''); return (process.env.USERPROFILE || process.env.HOME || '').replace(/\\/g, '/') + '/.claude'; } const ROOT = detectClaudeRoot(); const HOOKS_DIR = path.join(ROOT, 'hooks'); // ─── CLI 参数 ──────────────────────────────────────────── const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const patchArg = args.find(a => a.startsWith('--patch=')); const PATCH_FILTER = patchArg ? new Set(patchArg.replace('--patch=', '').split(',').map(s => s.trim().toUpperCase())) : null; // ─── 结果收集 ──────────────────────────────────────────── const results = []; /** * 通用补丁函数:基于 sentinel 幂等检测 + 原子写入 * @param {string} id 补丁 ID * @param {string} file 目标文件绝对路径 * @param {string} sentinel 已应用标记(存在则跳过) * @param {Function} transform (content: string) => string,返回修改后内容 * @param {string} desc 描述 */ function applyPatch(id, file, sentinel, transform, desc) { if (PATCH_FILTER && !PATCH_FILTER.has(id)) { results.push({ id, status: 'skip', reason: '未在 --patch 过滤列表中', file: path.basename(file) }); return; } try { if (!fs.existsSync(file)) { results.push({ id, status: 'error', reason: `文件不存在: ${file}`, file: path.basename(file) }); return; } const content = fs.readFileSync(file, 'utf8'); // 幂等检测 if (content.includes(sentinel)) { results.push({ id, status: 'skip', reason: '已应用(幂等跳过)', file: path.basename(file) }); return; } const patched = transform(content); if (patched === content) { results.push({ id, status: 'error', reason: '目标字符串未找到(版本不匹配?)', file: path.basename(file) }); return; } if (DRY_RUN) { results.push({ id, status: 'dry-run', desc, file: path.basename(file) }); return; } // 原子写入 const tmp = file + '.r2patch.tmp'; fs.writeFileSync(tmp, patched, 'utf8'); fs.renameSync(tmp, file); results.push({ id, status: 'ok', desc, file: path.basename(file) }); } catch (err) { results.push({ id, status: 'error', reason: err.message, file: path.basename(file) }); } } // ═══════════════════════════════════════════════════════════ // XC2: route-compliance-gate.js // 问题: PowerShell 成功后 wmic 仍无条件执行(缓存 miss 时延迟翻倍) // 修复: PowerShell 成功立即写缓存并 return;wmic 仅在 PowerShell 失败时执行 // 同时增加 driveLetter 输入校验防止命令注入 // ═══════════════════════════════════════════════════════════ applyPatch( 'XC2', path.join(HOOKS_DIR, 'route-compliance-gate.js'), 'XC2 修复: driveLetter 输入校验', // sentinel (content) => { // 精确匹配现有代码块(从 driveLetter 声明到 wmic 执行结束) const OLD = [ ' // 优先 PowerShell Get-PSDrive (Windows 10/11 通用,无弃用风险)', ' try {', ' const driveLetter = drive.charAt(0);', ' const psOut = execSync(`powershell -NoProfile -Command "(Get-PSDrive ${driveLetter}).Free"`, { encoding: \'utf8\', timeout: 3000 }).trim();', ' const freeSpace = parseInt(psOut, 10);', ' if (!isNaN(freeSpace)) { result = freeSpace < THRESHOLD; }', ' } catch {}', '', ' // Fallback: wmic (Windows 10 及旧版本)', ' const wmicOut = execSync(`wmic logicaldisk where "DeviceID=\'${drive}\'" get FreeSpace /value`, { encoding: \'utf8\', timeout: 2000 });', ' const match = wmicOut.match(/FreeSpace=(\\d+)/);', ' if (match) { result = parseInt(match[1], 10) < THRESHOLD; }', ].join('\n'); const NEW = [ ' // 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 psOut = execSync(`powershell -NoProfile -Command "(Get-PSDrive ${driveLetter}).Free"`, { encoding: \'utf8\', timeout: 3000 }).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 wmicOut = execSync(`wmic logicaldisk where "DeviceID=\'${drive}\'" get FreeSpace /value`, { encoding: \'utf8\', timeout: 2000 });', ' const match = wmicOut.match(/FreeSpace=(\\d+)/);', ' if (match) { result = parseInt(match[1], 10) < THRESHOLD; }', ' }', ].join('\n'); return content.includes(OLD) ? content.replace(OLD, NEW) : content; }, 'XC2: PowerShell 成功后跳过 wmic + driveLetter 输入校验', ); // ═══════════════════════════════════════════════════════════ // XC3-import: block-sensitive-files.js // 注入 _sanitize 辅助函数到文件顶部 require 区域之后 // ═══════════════════════════════════════════════════════════ applyPatch( 'XC3-import', path.join(HOOKS_DIR, 'block-sensitive-files.js'), '// XC3 修复: 引入 sanitize 模块', // sentinel (content) => { const OLD = 'const fs = require(\'fs\');\nconst path = require(\'path\');'; const NEW = [ "const fs = require('fs');", "const path = require('path');", '', '// XC3 修复: 引入 sanitize 模块,对安全日志中的敏感内容脱敏(防止 API Key 写入日志)', 'const _sanitize = (() => {', ' try {', ' const _root = (() => {', ' const selfDir = path.dirname(__filename);', " if (selfDir.includes('.claude')) return selfDir.replace(/[\\/\\\\]hooks$/, '');", " return (process.env.USERPROFILE || process.env.HOME || '').replace(/\\\\/g, '/') + '/.claude';", ' })();', " return require(path.join(_root, 'scripts', 'sanitize.js')).sanitize;", " } catch { return (t) => (t || ''); } // 模块缺失时透传,不阻断主流程", '})();', ].join('\n'); return content.includes(OLD) ? content.replace(OLD, NEW) : content; }, 'XC3-import: 注入 _sanitize 辅助函数', ); // ═══════════════════════════════════════════════════════════ // XC3: block-sensitive-files.js // 对 logSecurityEvent 的 detail 字段调用 _sanitize 脱敏 // ═══════════════════════════════════════════════════════════ applyPatch( 'XC3', path.join(HOOKS_DIR, 'block-sensitive-files.js'), '// XC3 修复: 脱敏后写入', // sentinel (content) => { const OLD = " detail: (detail || '').slice(0, 200),"; const NEW = " detail: _sanitize((detail || '').slice(0, 200)), // XC3 修复: 脱敏后写入,防止 API Key 泄露到安全日志"; return content.includes(OLD) ? content.replace(OLD, NEW) : content; }, 'XC3: logSecurityEvent detail 字段脱敏', ); // ═══════════════════════════════════════════════════════════ // XC11: block-sensitive-files.js // isSettingsSafeWrite 增加值校验 // skipDangerousModePermissionPrompt 只允许设为 false // ═══════════════════════════════════════════════════════════ applyPatch( 'XC11', path.join(HOOKS_DIR, 'block-sensitive-files.js'), 'XC11 修复: 校验每个键的值', // sentinel (content) => { const OLD = [ ' function isSettingsSafeWrite(fp, c) {', " if (!/[\\/]\\.\\.claude[\\/]settings\\.json$/.test(fp)) return false;", ' if (!c) return false;', ' try {', ' const parsed = JSON.parse(c);', ' const keys = Object.keys(parsed);', ' return keys.length > 0 && keys.every(k => SETTINGS_SAFE_KEYS.has(k));', ' } catch { return false; }', ' }', ].join('\n'); // 原文件实际内容(从 Read 工具确认的) const OLD2 = ' function isSettingsSafeWrite(fp, c) {\n' + " if (!/[\\/]\\.claude[\\/]settings\\.json$/.test(fp)) return false;\n" + ' if (!c) return false;\n' + ' try {\n' + ' const parsed = JSON.parse(c);\n' + ' const keys = Object.keys(parsed);\n' + ' return keys.length > 0 && keys.every(k => SETTINGS_SAFE_KEYS.has(k));\n' + ' } catch { return false; }\n' + ' }'; const NEW = [ ' // XC11 修复: 除键名白名单外,还限制允许写入的值', ' // 攻击者不能将 skipDangerousModePermissionPrompt 设回 true', ' const SETTINGS_SAFE_VALUES = {', " 'skipDangerousModePermissionPrompt': [false],", ' };', ' function isSettingsSafeWrite(fp, c) {', " if (!/[\\/]\\.claude[\\/]settings\\.json$/.test(fp)) return false;", ' if (!c) return false;', ' try {', ' const parsed = JSON.parse(c);', ' const keys = Object.keys(parsed);', ' if (keys.length === 0) return false;', ' if (!keys.every(k => SETTINGS_SAFE_KEYS.has(k))) return false;', ' // XC11 修复: 校验每个键的值是否在允许列表中', ' for (const [k, v] of Object.entries(parsed)) {', ' const allowed = SETTINGS_SAFE_VALUES[k];', ' if (allowed !== undefined && !allowed.includes(v)) return false;', ' }', ' return true;', ' } catch { return false; }', ' }', ].join('\n'); if (content.includes(OLD2)) return content.replace(OLD2, NEW); if (content.includes(OLD)) return content.replace(OLD, NEW); return content; }, 'XC11: isSettingsSafeWrite 增加值校验,skipDangerousModePermissionPrompt 只允许 false', ); // ═══════════════════════════════════════════════════════════ // XC14: route-interceptor.js // task-notification 系统消息在 main() 中提前退出 // 注意: appendRouteLog 已有同类过滤,但 writeRouteState 调用早于它 // ═══════════════════════════════════════════════════════════ applyPatch( 'XC14', path.join(HOOKS_DIR, 'route-interceptor.js'), 'XC14 修复: task-notification 系统消息提前退出', // sentinel (content) => { const OLD = ' if (!prompt || typeof prompt !== \'string\' || prompt.trim().length === 0) {\n' + ' process.exit(0);\n' + ' return;\n' + ' }\n' + '\n' + ' // v5.3: 会话首次激活横幅 (返回横幅文本或 null)'; const NEW = ' if (!prompt || typeof prompt !== \'string\' || prompt.trim().length === 0) {\n' + ' process.exit(0);\n' + ' return;\n' + ' }\n' + '\n' + ' // XC14 修复: task-notification 系统消息提前退出,不写 route-state-current.json\n' + ' // appendRouteLog 已有同类过滤,但 writeRouteState 调用早于它,state 文件会被污染\n' + ' if (prompt.includes(\'\')) {\n' + ' process.exit(0);\n' + ' return;\n' + ' }\n' + '\n' + ' // v5.3: 会话首次激活横幅 (返回横幅文本或 null)'; return content.includes(OLD) ? content.replace(OLD, NEW) : content; }, 'XC14: task-notification 提前退出,不写 route-state', ); // ─── 运行语法验证 ───────────────────────────────────────── function verifySyntax(file) { if (DRY_RUN) return; try { const { execSync } = require('child_process'); execSync(`node -c "${file}"`, { encoding: 'utf8', timeout: 5000 }); return true; } catch (err) { return false; } } // ─── 输出结果 ───────────────────────────────────────────── console.log('\n=== Bookworm v6.0 Round-2 Patch Results ===\n'); let okCount = 0, skipCount = 0, errorCount = 0; for (const r of results) { const icon = r.status === 'ok' ? '✓' : r.status === 'skip' ? '–' : r.status === 'dry-run' ? '○' : '✗'; const detail = r.desc || r.reason || ''; console.log(` ${icon} [${r.id}] ${r.file}: ${r.status}${detail ? ' — ' + detail : ''}`); if (r.status === 'ok' || r.status === 'dry-run') okCount++; else if (r.status === 'skip') skipCount++; else errorCount++; } // 语法验证(非 dry-run) if (!DRY_RUN) { const toVerify = [ path.join(HOOKS_DIR, 'route-compliance-gate.js'), path.join(HOOKS_DIR, 'block-sensitive-files.js'), path.join(HOOKS_DIR, 'route-interceptor.js'), ]; console.log('\n--- 语法验证 ---'); for (const f of toVerify) { const ok = verifySyntax(f); console.log(` ${ok ? '✓' : '✗'} node -c ${path.basename(f)}: ${ok ? 'OK' : 'SYNTAX ERROR'}`); if (!ok) errorCount++; } } console.log(`\n Applied: ${okCount} Skipped: ${skipCount} Errors: ${errorCount}`); if (DRY_RUN) console.log(' (dry-run 模式,未实际写入文件)'); console.log(''); process.exit(errorCount > 0 ? 1 : 0);