194 lines
6.6 KiB
JavaScript
194 lines
6.6 KiB
JavaScript
|
|
#!/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 (<<EOF...EOF), heredoc 由调用侧的动词正则间接限制
|
||
|
|
return s.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
||
|
|
}
|
||
|
|
|
||
|
|
function detectBashRule(cmd) {
|
||
|
|
if (!cmd || typeof cmd !== 'string') return null;
|
||
|
|
const c = cmd.trim();
|
||
|
|
// B1) for X in A B C D E F ...
|
||
|
|
const forMatch = c.match(/\bfor\s+\w+\s+in\s+([^;]+?)(;|\s+do\b)/);
|
||
|
|
if (forMatch) {
|
||
|
|
const items = forMatch[1].trim().split(/\s+/).filter(Boolean);
|
||
|
|
if (items.length >= 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);
|
||
|
|
}
|
||
|
|
})();
|