Ctrl K
ComunidadGuías…

agents/claudePretoolGate.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Claude Code `PreToolUse` hook source — written into `ctx.tmpdir` at runtime
 * and registered via a tmpdir-scoped `settings.json` referenced by
 * `--settings <path>` (see action/agents/claude.ts).
 *
 * Closes the subagent → state-mutating MCP tool path that motivated the
 * 2026-05-18 zed-industries/cloud incident (`reviewfrog` lens called
 * `checkout_pr` mid-review and the orchestrator's next push clobbered an
 * unrelated branch). Pairs with the `tool.execute.before` hook in
 * action/agents/opencodePlugin.ts; both runtimes share the deny list at
 * action/agents/subagentToolGates.ts.
 *
 * PreToolUse hook contract (verified against yasasbanukaofficial/claude-code
 * `src/utils/hooks/hooksConfigManager.ts` and `src/utils/hooks.ts`):
 *   - stdin: JSON with `hook_event_name: "PreToolUse"`, `tool_name`,
 *     `tool_input`, `tool_use_id`, `session_id`, `cwd`, `transcript_path`,
 *     and crucially `agent_id` / `agent_type` populated when the call
 *     originates from a subagent (set by the SDK when a Task/Agent
 *     dispatches a tool — see `createBaseHookInput` in claude-code source).
 *   - exit 0 → allow, no output shown
 *   - exit 2 → block tool call AND show stderr to model (this is the path
 *     we want for the deny case — the subagent gets a clear refusal it can
 *     reason about and pick a different action)
 *   - other → show stderr to user only, continue with tool call
 *
 * The hook itself is intentionally tiny: stdin → JSON → check `agent_id`
 * presence + `tool_name` against the deny list → exit 0 or 2. No deps.
 *
 * Why the script source is a string template, not a separate `.ts` file
 * shipped with the action: the action runs as a published npm package; at
 * install time we don't have the source on disk in a stable place. Embedding
 * the source into `dist/main.mjs` and writing it out per-run keeps the path
 * inside `ctx.tmpdir` (where `--settings` can find it) and survives bundle
 * minification.
 */

/**
 * Source written to `<ctx.tmpdir>/pullfrog-pretool-gate.mjs`. Plain ESM,
 * no TypeScript, no dependencies — node executes it directly via the
 * `#!/usr/bin/env node` shebang and the executable bit set by the harness.
 */
export const CLAUDE_PRETOOL_GATE_FILENAME = "pullfrog-pretool-gate.mjs" as const;

/** the Stop-hook script and the flag `--settings` JSON, both written into the
 * per-run tmpdir alongside the gate. named here so the hook-asset seal
 * (nativeFsDenies.ts) and the writers in claude.ts share one source. */
export const CLAUDE_STOP_HOOK_FILENAME = "pullfrog-stop-hook.sh" as const;
export const CLAUDE_SETTINGS_FILENAME = "pullfrog-claude-settings.json" as const;

/**
 * Build the gate script source, embedding the run's derived denied-tool set
 * (see `subagentDeniedToolNames` in action/agents/subagentToolGates.ts).
 */
export function buildClaudePretoolGateSource(deniedTools: readonly string[]): string {
  return `#!/usr/bin/env node
// AUTOGENERATED by Pullfrog. PreToolUse hook that hard-blocks state-mutating
// MCP tool calls from subagents. See action/agents/claudePretoolGate.ts.

const SUBAGENT_DENIED_TOOLS = new Set(${JSON.stringify(deniedTools)});

function stripMcpPrefix(toolName) {
  if (toolName.startsWith("mcp__pullfrog__")) return toolName.slice("mcp__pullfrog__".length);
  return toolName;
}

let stdin = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
  stdin += chunk;
});
process.stdin.on("end", () => {
  let payload;
  try {
    payload = JSON.parse(stdin);
  } catch {
    // malformed input — exit non-blocking so the run continues. user-only
    // stderr per the hook contract.
    process.stderr.write("pullfrog-pretool-gate: malformed stdin JSON\\n");
    process.exit(1);
  }
  const toolName = typeof payload?.tool_name === "string" ? payload.tool_name : "";
  // claude-code populates agent_id whenever a tool call originates inside
  // a Task/Agent subagent dispatch (createBaseHookInput in claude-code
  // source). on the orchestrator's main thread agent_id is undefined.
  // agent_type can be set on the orchestrator itself via --agent, so it's
  // not a reliable subagent discriminator on its own; agent_id is.
  // contract verified against @anthropic-ai/claude-code 2.1.112 (pinned in
  // action/package.json); re-verify createBaseHookInput if that bumps — if
  // agent_id ever stops being populated the gate fails OPEN for subagents.
  const agentId = typeof payload?.agent_id === "string" ? payload.agent_id : "";
  if (!agentId) process.exit(0);
  const bare = stripMcpPrefix(toolName);
  if (!SUBAGENT_DENIED_TOOLS.has(bare)) process.exit(0);
  process.stderr.write(
    "subagent attempted to call denied tool '" + bare + "'. " +
      "subagents share the orchestrator's in-process working tree and toolState; " +
      "state-changing MCP tools (checkout_pr, push_branch, create_pull_request_review, " +
      "report_progress, etc.) are reserved for the orchestrator. " +
      "report findings back to the orchestrator and let it perform the mutation.\\n"
  );
  process.exit(2);
});
`;
}

/**
 * Settings JSON shape registered via `claude --settings <path>`. The
 * matcher `^mcp__pullfrog__` is treated as a regex by claude-code's
 * `matchesPattern` helper (anything outside `[a-zA-Z0-9_|]` triggers the
 * regex branch — verified in src/utils/hooks.ts), so this anchors at the
 * start of the tool name and fires for every Pullfrog MCP tool. We narrow
 * inside the script itself rather than declaring per-tool matchers because
 * the deny list is the source of truth.
 *
 * The hook process inherits the parent's PATH, so `node` resolves to the
 * runner's node binary; the `--settings` flag accepts either a path or a
 * literal JSON string per claude-code source `src/main.tsx` (`Path to a
 * settings JSON file or a JSON string`), but we use a path so the script
 * and its config sit side-by-side under `ctx.tmpdir`.
 *
 * `denyRules` are the native tools to deny (`CLAUDE_DENIED_TOOLS` + their
 * `Agent(...)` forms) at a settings-source rule — the authoritative,
 * bypass-immune layer. `--disallowedTools` alone (a `cliArg` deny) was observed
 * to leak under `--dangerously-skip-permissions`, so the deny is carried here
 * too. The single consumer is `buildClaudeSettings`, which
 * spreads `hooks` and folds `permissions.deny` into its richer deny list; that
 * one object is then written to BOTH the `--settings` flag and the `/etc`
 * managed file, which is what keeps the two surfaces from drifting (#1179).
 */
export function buildClaudePretoolGateSettings(
  scriptAbsolutePath: string,
  denyRules: string[]
): {
  hooks: {
    PreToolUse: Array<{
      matcher: string;
      hooks: Array<{ type: "command"; command: string; timeout?: number }>;
    }>;
  };
  permissions: { deny: string[] };
} {
  return {
    hooks: {
      PreToolUse: [
        {
          matcher: "^mcp__pullfrog__",
          hooks: [
            {
              type: "command",
              // shell-quote-safe because tmpdir paths created via
              // node:fs.mkdtempSync don't contain spaces, but pass via a
              // literal that node parses as a single argv entry to be
              // defensive against future tmpdir layout changes.
              command: `node ${JSON.stringify(scriptAbsolutePath)}`,
              timeout: 5,
            },
          ],
        },
      ],
    },
    permissions: { deny: denyRules },
  };
}