agents/claudePretoolGate.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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 },
};
}