/** * 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; 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)?.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 = { 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) }; 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; 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, 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)?.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; 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> { 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] }, ], }; }