Ctrl K
ComunidadGuías…

src/server/hooks.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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] },
    ],
  };
}