src/server/hooks.ts
Rama main · réplica de lectura del repositorio original.
/**
* SDK hooks — PreToolUse / PostToolUse callbacks wired into query().
*
* Hooks intercept tool calls inside the Claude Code CLI subprocess.
* Unlike `canUseTool` (permission gate), hooks can modify input, inject
* context, and guide model behavior through denial reasons.
*/
import type { HookCallback, HookCallbackMatcher, HookEvent, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { rewriteSlackMentions } from './slack/mention-rewrite.ts';
import { PROTECTED_DATA_MESSAGE, writesProtectedData } from './security/enforce.ts';
import { getOffload, type OffloadSettings } from './shraga-config.ts';
import { heavyCommandDenyMessage, heavyLocalCommand } from './offload.ts';
import { wholeDiskSearch, wholeDiskSearchMessage } from './disk-search-guard.ts';
import { DATA_DIR } from './paths.ts';
/** Patterns that indicate a long-running script the model should background. */
const LONG_RUNNING_PATTERNS = [
/\bdata\/scripts\//,
];
/** Instant scripts under those paths whose output the user is waiting on (a 2FA code must not go async). */
const INSTANT_SCRIPT_PATTERNS = [
/\bdata\/scripts\/totp\.ts\b/,
];
/**
* Deny foreground Bash calls matching known long-running patterns.
* The denial reason tells the model to retry with `run_in_background: true`,
* which lets the CLI manage the process asynchronously (the model can
* continue working and gets notified when the command finishes).
*/
const forceBackgroundForScripts = (exemptCommand?: string, durableJobs = false): HookCallback => async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input } = input as PreToolUseHookInput;
if (tool_name !== 'Bash') return {};
const ti = tool_input as Record<string, unknown>;
const cmd = ti.command as string | undefined;
if (!cmd || ti.run_in_background) return {};
// The turn EXISTS to run this one command (a `bash` schedule): there is no other work to
// continue with, and its exit code is the run's verdict. Backgrounding it only hides that
// exit code behind a task id, so the guard must not fire on it.
if (exemptCommand && cmd === exemptCommand) return {};
const isLongRunning = LONG_RUNNING_PATTERNS.some(p => p.test(cmd)) && !INSTANT_SCRIPT_PATTERNS.some(p => p.test(cmd));
if (!isLongRunning) return {};
console.log(`[hooks] Denying foreground Bash for long-running script: ${cmd.slice(0, 120)}`);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: durableJobs
? `This script may take several minutes. Start it with ${JOB_START} instead — it survives this turn and the session is woken when it exits.`
: 'This script may take several minutes. Use run_in_background: true so you can continue working while it runs.',
},
};
};
const JOB_START = 'mcp__jobs__job_start';
/**
* Where durable jobs are mounted, refuse native `run_in_background`. The CLI holds the turn open for
* such a task only up to the bg-wait budget (claude-code.ts BG_TASK_MAX_WAIT_MS, 15 min), then exits
* and the task dies with it. On 2026-09-24 that SIGTERMed a social publish run mid-LinkedIn after
* X and @livshitz_ had posted. `job_start` runs the same command server-side, detached, with wake-on-exit.
*/
const denyNativeBackground: HookCallback = async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input } = input as PreToolUseHookInput;
if (tool_name !== 'Bash' || !(tool_input as Record<string, unknown>)?.run_in_background) return {};
console.log('[hooks] Denied native run_in_background — steering to job_start');
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: `run_in_background dies when this turn ends (15-min cap). Run the same command with ${JOB_START} — it outlives the turn and the session is woken when it exits.`,
},
};
};
/**
* Text fields per Slack MCP tool that may carry `@name` prose the agent typed.
* (Not `post_slack_poll` — its options are structured labels, not mention prose.)
*/
const SLACK_MENTION_FIELDS: Record<string, string[]> = {
post_slack_message: ['text'],
post_slack_update: ['text'],
post_slack_reply: ['text'],
post_slack_files: ['initial_comment'],
};
/**
* Rewrite human-readable `@name (operator)` / `@name` display strings in
* outbound Slack text back into real `<@U…>` mention tokens, so mentions the
* agent typed from its readable context actually fire a notification.
* Closes the inbound/outbound asymmetry (resolveUserMentions has no reverse).
*/
const resolveSlackMentions: HookCallback = async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input } = input as PreToolUseHookInput;
const m = /^mcp__mcp-slack-use__(post_slack_\w+)$/.exec(tool_name);
if (!m) return {};
const fields = SLACK_MENTION_FIELDS[m[1]];
if (!fields) return {};
const ti = { ...(tool_input as Record<string, unknown>) };
let changed = false;
for (const f of fields) {
const val = ti[f];
if (typeof val !== 'string') continue;
const r = rewriteSlackMentions(val);
if (r.changed) { ti[f] = r.text; changed = true; }
}
if (!changed) return {};
console.log(`[hooks] Resolved @name display strings to <@U…> mention tokens in ${tool_name}`);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
updatedInput: ti,
},
};
};
/**
* Guardrail for the AGF Firebase RTDB MCPs. The `agf-rtdb` skill's rules only bind
* the model when that skill is loaded — but it auto-injects only on a trigger match,
* so a query phrased without the trigger vocabulary (e.g. "the currently alive room")
* leaves the model flying blind. It then does the exact thing Rule #1 forbids:
* `get_db path=agf` (or `agf/session`) → a whole-DB read that errors `too_big` / hangs.
* This deny is a HARD backstop independent of phrasing or the model remembering: block
* the two dangerous shapes and hand back the corrective recipe + a pointer to the skill.
*/
const FIREBASE_HUGE_PATHS = new Set(['', 'agf', 'agf/session']);
const norm = (p: unknown) => String(p ?? '').trim().replace(/^\/+|\/+$/g, '');
const guardFirebaseReads: HookCallback = async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input } = input as PreToolUseHookInput;
const m = /^mcp__mcp-firebase-(?:prod|lab)__(get_db|get_db_keys|get_db_query)$/.exec(tool_name);
if (!m) return {};
const method = m[1];
const ti = tool_input as Record<string, unknown>;
const path = norm(ti.path);
let reason: string | null = null;
// Rule #1: full-value read of a HUGE node. (get_db_keys is keys-only → safe even on agf.)
if (method === 'get_db' && FIREBASE_HUGE_PATHS.has(path)) {
reason = `Blocked: get_db on "${path || '/'}" reads the whole node — it errors too_big / hangs (agf-rtdb skill Rule #1: never read /agf or /agf/session directly). ` +
`For the currently-active session(s): get_db_query path=agf/session orderBy="timing/status" equalTo="Active" limitToFirst=10 (status is Capitalized + case-sensitive). ` +
`Then drill into small sub-paths (…/timing, …/participantsActivity, …/speakers) — never the full session node. Read data/skills/agf-rtdb.md for the full recipes.`;
} else if (method === 'get_db_query' && !norm(ti.orderBy)) {
// Rule #2: query without orderBy pulls the whole node first.
reason = `Blocked: get_db_query without orderBy pulls the entire node before filtering (agf-rtdb skill Rule #2). ` +
`Add an INDEXED orderBy: on agf/session use "timing/status" or "timing/startTime"; any push-keyed list uses "$key". Read data/skills/agf-rtdb.md for indexed fields.`;
}
if (!reason) return {};
console.log(`[hooks] Firebase guardrail blocked ${tool_name} path="${path}" — steering to agf-rtdb skill`);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: reason,
},
};
};
/** Tamper protection: no agent file tool writes server-owned data (audit, conversations, sessions, security, keys,
* MCP config) — any profile, SECURITY_ENFORCE on or off. A hook, not canUseTool: the SDK auto-approves allowed/
* accept-edits tools without calling canUseTool. See security/enforce.ts PROTECTED_DATA_WRITE. */
const denyProtectedDataWrites: HookCallback = async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input, cwd } = input as PreToolUseHookInput;
if (!writesProtectedData(tool_name, (tool_input ?? {}) as Record<string, unknown>, cwd || undefined)) return {};
console.log(`[hooks] Denied ${tool_name} on protected data path`);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: PROTECTED_DATA_MESSAGE,
},
};
};
/** Offloading box only: refuse clearly heavy local media commands (ffmpeg encodes, remotion renders, media servers). */
export const denyHeavyLocalMedia = (cfg: OffloadSettings): HookCallback => async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input } = input as PreToolUseHookInput;
if (tool_name !== 'Bash') return {};
const what = heavyLocalCommand(String((tool_input as Record<string, unknown>)?.command ?? ''));
if (!what) return {};
console.log(`[hooks] Denied Bash: ${what} (offload is configured)`);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: heavyCommandDenyMessage(what, cfg),
},
};
};
/** Refuse searches rooted at / or the bare home dir (Bash find/grep -r/mdfind, or the Grep/Glob tools' `path`). */
export const denyWholeDiskSearch: HookCallback = async (input) => {
if (input.hook_event_name !== 'PreToolUse') return {};
const { tool_name, tool_input } = input as PreToolUseHookInput;
const ti = (tool_input ?? {}) as Record<string, unknown>;
const what = tool_name === 'Bash' ? wholeDiskSearch(String(ti.command ?? ''))
: typeof ti.path === 'string' && wholeDiskSearch(`find ${ti.path}`) ? `${tool_name} ${ti.path}` : null;
if (!what) return {};
console.log(`[hooks] Denied whole-disk search: ${what}`);
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse' as const,
permissionDecision: 'deny' as const,
permissionDecisionReason: wholeDiskSearchMessage(what, DATA_DIR),
},
};
};
/** Build the hooks map to pass into SDK query() options.
* `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
* (a `bash` schedule's command) — see forceBackgroundForScripts. */
export function buildHooks(opts?: { exemptBashCommand?: string; offload?: OffloadSettings; durableJobs?: boolean }): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
const offload = opts && 'offload' in opts ? opts.offload : getOffload();
return {
PreToolUse: [
{ matcher: 'Bash|Grep|Glob', hooks: [denyWholeDiskSearch] },
...(offload ? [{ matcher: 'Bash', hooks: [denyHeavyLocalMedia(offload)] }] : []),
{ matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand, opts?.durableJobs), ...(opts?.durableJobs ? [denyNativeBackground] : [])] },
{ matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
{ matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
{ matcher: 'Write|Edit|MultiEdit|NotebookEdit', hooks: [denyProtectedDataWrites] },
],
};
}