agents/claude.ts
Rama main · réplica de lectura del repositorio original.
/**
* Claude Code agent — secure harness around the `claude` CLI.
*
* mirrors the opencode harness's security model:
* - native exec tools (Bash, PowerShell, Monitor, REPL, Workflow) and the
* claude.ai egress tools (`CLAUDE_DENIED_TOOLS`) blocked via BOTH
* --disallowedTools AND managed-settings.json `permissions.deny` (the agent
* cannot shell out / run code outside the MCP shell). the managed-settings
* deny is the authoritative, bypass-immune layer: `--disallowedTools` alone
* (a `cliArg`-source deny) was observed to leak under
* `--dangerously-skip-permissions`, surfacing a secret env marker via the
* native Bash tool. managed-settings denies are `policySettings`-source,
* highest precedence, and survive bypassPermissions mode.
* - managed-settings.json: filesystem sandbox — deny /proc, /sys reads
* - MCP ShellTool provides restricted shell (filtered env, no secrets)
* - MCP server injected via --mcp-config (not replacing project config)
* - ASKPASS handles git auth separately (token never in subprocess env)
*
* the agent process itself gets full env (needs LLM API keys, PATH, etc.).
* security is enforced at the tool layer, not the process layer.
*/
import { execFileSync } from "node:child_process";
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { pullfrogMcpName } from "../external.ts";
import {
BEDROCK_MODEL_ID_ENV,
isVertexAnthropicId,
modelAliases,
stripProviderPrefix,
VERTEX_MODEL_ID_ENV,
} from "../models.ts";
import { AGENT_ACTIVITY_TIMEOUT_MS, getIdleMs, markActivity } from "../utils/activity.ts";
import { preflightClaudeSubscription } from "../utils/claudeSubscription.ts";
import { formatJsonValue, log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { findProviderErrorMatch } from "../utils/providerErrors.ts";
import { resolveRunEffort } from "../utils/runEffort.ts";
import { trustedBinary } from "../utils/sandbox.ts";
import { addSkill, installBundledSkills } from "../utils/skills.ts";
import {
DEFAULT_MAX_RETAINED_BYTES,
SPAWN_ACTIVITY_TIMEOUT_CODE,
SpawnTimeoutError,
spawn,
TailBuffer,
} from "../utils/subprocess.ts";
import { ThinkingTimer } from "../utils/timer.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { applyClaudeVertexEnv } from "../utils/vertex.ts";
import {
buildClaudePretoolGateSettings,
buildClaudePretoolGateSource,
CLAUDE_PRETOOL_GATE_FILENAME,
CLAUDE_SETTINGS_FILENAME,
CLAUDE_STOP_HOOK_FILENAME,
} from "./claudePretoolGate.ts";
import { startGateServer } from "./gateServer.ts";
import {
AGENT_BIN_DIR_RELPATH,
CLAUDE_HOOK_ASSET_RELPATHS,
GIT_NATIVE_READ_DENY_CLAUDE,
GIT_NATIVE_WRITE_DENY_CLAUDE,
} from "./nativeFsDenies.ts";
import { finalizeAgentResult } from "./postRun.ts";
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
import {
type AgentResult,
type AgentRunContext,
type AgentUsage,
agent,
logTokenTable,
MAX_STDERR_LINES,
} from "./shared.ts";
async function installClaudeCli(): Promise<string> {
return await installFromNpmTarball({
packageName: "@anthropic-ai/claude-code",
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
// 2.1.113+ ships a native binary (bin/claude.exe) instead of cli.js; the
// package postinstall copies it from the platform optionalDependency, so we
// need installDependencies to run that postinstall.
executablePath: "bin/claude.exe",
installDependencies: true,
});
}
/**
* Native claude-code tools that execute arbitrary shell/code and therefore
* bypass Pullfrog's security boundary (the restricted MCP `shell` tool with a
* filtered, secret-free env). These run inside the agent process with full env,
* so leaving any of them enabled defeats both `shell: "disabled"` AND the
* env-filtering that the MCP shell relies on even when shell is enabled.
*
* As of claude-code 2.1.150 the exec surface is no longer just `Bash`:
* - `Monitor` runs a shell command/script (the `command` field)
* - `REPL` runs arbitrary JavaScript (can `require("node:child_process")`)
* - `Workflow` orchestrates subagents/pipelines that can reach the above
* (registered by default from 2.1.280)
* - `PowerShell` is a native shell like `Bash`: on by default on Windows
* runners, opt-in elsewhere via `CLAUDE_CODE_USE_POWERSHELL_TOOL=1`
* Each is denied at top level and inside `Agent(...)` (Task subagents), mirroring
* the existing `Bash` / `Agent(Bash)` pair. Denying a tool that isn't registered
* in a given run is a harmless no-op, so this list is also forward-safe.
* Denying `Bash` also keeps `Glob` / `Grep` registered: native builds with
* embedded search drop them in favor of search inside `Bash` (2.1.162+), and
* bring them back only when `Bash` is unavailable.
*
* `CLAUDE_DENY_RULES` is wired into TWO surfaces: `--disallowedTools`
* (removes the tools from the advertised list) and managed-settings.json
* `permissions.deny` (the authoritative, bypass-immune deny — see
* buildManagedSettings). The flag alone proved insufficient: under
* `--dangerously-skip-permissions` the native Bash tool ran despite
* `--disallowedTools Bash`, leaking a per-run secret marker.
*/
const CLAUDE_EXEC_TOOLS = ["Bash", "PowerShell", "Monitor", "REPL", "Workflow"] as const;
/** not exec, but each reaches off the runner under a claude.ai login: `DesignSync`, `Artifact` and
* `ShareOnboardingGuide` upload local files, outside the `Read` denies; `SendMessage`/`ListAgents`,
* `PushNotification` and `RemoteTrigger` reach the account's other sessions, devices and routines.
* the session schedulers can only keep a one-shot `claude -p` alive past its final result, into the
* activity watchdog (#1583). */
const CLAUDE_DENIED_TOOLS = [
...CLAUDE_EXEC_TOOLS,
"DesignSync",
"Artifact",
"ShareOnboardingGuide",
"SendMessage",
"ListAgents",
"PushNotification",
"RemoteTrigger",
"ScheduleWakeup",
"CronCreate",
"CronDelete",
"CronList",
];
const CLAUDE_DENY_RULES = [
...CLAUDE_DENIED_TOOLS,
...CLAUDE_DENIED_TOOLS.map((t) => `Agent(${t})`),
];
const CLAUDE_DISALLOWED_TOOLS = CLAUDE_DENY_RULES.join(",");
// ── config ─────────────────────────────────────────────────────────────────────
function writeMcpConfig(ctx: AgentRunContext): string {
const configDir = join(ctx.tmpdir, ".claude");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
[pullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
},
})
);
return configPath;
}
/**
* Drop the PreToolUse gate script + its `--settings` JSON into the per-run
* tmpdir and return the absolute path to the settings file. The script
* blocks state-mutating MCP tool calls when `agent_id` is non-empty (i.e.,
* the call originates inside a Task/Agent subagent dispatch). See
* action/agents/claudePretoolGate.ts for the contract.
*
* Two paths register the gate:
* 1. flag settings (`--settings <path>`) — the only surface on non-CI runs
* (`pnpm play`, local dev), where `installManagedSettings` is a no-op,
* and the surviving one in CI when the `/etc` write fails.
* 2. managed settings (/etc/claude-code/managed-settings.json) — needed in
* CI because `allowManagedHooksOnly: true` filters flag-settings hooks
* once it is in force. The same hook entry is embedded in
* `buildManagedSettings` below.
*
* The flag settings carry the WHOLE boundary (`buildClaudeSettings`), not just
* the gate: the native exec-tool deny, the `/proc` `/sys` `.git` and
* `secretDenyPaths` denies, the sandbox `denyRead` and the Stop hook. A
* settings-source deny holds under `--dangerously-skip-permissions` where the
* `--disallowedTools` cliArg deny proved leaky, so this is a real second
* surface rather than a courtesy copy — and it is what keeps the boundary in
* force on a runner where the `/etc` write fails (#1179).
*/
function writePretoolGateAssets(params: { ctx: AgentRunContext; stopHookPath: string }): {
scriptPath: string;
settingsPath: string;
} {
const scriptPath = join(params.ctx.tmpdir, CLAUDE_PRETOOL_GATE_FILENAME);
writeFileSync(scriptPath, buildClaudePretoolGateSource(params.ctx.subagentDeniedTools));
chmodSync(scriptPath, 0o755);
const settingsPath = join(params.ctx.tmpdir, CLAUDE_SETTINGS_FILENAME);
const settings = buildClaudeSettings({
ctx: params.ctx,
stopHookPath: params.stopHookPath,
pretoolGateScriptPath: scriptPath,
});
writeFileSync(settingsPath, JSON.stringify(settings));
return { scriptPath, settingsPath };
}
/**
* Build the `--agents` JSON definition for the `reviewfrog` subagent.
*
* The Claude Code path always runs against an Anthropic model (see
* resolveAgent), so we hardcode the cheaper-sibling downshift: lenses run
* on Sonnet, the orchestrator stays on whatever model `--model` was passed.
*
* Per-call model override is also possible (Task tool's `model` arg accepts
* 'sonnet' | 'opus' | 'haiku') and takes precedence over what's set here —
* we don't pass it; the per-subagent `model` field is the right default.
*
* The non-mutative + non-recursive contract is enforced by the prose system
* prompt baked into the agent — see action/agents/reviewer.ts for why we
* no longer wire per-agent `disallowedTools` here.
*/
function buildAgentsJson(): string {
const agents = {
[REVIEWER_AGENT_NAME]: {
description:
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
prompt: REVIEWER_SYSTEM_PROMPT,
model: "claude-sonnet-5-5",
},
};
return JSON.stringify(agents);
}
// ── effort ────────────────────────────────────────────────────────────────────
// env var claude-code reads INSTEAD of `--effort` — it wins over the flag, over
// settings, and over the model default (`unset`/`auto` drop effort entirely).
const CLAUDE_EFFORT_ENV = "CLAUDE_CODE_EFFORT_LEVEL";
/**
* levels the pinned binary's `--effort` will accept, verbatim from
* `claude --help` on 2.1.321 and confirmed by probing each one on Opus 5.5.
* 2.1.150 exited 1 on anything else, before any API call; 2.1.280+ instead warns
* and silently runs the default effort — so this is still the last gate that
* stops a rung the CLI would not honor. rungs come from models.dev, a different
* source from this enum, so the two are free to drift.
*
* `ultra` deliberately absent: the binary carries it internally (the request
* builder folds it to `max`) but `--effort ultra` is rejected as unknown.
* `ultracode` (CLI >= 2.1.203) parses on 2.1.321 but no model publishes it as a
* rung. reading the binary's strings will suggest otherwise; probe the flag.
*
* REVALIDATE ON EVERY claude-code BUMP.
*/
const CLAUDE_CODE_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
/** capability tokens claude-code gates `--effort` and adaptive thinking on. */
function effortCapabilities(levels: readonly string[]): string {
const topRungs = levels.filter((l) => l === "xhigh" || l === "max").map((l) => `${l}_effort`);
return ["effort", ...topRungs, "adaptive_thinking", "thinking"].join(",");
}
/**
* restore effort on the classic Bedrock/Vertex routes, where claude-code strips
* `--effort` and falls back to the legacy `thinking.budget_tokens` shape that
* Opus 4.7+ 400s on, for every model its pinned build doesn't hardcode.
*
* of the four slots the capability table walks, `ANTHROPIC_CUSTOM_MODEL_OPTION`
* is the one that doesn't also redefine what "opus"/"sonnet" mean for the rest
* of the session; the lookup is plain model-ID equality, so it needs
* `ANTHROPIC_MODEL` to name the same ID. callers scope this to IDs naming an
* alias we catalog, since over-claiming pushes a model back onto a shape it
* 400s on. operator-set values always win. see wiki/effort.md.
*/
function applyHostedEffortCapabilities(params: {
env: Record<string, string | undefined>;
modelId: string;
levels: readonly string[];
}): void {
params.env.ANTHROPIC_MODEL ||= params.modelId;
params.env.ANTHROPIC_CUSTOM_MODEL_OPTION ||= params.modelId;
params.env.ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES ||= effortCapabilities(
params.levels
);
}
// ── NDJSON event types ─────────────────────────────────────────────────────────
interface ContentBlock {
type: string;
text?: string;
id?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: string | unknown;
is_error?: boolean;
[key: string]: unknown;
}
// SDK schema (per claude-agent-sdk docs) puts `session_id` and
// `parent_tool_use_id` at the top level of every Assistant/User/System/Result
// message, not inside `message`. Subagent events carry a non-null
// `parent_tool_use_id` pointing at the orchestrator's Task/Agent tool_use id.
interface ClaudeSystemEvent {
type: "system";
session_id?: string;
parent_tool_use_id?: string | null;
[key: string]: unknown;
}
interface ClaudeAssistantEvent {
type: "assistant";
session_id?: string;
parent_tool_use_id?: string | null;
message?: {
role?: string;
content?: ContentBlock[];
model?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
interface ClaudeUserEvent {
type: "user";
session_id?: string;
parent_tool_use_id?: string | null;
message?: {
role?: string;
content?: ContentBlock[];
[key: string]: unknown;
};
[key: string]: unknown;
}
interface ClaudeResultEvent {
type: "result";
subtype?: string;
// claude CLI sets `is_error: true` (alongside `subtype: "success"`) when
// an upstream provider fails mid-stream. `api_error_status` carries the
// provider HTTP status (e.g. 401 for invalid API key). per the official
// SDK types, `api_error_status` is `number | null`, and the `error_*`
// subtypes carry their actionable payload in `errors: string[]` instead
// of `result`.
is_error?: boolean;
api_error_status?: number | null;
errors?: string[];
result?: string;
session_id?: string;
num_turns?: number;
total_cost_usd?: number;
total_input_tokens?: number;
total_output_tokens?: number;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
[key: string]: unknown;
}
// additional event types emitted by Claude CLI (handled as no-ops / debug)
interface ClaudeStreamEvent {
type: "stream_event";
[key: string]: unknown;
}
interface ClaudeToolProgressEvent {
type: "tool_progress";
[key: string]: unknown;
}
interface ClaudeToolUseSummaryEvent {
type: "tool_use_summary";
[key: string]: unknown;
}
interface ClaudeAuthStatusEvent {
type: "auth_status";
[key: string]: unknown;
}
type ClaudeEvent =
| ClaudeSystemEvent
| ClaudeAssistantEvent
| ClaudeUserEvent
| ClaudeResultEvent
| ClaudeStreamEvent
| ClaudeToolProgressEvent
| ClaudeToolUseSummaryEvent
| ClaudeAuthStatusEvent;
// ── runner ──────────────────────────────────────────────────────────────────────
type RunParams = {
label: string;
cmd: string;
args: string[];
cwd: string;
env: Record<string, string | undefined>;
todoTracker?: TodoTracker | undefined;
onActivityTimeout?: (() => void) | undefined;
onToolUse?: ((event: { toolName: string; input: unknown }) => void) | undefined;
};
type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
/**
* Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units,
* dropping any partial first line. used in the exit-non-zero stdout fallback
* so we never surface a truncated NDJSON event to operators —
* `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a
* syntactically broken JSON fragment. code units rather than bytes because
* `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8
* content the effective byte budget can be up to 4× the nominal limit.
*/
function tailLines(text: string, maxCodeUnits: number): string {
if (text.length <= maxCodeUnits) return text;
const tail = text.slice(-maxCodeUnits);
const firstNewline = tail.indexOf("\n");
// if no newline in window or it's at the very start, return as-is;
// otherwise drop the partial first line.
return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
}
export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
const startTime = performance.now();
let eventCount = 0;
// per-session labeler so parallel subagent log lines can be differentiated.
// claude-agent-sdk runs subagents inside the orchestrator's session — they
// share `session_id` — and stamps every subagent message with a non-null
// `parent_tool_use_id` pointing at the Agent tool_use that spawned them.
// we bind each Agent tool_use id to its dispatched label up front, then
// labelFor short-circuits to the direct mapping when parent_tool_use_id is
// set. orchestrator events (parent_tool_use_id === null) flow through the
// sessionID path and bind to ORCHESTRATOR_LABEL on first sighting.
const labeler = new SessionLabeler();
function eventLabel(event: { session_id?: string; parent_tool_use_id?: string | null }): string {
return labeler.labelFor(event.session_id ?? null, event.parent_tool_use_id ?? null);
}
function withLabel(label: string, message: string): string {
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
}
// one ThinkingTimer per session — sharing a single timer across sessions
// conflated cross-session interleaving as parent thinking time. each timer
// formats its log lines through the session label so attribution is visible.
const thinkingTimers = new Map<string, ThinkingTimer>();
function timerFor(label: string): ThinkingTimer {
let t = thinkingTimers.get(label);
if (!t) {
const formatLine = (line: string) =>
label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
t = new ThinkingTimer(formatLine);
thinkingTimers.set(label, t);
}
return t;
}
let finalOutput = "";
let sessionId: string | undefined;
let resultErrorSubtype: string | null = null;
// captures the structured error string from a result event with
// `is_error: true` (e.g. mid-stream provider auth failures the CLI
// surfaces as `subtype: "success"` synthetic-stop events, or the
// `errors[]` array from `error_*` subtypes). preferred over raw
// stdout/stderr in the exit-non-zero path so the GitHub Actions
// `##[error]` line shows the actionable message instead of an 8KB+
// NDJSON dump.
let lastResultError: string | null = null;
// set only for synthetic-stop `subtype: "success"` + `is_error: true`
// events, where `accumulatedTokens` from prior `assistant` events is
// stale and logging it would mislead operators into thinking billable
// tokens were spent on a successful turn. deliberately NOT set for
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
// because those runs genuinely consumed tokens and operators need
// billing visibility for them.
let syntheticStopFailure = false;
// a success result with nothing after it: the session is done, so an idle CLI is not a hang (#1583).
// one process can emit several results, and a later turn that stalls mid-stream still fails.
let settled = false;
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
// Claude CLI reports a single end-of-run `total_cost_usd` on the result
// event. per-message events don't carry cost, so there's nothing to sum —
// we just capture the final value when it arrives.
let accumulatedCostUsd = 0;
let tokensLogged = false;
function buildUsage(): AgentUsage | undefined {
const totalInput =
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
return totalInput > 0 || accumulatedTokens.output > 0
? {
agent: "claude",
inputTokens: totalInput,
outputTokens: accumulatedTokens.output,
cacheReadTokens: accumulatedTokens.cacheRead || undefined,
cacheWriteTokens: accumulatedTokens.cacheWrite || undefined,
costUsd: accumulatedCostUsd > 0 ? accumulatedCostUsd : undefined,
}
: undefined;
}
const handlers = {
system: (event: ClaudeSystemEvent) => {
// claude-agent-sdk only emits system:init for the top-level query, so
// this binds the orchestrator label and never appears in subagent flow.
// we still route through eventLabel so a subagent system event (if the
// SDK ever adds one) wouldn't go silently misattributed.
const label = eventLabel(event);
log.debug(withLabel(label, `» ${params.label} system event`));
},
assistant: (event: ClaudeAssistantEvent) => {
settled = false;
const content = event.message?.content;
if (!content) return;
const label = eventLabel(event);
const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
for (const block of content) {
if (block.type === "text" && block.text?.trim()) {
const message = block.text.trim();
log.box(message, { title: boxTitle });
// only the orchestrator's text becomes the run's "output" — subagent
// report-back text would otherwise clobber the parent's final answer.
if (label === ORCHESTRATOR_LABEL) {
finalOutput = message;
}
} else if (block.type === "tool_use") {
const toolName = block.name || "unknown";
// specialist reads cannot satisfy the primary reviewer's diff coverage.
if (params.onToolUse && label === ORCHESTRATOR_LABEL) {
params.onToolUse({
toolName,
input: block.input,
});
}
timerFor(label).markToolCall();
const inputFormatted = formatJsonValue(block.input || {});
const toolCallLine =
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
log.info(withLabel(label, toolCallLine));
// when the orchestrator dispatches a subagent, bind the Agent
// tool_use id to the dispatched label so future events carrying
// `parent_tool_use_id === block.id` resolve directly to the right
// lens. v2.1.63+ renamed the tool to "Agent"; older versions
// emitted "Task". match both for forward-compat.
if (
(toolName === "Task" || toolName === "Agent") &&
block.input &&
typeof block.input === "object"
) {
const taskInput = block.input as {
description?: string;
subagent_type?: string;
prompt?: string;
};
const dispatchedLabel = labeler.recordTaskDispatch(taskInput, block.id ?? null);
log.info(
withLabel(
label,
`» dispatching subagent: ${dispatchedLabel}` +
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
)
);
}
// agent's explicit MCP report_progress takes priority over todo tracking
if (toolName.includes("report_progress") && params.todoTracker) {
log.debug("» report_progress detected, disabling todo tracking");
params.todoTracker.cancel();
}
// parse TodoWrite events for live progress tracking. only honor the
// orchestrator's todos — subagents emit their own todo lists which
// would otherwise clobber the visible progress comment.
if (
toolName === "TodoWrite" &&
params.todoTracker?.enabled &&
label === ORCHESTRATOR_LABEL
) {
params.todoTracker.update(block.input);
}
}
}
// accumulate per-message usage if available. capture cache fields too
// so the fallback token table (used when no final `result` event fires)
// still reports the full breakdown instead of silently dropping cache.
const msgUsage = event.message?.usage;
if (msgUsage) {
accumulatedTokens.input += msgUsage.input_tokens || 0;
accumulatedTokens.output += msgUsage.output_tokens || 0;
accumulatedTokens.cacheRead += msgUsage.cache_read_input_tokens || 0;
accumulatedTokens.cacheWrite += msgUsage.cache_creation_input_tokens || 0;
}
},
user: (event: ClaudeUserEvent) => {
settled = false;
const content = event.message?.content;
if (!content) return;
const label = eventLabel(event);
for (const block of content) {
if (typeof block === "string") continue;
if (block.type === "tool_result") {
timerFor(label).markToolResult();
const outputContent =
typeof block.content === "string"
? block.content
: Array.isArray(block.content)
? (block.content as unknown[])
.map((entry: unknown) =>
typeof entry === "string"
? entry
: typeof entry === "object" && entry !== null && "text" in entry
? String((entry as { text: unknown }).text)
: JSON.stringify(entry)
)
.join("\n")
: String(block.content);
if (block.is_error) {
log.info(withLabel(label, `» tool error: ${outputContent}`));
} else {
log.debug(withLabel(label, `» tool output: ${outputContent}`));
}
}
}
},
result: (event: ClaudeResultEvent) => {
if (event.session_id) sessionId = event.session_id;
const subtype = event.subtype || "unknown";
const numTurns = event.num_turns || 0;
// claude CLI emits synthetic-stop result events with `subtype: "success"`
// but `is_error: true` when an upstream provider fails mid-stream (e.g.
// 401 from anthropic). short-circuit before the usage/token-table path
// so we don't log a usage table for a failed attempt and so downstream
// (`resultErrorSubtype` branch) surfaces the structured error. gated on
// `subtype === "success"` because the `error_*` subtypes also set
// `is_error: true` but carry their payload in `errors: string[]` and
// are handled by the dedicated branches below.
if (event.is_error === true && subtype === "success") {
const apiStatus = event.api_error_status;
lastResultError =
event.result?.trim() ||
`claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
resultErrorSubtype = subtype;
syntheticStopFailure = true;
log.info(
`» ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`
);
return;
}
if (subtype === "success") {
settled = true;
// extract detailed usage from result event (most accurate source).
// note: `input` here is non-cached input tokens only, matching the
// semantics of OpenCode's step_finish.tokens.input — the logTokenTable
// helper sums Input + Cache Read + Cache Write + Output into the Total
// column so consumers get the real billable figure.
const usage = event.usage;
const inputTokens = usage?.input_tokens || 0;
const cacheRead = usage?.cache_read_input_tokens || 0;
const cacheWrite = usage?.cache_creation_input_tokens || 0;
const outputTokens = usage?.output_tokens || 0;
// guard against NaN/Infinity from malformed CLI output poisoning the total
const costUsd =
typeof event.total_cost_usd === "number" && Number.isFinite(event.total_cost_usd)
? event.total_cost_usd
: 0;
accumulatedTokens = { input: inputTokens, output: outputTokens, cacheRead, cacheWrite };
accumulatedCostUsd = costUsd;
log.info(`» ${params.label} result: subtype=${subtype}, turns=${numTurns}`);
if (!tokensLogged) {
logTokenTable({
input: inputTokens,
cacheRead,
cacheWrite,
output: outputTokens,
costUsd,
});
tokensLogged = true;
}
} else if (subtype === "error_max_turns") {
resultErrorSubtype = subtype;
lastResultError = event.errors?.join("\n").trim() || null;
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
} else if (subtype === "error_during_execution") {
resultErrorSubtype = subtype;
lastResultError = event.errors?.join("\n").trim() || null;
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
} else if (subtype.startsWith("error")) {
resultErrorSubtype = subtype;
lastResultError = event.errors?.join("\n").trim() || null;
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
} else {
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
}
if (event.result?.trim()) {
finalOutput = event.result.trim();
}
},
// additional Claude CLI event types — debug-logged only
stream_event: () => {},
tool_progress: () => {},
tool_use_summary: () => {},
auth_status: () => {},
};
const recentStderr: string[] = [];
// ring buffer of recent non-JSON stdout lines. Claude CLI prints
// human-readable TTY chrome (status bubbles, quota notices, etc.)
// alongside the NDJSON event stream. when the CLI exits non-zero without
// emitting a structured error event, these lines are the only actionable
// signal — preferring them over the NDJSON tail keeps progress comments
// readable. issue #643.
const recentNonJsonStdout: string[] = [];
let lastProviderError: string | null = null;
// capped accumulator — see opencode.ts for rationale (issue #680).
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
let stdoutBuffer = "";
try {
const result = await spawn({
cmd: params.cmd,
args: params.args,
cwd: params.cwd,
env: params.env,
// flat agent idle budget — long synchronous MCP tool calls (issue #760)
// sit well under it, so no per-toolcall suspend bracketing is needed.
activityTimeout: AGENT_ACTIVITY_TIMEOUT_MS,
onActivityTimeout: params.onActivityTimeout,
stdio: ["ignore", "pipe", "pipe"],
// run claude in its own process group so SIGKILL on activity timeout /
// outer cancellation reaches any subprocesses it spawns (rg, file
// watchers, mcp transports, etc). claude (2.1.113+) is now a native
// binary like opencode-ai/bin/opencode, so detached + killGroup is
// required to avoid orphaning the binary and its children.
killGroup: true,
// claude already drains every chunk via onStdout (NDJSON parsing) and
// onStderr (recentStderr ring buffer). retaining a second copy in the
// spawn wrapper would grow unbounded for long sessions and previously
// crashed the wrapper with RangeError. see issue #680.
retain: "none",
onStdout: async (chunk) => {
const text = chunk.toString();
output.append(text);
markActivity();
stdoutBuffer += text;
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let event: ClaudeEvent;
try {
event = JSON.parse(trimmed) as ClaudeEvent;
} catch {
log.debug(`» non-JSON stdout line: ${trimmed.substring(0, 200)}`);
recentNonJsonStdout.push(trimmed);
if (recentNonJsonStdout.length > MAX_STDERR_LINES) recentNonJsonStdout.shift();
continue;
}
eventCount++;
log.debug(JSON.stringify(event, null, 2));
const timeSinceLastActivity = getIdleMs();
if (timeSinceLastActivity > 10000) {
log.info(
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s (${params.label} may be processing internally) (${eventCount} events processed so far)`
);
}
markActivity();
const handler = handlers[event.type as keyof typeof handlers];
if (!handler) {
log.debug(`» ${params.label} event (unhandled): type=${event.type}`);
continue;
}
try {
(handler as (e: ClaudeEvent) => void)(event);
} catch (err) {
log.info(
`» ${params.label} handler for type=${event.type} threw: ${err instanceof Error ? err.message : String(err)}`
);
}
}
},
onStderr: (chunk) => {
const trimmed = chunk.trim();
if (!trimmed) return;
recentStderr.push(trimmed);
if (recentStderr.length > MAX_STDERR_LINES) recentStderr.shift();
const match = findProviderErrorMatch(trimmed);
if (match) {
lastProviderError = match.label;
log.info(`» provider error detected (${match.label}): ${match.excerpt}`);
} else {
log.debug(trimmed);
}
},
});
if (result.exitCode === 0) {
await params.todoTracker?.flush();
} else {
params.todoTracker?.cancel();
}
const duration = performance.now() - startTime;
log.info(
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
);
if (eventCount === 0) {
const stderrContext = recentStderr.join("\n");
const diagnosis = lastProviderError
? `provider error: ${lastProviderError}`
: "unknown cause (no stdout events received)";
log.info(`» ${params.label} produced 0 events (${diagnosis})`);
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
}
// skip the fallback token table only for the synthetic-stop
// `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from
// prior `assistant` events is stale there and logging it would mislead
// operators into thinking billable tokens were spent on a successful turn.
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
// represent runs that genuinely consumed tokens, so they still get the
// table for billing visibility.
if (
!tokensLogged &&
!syntheticStopFailure &&
(accumulatedTokens.input > 0 ||
accumulatedTokens.output > 0 ||
accumulatedTokens.cacheRead > 0 ||
accumulatedTokens.cacheWrite > 0)
) {
logTokenTable({ ...accumulatedTokens, costUsd: accumulatedCostUsd });
tokensLogged = true;
}
const usage = buildUsage();
if (result.exitCode !== 0) {
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
// prefer the structured `lastResultError` (parsed from a result event
// with `is_error: true`) over raw stdout. raw stdout is the full NDJSON
// event stream — dumping it into a GitHub Actions `##[error]` line both
// hides the actionable provider message and pollutes the run log. cap
// the stdout fallback to the last 2KB so it stays readable when neither
// a structured error nor stderr is available.
//
// result.stdout / result.stderr are empty because we pass retain:"none"
// to spawn (see issue #680); the agent layer keeps its own bounded
// mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer).
const stdoutSnapshot = output.toString();
const stderrSnapshot = recentStderr.join("\n");
const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
// prefer non-JSON stdout (human-readable TTY chrome the CLI prints,
// including status bubbles and quota notices) over the raw NDJSON
// tail. when the CLI exits 1 without emitting `is_error` (issue #643),
// the NDJSON fallback would otherwise dump 2KB of `system/init` events
// into the progress comment with no mention of the actual cause.
const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n");
const errorMessage =
lastResultError ||
stderrSnapshot ||
nonJsonStdoutSnapshot ||
truncatedStdout ||
`unknown error - no output from Claude CLI${errorContext}`;
log.error(
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
);
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
return {
success: false,
output: finalOutput || stdoutSnapshot,
error: errorMessage,
usage,
sessionId,
};
}
if (eventCount === 0 && lastProviderError) {
return {
success: false,
output: finalOutput || output.toString(),
error: `provider error: ${lastProviderError}`,
usage,
sessionId,
};
}
if (resultErrorSubtype) {
return {
success: false,
output: finalOutput || output.toString(),
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
usage,
sessionId,
};
}
return { success: true, output: finalOutput || output.toString(), usage, sessionId };
} catch (error) {
params.todoTracker?.cancel();
const duration = performance.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
const isActivityTimeout =
error instanceof SpawnTimeoutError && error.code === SPAWN_ACTIVITY_TIMEOUT_CODE;
if (isActivityTimeout && settled && !resultErrorSubtype) {
log.info(`» ${params.label} idle after a successful result — treating as complete`);
return { success: true, output: finalOutput, usage: buildUsage(), sessionId };
}
const stderrContext = recentStderr.slice(-10).join("\n");
const diagnosis = lastProviderError
? `likely cause: ${lastProviderError}`
: eventCount === 0
? "Claude produced 0 stdout events - check if the API is reachable"
: `${eventCount} events were processed before the hang`;
log.info(
`» ${params.label} ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
);
log.info(`» diagnosis: ${diagnosis}`);
if (stderrContext)
log.info(
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
);
return {
success: false,
output: finalOutput || output.toString(),
error: `${errorMessage} [${diagnosis}]`,
usage: buildUsage(),
sessionId,
};
}
}
// ── managed settings ────────────────────────────────────────────────────────────
const MANAGED_SETTINGS_DIR = "/etc/claude-code";
const MANAGED_SETTINGS_PATH = `${MANAGED_SETTINGS_DIR}/managed-settings.json`;
// managed-settings.json has absolute highest precedence in Claude Code's config hierarchy.
// it cannot be overridden by user, project, or local settings — safe against malicious PRs.
//
// permissions.deny blocks native tools (Read, Grep, Edit, Glob) from accessing /proc and /sys,
// the git surfaces (blanket Edit(.git/**) write deny + narrow .git/config read deny — see
// nativeFsDenies.ts), and any path passed in via ctx.secretDenyPaths (codex auth dir, vertex
// creds dir, etc.).
// sandbox.filesystem.denyRead blocks the Bash tool sandbox from reading those paths.
// allowManagedPermissionRulesOnly prevents malicious PRs from adding allow rules that override
// our deny rules — safe in CI because --dangerously-skip-permissions makes allow/ask irrelevant.
// allowManagedHooksOnly prevents malicious project hooks from bypassing deny rules.
// Per Claude Code permissions docs, Read(...) deny ALSO blocks file-reading Bash commands
// (cat, head, tail, sed) and survives bypassPermissions mode. See wiki/security.md and
// wiki/codex-auth.md.
/**
* env var carrying the gate-server URL to the Claude subprocess. the Stop
* hook curls it on every stop; an absent value disables the hook (e.g.
* non-CI local dev paths that don't install managed settings either).
*/
const STOP_HOOK_GATE_URL_ENV = "PULLFROG_GATE_URL";
/**
* managed Stop hook. swaps the old `--resume <sessionId>` follow-up
* subprocesses (reflection + every gate retry — cost audit on PR #792
* showed reflection alone burned ~$0.85 / 111K cache_write per Opus run,
* almost all of it wasted re-running `getAttachmentMessages` in the fresh
* process) for a `{decision: "block", reason: ...}` injection inside the
* live `queryLoop`. existing session context is already in the prompt
* cache so only the new reason text is fresh cache_write.
*
* the script is intentionally minimal — all decision logic lives in the
* sidecar gate server (`gateServer.ts`), which reads live `ctx.toolState`
* mutations from the same process the MCP server runs in. budget +
* one-shot tracking lives there too, so re-fires across multiple stops in
* one session are safe. claude-code's 8-consecutive-block override is the
* last-line backstop.
*/
function buildStopHookScript(): string {
return [
"#!/usr/bin/env bash",
"set -euo pipefail",
`url="\${${STOP_HOOK_GATE_URL_ENV}:-}"`,
'if [ -z "$url" ]; then exit 0; fi',
"cat >/dev/null",
'response=$(curl -fsS --max-time 30 "$url" 2>/dev/null || printf \'{"block":false}\')',
'block=$(printf "%s" "$response" | jq -r ".block // false")',
'if [ "$block" != "true" ]; then exit 0; fi',
'reason=$(printf "%s" "$response" | jq -r ".reason // \\"\\"")',
'if [ -z "$reason" ]; then exit 0; fi',
'jq -n --arg reason "$reason" \'{decision: "block", reason: $reason}\'',
"",
].join("\n");
}
interface ManagedSettingsParams {
ctx: AgentRunContext;
stopHookPath: string | null;
pretoolGateScriptPath: string;
}
/**
* The permission boundary itself, minus the two `allowManaged*Only` policy
* flags that only mean anything in `/etc`. BOTH surfaces write this: the
* managed file and the `--settings` flag. They used to disagree — the native
* FS denies (`/proc`, `/sys`, `.git`, `secretDenyPaths`), the sandbox
* `denyRead` and the Stop hook lived ONLY in the managed file, so a runner
* without passwordless sudo silently ran with four of the seven rows absent
* and no Stop-hook gate at all (#1179). One builder, two writers.
*/
function buildClaudeSettings(params: ManagedSettingsParams): Record<string, unknown> {
const secretDenyPaths = params.ctx.secretDenyPaths ?? [];
const toolDeny = secretDenyPaths.flatMap((path) => [
`Read(${path}/**)`,
`Read(/${path}/**)`,
`Grep(${path}/**)`,
`Grep(/${path}/**)`,
`Edit(${path}/**)`,
`Edit(/${path}/**)`,
`Glob(${path}/**)`,
`Glob(/${path}/**)`,
]);
// write-deny the pullfrog hook assets so a prompt-injected agent cannot
// rewrite the gate/Stop hook to capture the env it runs with. exact absolute
// paths under tmpdir; dual leading-slash mirrors the secretDenyPaths form.
// reads stay allowed (the files are not secret; the write is the vector).
// canonical set: action/agents/nativeFsDenies.ts. the MCP shell is sealed
// separately by the read-only bind in action/mcp/shell.ts.
const hookDeny = CLAUDE_HOOK_ASSET_RELPATHS.flatMap((rel) => {
const abs = join(params.ctx.tmpdir, rel);
return [`Edit(${abs})`, `Edit(/${abs})`];
});
// first on the agent's PATH, so its own write tool must not plant there either
const agentBin = join(params.ctx.tmpdir, AGENT_BIN_DIR_RELPATH);
hookDeny.push(`Edit(${agentBin}/**)`, `Edit(/${agentBin}/**)`);
// single builder for both the PreToolUse gate hook and the native tool deny —
// both fields are consumed here (and identically in the flag-settings path via
// writePretoolGateAssets), keeping CLAUDE_DENY_RULES the single source.
const gate = buildClaudePretoolGateSettings(params.pretoolGateScriptPath, CLAUDE_DENY_RULES);
const base: Record<string, unknown> = {
permissions: {
deny: [
// CLAUDE_DENIED_TOOLS — the authoritative, bypass-immune deny.
// `--disallowedTools` (a cliArg-source deny) leaked under
// `--dangerously-skip-permissions`; policySettings denies survive
// bypassPermissions mode. covers top-level + Agent(...) subagent use.
...gate.permissions.deny,
"Read(//proc/**)",
"Read(//sys/**)",
"Grep(//proc/**)",
"Grep(//sys/**)",
"Edit(//proc/**)",
"Edit(//sys/**)",
"Glob(//proc/**)",
"Glob(//sys/**)",
// git surfaces — blanket Edit(.git/**) write deny (nothing legit
// writes .git via native tools; real commits go through MCP git tools
// outside this gate) + narrow Read/Grep/Glob(.git/config) read deny.
// mirrors opencode's edit-blanket / read-narrow split. canonical:
// action/agents/nativeFsDenies.ts.
...GIT_NATIVE_WRITE_DENY_CLAUDE,
...GIT_NATIVE_READ_DENY_CLAUDE,
...toolDeny,
...hookDeny,
],
},
sandbox: {
filesystem: {
denyRead: ["/proc", "/sys", ...secretDenyPaths],
},
},
};
// PreToolUse gate replicated into managed settings so it survives the
// `allowManagedHooksOnly: true` policy gate (see
// src/utils/hooks/hooksConfigSnapshot.ts in claude-code source). the Stop
// hook (gate-server retries) is layered into the same `hooks` object when
// present so both fire under managed settings.
const hooks: Record<string, unknown> = {
...gate.hooks,
};
if (params.stopHookPath) {
hooks.Stop = [
{
hooks: [{ type: "command", command: params.stopHookPath }],
},
];
}
base.hooks = hooks;
return base;
}
/**
* The `/etc` copy: the same boundary plus the two policy flags that make it
* authoritative — flag-settings hooks are filtered once `allowManagedHooksOnly`
* is in force, which is why the PreToolUse gate is replicated into both.
*/
function buildManagedSettings(params: ManagedSettingsParams): Record<string, unknown> {
return {
allowManagedPermissionRulesOnly: true,
allowManagedHooksOnly: true,
...buildClaudeSettings(params),
};
}
/**
* `/etc/claude-code` is privileged only because it is `/etc` — plenty of
* self-hosted images run the job as root, where the sudo shell-out is pure
* overhead and its password prompt is a gratuitous failure. try the plain
* write first, then sudo. see #1179.
*/
function installManagedSettings(params: ManagedSettingsParams): boolean {
if (process.env.CI !== "true") return false;
const content = JSON.stringify(buildManagedSettings(params), null, 2);
try {
mkdirSync(MANAGED_SETTINGS_DIR, { recursive: true });
writeFileSync(MANAGED_SETTINGS_PATH, content);
log.debug(`» wrote managed settings to ${MANAGED_SETTINGS_PATH}`);
return true;
} catch {
// not writable as this user — fall through to sudo.
}
try {
execFileSync(trustedBinary("sudo"), ["mkdir", "-p", MANAGED_SETTINGS_DIR]);
execFileSync(trustedBinary("sudo"), ["tee", MANAGED_SETTINGS_PATH], {
input: content,
stdio: ["pipe", "ignore", "pipe"],
});
log.debug(`» wrote managed settings to ${MANAGED_SETTINGS_PATH}`);
return true;
} catch (err) {
// the deny set and the Stop hook still reach the agent through
// `--settings`; what is lost here is the `allowManaged*Only` policy
// hardening, so say precisely that rather than implying no boundary.
log.warning(
`» failed to install managed settings (${err}) — the permission deny set and Stop-hook gate ` +
"still apply via --settings, but the managed-only policy hardening does not. grant the " +
"runner passwordless sudo, or make /etc/claude-code writable by the job user."
);
return false;
}
}
// ── agent ───────────────────────────────────────────────────────────────────────
/**
* The Anthropic model a claude-code run falls back to when nothing resolved
* one — the mirror of `autoSelectModel` on the opencode side. `resolveAgent`
* routes here for an account whose only Anthropic credential is a subscription
* and whose model was never pinned; without a pick the CLI would quietly choose
* its own, so the run persisted `WorkflowRun.model = null` and named no model in
* its footer. That blindness is exactly what let this path fail unnoticed.
*/
function autoSelectClaudeModel(): string | undefined {
return modelAliases.find(
(a) => a.provider === "anthropic" && a.preferred && !a.hidden && !a.fallback && !a.routing
)?.resolve;
}
export const claude = agent({
name: "claude",
install: installClaudeCli,
run: async (ctx) => {
const cliPath = await installClaudeCli();
const specifier = ctx.payload.proxyModel ?? ctx.resolvedModel ?? autoSelectClaudeModel();
// mirror opencode's writeback: `main.ts` could not know the fallback pick,
// so `toolState.model` (footers + the end-of-run PATCH that persists
// `WorkflowRun.model`) would otherwise report nothing for these runs.
if (specifier) ctx.toolState.model = specifier;
// claude-code on Bedrock takes the bare AWS model ID — no provider prefix
// to strip. agent selection already decides whether the model is Anthropic;
// the env-var sentinel identifies the backend after that decision.
const bedrockModelId = process.env[BEDROCK_MODEL_ID_ENV]?.trim();
const isBedrockRoute =
specifier !== undefined && bedrockModelId !== undefined && bedrockModelId === specifier;
const vertexModelId = process.env[VERTEX_MODEL_ID_ENV]?.trim();
const isVertexRoute =
specifier !== undefined &&
vertexModelId !== undefined &&
vertexModelId === specifier &&
isVertexAnthropicId(specifier);
const model = !specifier
? undefined
: isBedrockRoute
? specifier
: isVertexRoute
? undefined
: stripProviderPrefix(specifier);
const homeEnv = {
HOME: ctx.tmpdir,
XDG_CONFIG_HOME: join(ctx.tmpdir, ".config"),
};
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "claude"), { recursive: true });
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
addSkill({
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
skill: "agent-browser",
env: homeEnv,
agent: "claude-code",
});
installBundledSkills({ home: homeEnv.HOME });
const mcpConfigPath = writeMcpConfig(ctx);
// resolve effort against the model that actually runs: a fallback-selected
// run has no `ctx.resolvedModel`, so `resolveRunAlias` could not place it and
// the run silently dropped its configured effort. same reason the opencode
// harness passes its own auto-select pick here. see wiki/effort.md.
const effort = resolveRunEffort({ ...ctx, resolvedModel: specifier });
// the startup block prints before the fallback is picked, so it can only say
// "pending" for those runs. this is the first point the real level is known.
if (!ctx.resolvedModel && !ctx.payload.proxyModel) {
log.info(`» effort: ${effort.rung ?? "n/a (model has no effort control)"}`);
}
// PreToolUse gate that hard-blocks state-mutating MCP tool calls from
// subagents (the `agent_id` field is non-empty in the hook input only
// for subagent-originated calls — verified against
// yasasbanukaofficial/claude-code src/utils/hooks.ts createBaseHookInput).
// Wired via two surfaces so it fires in both CI and local (see
// writePretoolGateAssets / buildManagedSettings comments).
// reflection + every gate retry (dirty tree, unsubmitted review, summary
// stale) move from post-exit `--resume <sessionId>` subprocesses to a
// managed Stop hook that curls a sidecar gate server. see
// `buildStopHookScript` for the cost rationale (PR #792 audit) and
// `gateServer.ts` for the decision policy.
const stopHookPath = join(ctx.tmpdir, CLAUDE_STOP_HOOK_FILENAME);
writeFileSync(stopHookPath, buildStopHookScript(), { mode: 0o755 });
const pretoolGate = writePretoolGateAssets({ ctx, stopHookPath });
installManagedSettings({ ctx, stopHookPath, pretoolGateScriptPath: pretoolGate.scriptPath });
// base args shared between initial run and continue runs
const baseArgs = [
"--output-format",
"stream-json",
"--dangerously-skip-permissions",
"--mcp-config",
mcpConfigPath,
"--settings",
pretoolGate.settingsPath,
"--verbose",
"--disallowedTools",
CLAUDE_DISALLOWED_TOOLS,
"--agents",
buildAgentsJson(),
];
// an out-of-range level is a hard arg-parse failure before any API call, so
// a model with no ladder gets no flag at all rather than a guessed level.
// rungs are whatever models.dev publishes, which is a different source from
// this CLI's enum — so drop anything the pinned binary won't take rather
// than let a catalog change exit the run. revalidate CLAUDE_CODE_EFFORTS on
// every claude-code bump; the binary gates `ultra` and could add more.
if (effort.rung && !CLAUDE_CODE_EFFORTS.includes(effort.rung)) {
log.warning(`» effort ${effort.rung} not sent — claude-code doesn't accept that level`);
} else if (effort.rung) {
baseArgs.push("--effort", effort.rung);
}
if (model) {
baseArgs.push("--model", model);
}
// agent process gets full env — needs LLM API keys, PATH, locale, etc.
// security is enforced via managed-settings.json, --disallowedTools (CLAUDE_DENIED_TOOLS), and MCP tool filtering.
//
// bedrock route: claude-code reads `CLAUDE_CODE_USE_BEDROCK=1` to switch
// its provider implementation from the direct Anthropic API to Bedrock.
// AWS_BEARER_TOKEN_BEDROCK / AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY +
// AWS_REGION are already in process.env from the workflow's `env:` block.
// see https://docs.claude.com/en/docs/claude-code/amazon-bedrock.
//
// we only force CLAUDE_CODE_USE_BEDROCK=1 when this is a Pullfrog-routed
// bedrock run; if the user has set the env var manually for some other
// reason (e.g. always-Bedrock org policy), `...process.env` already
// carries it through and we don't disturb it.
const repoDir = process.cwd();
// PWD must match the spawn cwd (see opencode.ts for the analogous fix).
// claude-code 2.1.x reads `process.env.PWD` and registers it as a "session"
// additional-working-directory when it differs from `process.cwd()` (per
// the bundled cli.js — `let H=process.env.PWD; if(H && H !== Y7() && ...)
// j.set(H, {path: H, source: "session"})`). Inheriting harness PWD via
// `...process.env` ends up adding the wrong dir to the agent's allowed
// working set under `pnpm runtest` / `pnpm play`, which silently confuses
// path-relative tools.
const env: Record<string, string | undefined> = {
...process.env,
...homeEnv,
PWD: repoDir,
};
// Claude Code caps this at its detected model window: 200K stays 200K, while 1M uses 500K.
// Keep operator overrides; revalidate this when the pinned CLI or model windows change.
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW ||= "500000";
// newer models get no task tool by default; these two restore `TodoWrite`, which feeds the
// progress comment's task list (TaskCreate/TaskUpdate would need a second parser).
env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1";
env.CLAUDE_CODE_ENABLE_TASKS = "0";
// unset, first-party routes defer every MCP tool behind ToolSearch, a turn spent before the
// first Pullfrog call. `false` loads them up front, as gateway routes already do.
env.ENABLE_TOOL_SEARCH ||= "false";
if (isBedrockRoute) {
env.CLAUDE_CODE_USE_BEDROCK = "1";
}
if (isVertexRoute) {
applyClaudeVertexEnv(env);
env.ANTHROPIC_MODEL = specifier;
}
if ((isBedrockRoute || isVertexRoute) && specifier && effort.alias?.effort) {
applyHostedEffortCapabilities({ env, modelId: specifier, levels: effort.alias.effort });
}
// claude-code's `Vw()` resolver prefers ANTHROPIC_API_KEY over the OAuth
// token when both are set, so we strip the API key to fall through to the
// Max-subscription path. bedrock route uses AWS creds and is excluded.
//
// the strip is gated on a 1-token preflight: an exhausted (session/weekly
// limit) or revoked subscription would otherwise kill the run at its first
// model call with a working API key sitting unused in env.
//
// this preflight is UNCONDITIONAL, and deliberately so. Deciding it was
// redundant because `checkConfiguredCredentials` ran upstream means
// answering "did that check probe THIS token", and the answer is not the
// one it looks like: the upstream check probes only the configured model's
// env vars and is skipped entirely for proxy runs, so a proxy run, a
// non-Anthropic model under `PULLFROG_AGENT=claude`, and a run with no
// model at all all reach here unprobed. Guessing that wrong strips a live
// API key in favour of a token nothing verified — the exact failure this
// gate exists to prevent — and one 1-token call is far cheaper than being
// wrong about it. Upstream covers what this block never could (a
// subscription that is the ONLY credential, with no API key to fall back
// to); this covers the rest.
//
// the strip only means anything when the subscription is what the API key
// is actually beating. claude-code ranks credentials cloud provider (1) >
// `ANTHROPIC_AUTH_TOKEN` (2) > `ANTHROPIC_API_KEY` (3) >
// `CLAUDE_CODE_OAUTH_TOKEN` (5) — see
// https://code.claude.com/docs/en/authentication#authentication-precedence
// — so on a Bedrock or Vertex route, or with an auth token set, deleting the
// key hands the run to something ranked above the subscription while the log
// claims the subscription is paying. a gateway (`ANTHROPIC_BASE_URL`) is out
// for a different reason: the preflight asks api.anthropic.com about a token
// the gateway never issued, so a pass is no evidence it accepts it.
if (
env.CLAUDE_CODE_OAUTH_TOKEN &&
!isBedrockRoute &&
!isVertexRoute &&
!env.ANTHROPIC_BASE_URL &&
!env.ANTHROPIC_AUTH_TOKEN &&
env.ANTHROPIC_API_KEY
) {
const preflight = await preflightClaudeSubscription({
token: env.CLAUDE_CODE_OAUTH_TOKEN,
model,
});
if (preflight.usable) {
log.debug(
"» CLAUDE_CODE_OAUTH_TOKEN present — stripping ANTHROPIC_API_KEY from Claude Code env so the OAuth subscription is used"
);
delete env.ANTHROPIC_API_KEY;
} else {
log.info(
`» Claude subscription unusable (${preflight.reason}) — falling back to ANTHROPIC_API_KEY`
);
delete env.CLAUDE_CODE_OAUTH_TOKEN;
}
}
// read the FINAL env, after the strip above has settled which credential
// claude-code will actually pick. mirrors its documented ranking — cloud
// provider (1) > ANTHROPIC_AUTH_TOKEN (2) > ANTHROPIC_API_KEY (3) >
// CLAUDE_CODE_OAUTH_TOKEN (5) — so this says what PAID, not merely what was
// present. left unset when none is, rather than guessing.
//
// the cloud rung reads the ENV VARS and not `isBedrockRoute`/`isVertexRoute`:
// those two mean "Pullfrog routed this run", while a workflow may set
// CLAUDE_CODE_USE_* itself (an always-Bedrock org policy) and the block above
// deliberately passes that through untouched. claude-code ranks a cloud
// provider first either way, so keying on our own routing would file those
// runs under whatever ranks lower — the exact misattribution this records to
// prevent. FOUNDRY has no Pullfrog route at all and is reachable only this way.
if (env.CLAUDE_CODE_USE_BEDROCK) {
ctx.toolState.credential = "bedrock";
} else if (env.CLAUDE_CODE_USE_VERTEX) {
ctx.toolState.credential = "vertex";
} else if (env.CLAUDE_CODE_USE_FOUNDRY) {
ctx.toolState.credential = "foundry";
} else if (env.ANTHROPIC_AUTH_TOKEN) {
ctx.toolState.credential = "gateway";
} else if (env.ANTHROPIC_API_KEY) {
ctx.toolState.credential = "api_key";
} else if (env.CLAUDE_CODE_OAUTH_TOKEN) {
ctx.toolState.credential = "subscription";
}
// the effective level is already in the startup block; only the override is
// new information. we keep passing `--effort` rather than setting the env
// var, so a customer's own value wins — same rule as every other
// workflow-provided env — but a silent win would make the setting a lie.
const effortEnvOverride = env[CLAUDE_EFFORT_ENV]?.trim();
if (effortEnvOverride) {
log.warning(
`» ${CLAUDE_EFFORT_ENV}=${effortEnvOverride} in the run env overrides the effort setting for this session`
);
}
log.debug(`» starting Pullfrog (Claude Code): ${cliPath} ${baseArgs.join(" ")}`);
log.debug(`» working directory: ${repoDir}`);
// gate server lives only as long as the claude subprocess does. the
// Stop hook curls `gateServer.url` and turns the response into its
// `{decision: "block", reason}` payload (or exits 0 to allow stop).
await using gateServer = await startGateServer(ctx);
const result = await runClaude({
label: "Pullfrog",
cmd: cliPath,
cwd: repoDir,
env: { ...env, [STOP_HOOK_GATE_URL_ENV]: gateServer.url },
todoTracker: ctx.todoTracker,
onActivityTimeout: ctx.onActivityTimeout,
onToolUse: ctx.onToolUse,
args: [...baseArgs, "-p", ctx.instructions.full],
});
// every follow-up turn (reflection + gate retries) has already happened
// inside this single subprocess via the Stop hook, so usage aggregation
// and resume orchestration are no-ops. all that remains is the terminal
// hard-fail render: when the budget exhausted with `stopHook` /
// `unsubmittedReview` still failing, flip `success` to false with the
// same error shape `runPostRunRetryLoop` produced pre-migration.
return finalizeAgentResult({ ctx, result });
},
});