#!/usr/bin/env node /** * agent-isolation-gate.js · R5 · 2026-04-26 * * PreToolUse Hook (matcher: Bash|Write|Edit) · Agent 隔离软门控 * * 检测主程在主上下文里跑大批量任务的迹象, 通过 systemMessage 提示走 Agent 隔离, * 避免主上下文被批量生成型任务榨干 (与 R4 上下文压力信号互补). * * 触发规则 (满足任一): * B1) Bash command 含 `for X in A B C D E F` (≥6 词) shell 循环 * B2) Bash command 含 `seq N` 且 N>=6 * B3) Bash command && 链 ≥6 且含 mkdir/touch/cp/mv/echo/cat * W1) 同会话最近 90s 内 Write/Edit 累计 ≥5 次 * * 行为: * - 不阻断 (continue:true), 不影响功能 * - 注入 systemMessage 提示 Agent 替代方案 * - 节流: 同会话同规则 5 分钟内只播报 1 次 * - fail-open: 任何异常静默放行 * * 状态: ~/.claude/session-state/agent-isolation-gate.json */ 'use strict'; const fs = require('fs'); const path = require('path'); const CLAUDE_ROOT = require('./lib/root.js'); const readStdin = require('./lib/read-stdin.js'); const STATE_DIR = path.join(CLAUDE_ROOT, 'session-state'); const STATE_PATH = path.join(STATE_DIR, 'agent-isolation-gate.json'); const W_WINDOW_MS = 90 * 1000; // Write/Edit 累计窗口 const W_THRESHOLD = 5; // Write/Edit 触发阈值 const THROTTLE_MS = 5 * 60 * 1000; // 同规则 5 分钟节流 const STATE_TTL_MS = 24 * 3600 * 1000; function loadState() { try { if (!fs.existsSync(STATE_PATH)) return {}; return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')) || {}; } catch { return {}; } } function saveState(s) { try { if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true }); const tmp = STATE_PATH + '.tmp.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(s), 'utf8'); fs.renameSync(tmp, STATE_PATH); } catch {} } function pruneState(s) { const cutoff = Date.now() - STATE_TTL_MS; for (const k of Object.keys(s)) { if (!s[k] || !s[k].lastTs || s[k].lastTs < cutoff) delete s[k]; } return s; } function stripQuoted(s) { // 移除 '...' 和 "..." 内的内容, 防字符串内分号干扰分隔符计数 // 不处理 heredoc (<= 6) return { rule: 'B1', detail: 'for-loop ' + items.length + ' items' }; } // B2) seq N const seqMatch = c.match(/\bseq\s+(\d+)(?:\s+(\d+))?(?:\s+(\d+))?/); // [PATCH-X07-SEQ-STEP] if (seqMatch) { let n; const g1 = parseInt(seqMatch[1], 10); const g2 = seqMatch[2] ? parseInt(seqMatch[2], 10) : null; const g3 = seqMatch[3] ? parseInt(seqMatch[3], 10) : null; if (g3 !== null) { const step = Math.max(1, g2); n = Math.max(0, Math.floor((g3 - g1) / step) + 1); } else if (g2 !== null) { n = Math.max(0, g2 - g1 + 1); } else { n = g1; } if (n >= 6) return { rule: 'B2', detail: 'seq ' + n }; } // R5-SEPARATORS-V2: B3 扩展为 && / ; / 换行 三类分隔符联合统计 // 先剥离引号字符串避免误统计 (heredoc/echo 内分号) const stripped = stripQuoted(c); const sepCount = ((stripped.match(/&&|;|\n(?!\s*$)/g)) || []).length; if (sepCount >= 5 && /\b(mkdir|touch|cp|mv|echo|cat|rm|ln)\b/.test(c)) { return { rule: 'B3', detail: 'separators ' + (sepCount + 1) + ' commands' }; } return null; } function buildMessage(rule, detail, sid) { const head = '[AGENT_ISOLATION · ' + rule + '] 检测到批量操作: ' + detail; let body; switch (rule) { case 'B1': case 'B2': case 'B3': body = '建议: 大批量 Bash 操作 (循环/链式) 输出会全部进入主上下文. 建议改写为脚本文件 + 单次执行, 或委托 Agent(general-purpose) 在隔离上下文中跑.'; break; case 'W1': body = '建议: 同会话短期内多次 Write/Edit 已触发 R1 切片阈值. 后续若仍有 ≥3 个新文件待写, 改派 Agent(general-purpose) 子进程隔离生成, 主程仅取关键路径回执.'; break; default: body = '建议: 改用 Agent 隔离重型操作.'; } return head + '\n' + body; } function isThrottled(sessionState, ruleKey, now) { const last = sessionState.lastFires && sessionState.lastFires[ruleKey]; return last && (now - last) < THROTTLE_MS; } function markFired(sessionState, ruleKey, now) { if (!sessionState.lastFires) sessionState.lastFires = {}; sessionState.lastFires[ruleKey] = now; sessionState.lastTs = now; } (async () => { try { let hookData = {}; try { hookData = await readStdin(); } catch {} const toolName = hookData.tool_name || ''; const sid = hookData.session_id || 'unknown'; const now = Date.now(); const state = pruneState(loadState()); const sessionState = state[sid] || {}; let triggered = null; if (toolName === 'Bash') { const cmd = hookData.tool_input && hookData.tool_input.command; triggered = detectBashRule(cmd); } else if (toolName === 'Write' || toolName === 'Edit') { // 滑窗计数 const ts = sessionState.writeTs || []; const recent = ts.filter(t => now - t < W_WINDOW_MS); recent.push(now); sessionState.writeTs = recent.slice(-50); // 最多保留 50 个时间戳 if (recent.length >= W_THRESHOLD) { triggered = { rule: 'W1', detail: recent.length + ' Write/Edit / ' + (W_WINDOW_MS / 1000) + 's' }; } } if (!triggered) { // 仍要保存 writeTs 计数 state[sid] = sessionState; sessionState.lastTs = now; saveState(state); process.exit(0); } if (isThrottled(sessionState, triggered.rule, now)) { state[sid] = sessionState; sessionState.lastTs = now; saveState(state); process.exit(0); } markFired(sessionState, triggered.rule, now); state[sid] = sessionState; saveState(state); const msg = buildMessage(triggered.rule, triggered.detail, sid); // PreToolUse 不阻断 (continue:true), 仅 systemMessage 提示 process.stdout.write(JSON.stringify({ continue: true, suppressOutput: false, systemMessage: msg })); process.exit(0); } catch { process.exit(0); } })();