import fs from "node:fs/promises"; import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; const MAX_ARG_COUNT = 128; const MAX_ARG_BYTES = 1024 * 1024; const MAX_REQUEST_BYTES = 2 * 1024 * 1024; const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000; const MIN_IDLE_TIMEOUT_MS = 1_000; const MAX_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // Mirror the Claude flags produced by extensions/anthropic/cli-shared.ts and // cli-backend.ts. Node execution intentionally excludes file/plugin/MCP/tool // flags so gateway-local paths can never escape onto the remote host. const BARE_ARGS = new Set([ "-p", "--print", "--include-partial-messages", "--verbose", "--fork-session", "--safe-mode", "--bare", "--no-chrome", "--disable-slash-commands", "--no-session-persistence", "--exclude-dynamic-system-prompt-sections", "--include-hook-events", "--replay-user-messages", ]); const VALUE_ARGS = new Set([ "--output-format", "--input-format", "--setting-sources", "--permission-mode", "--resume", "-r", "--session-id", "--model", "--effort", "--max-turns", "--fallback-model", "--prompt-suggestions", "--max-budget-usd", // Native tool policy projected from the gateway. --allowedTools stays // rejected: Claude reads it as auto-approval, which must not bypass this // node's own approval surface. The gateway always sends these as one // comma-joined value (resolveClaudeCliToolAvailabilityArgs); a multi-token // variadic form fails closed as an unsupported argument. "--tools", "--disallowedTools", ]); const ENV_ALLOWLIST = new Set([ "ANTHROPIC_API_KEY", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "CLAUDE_CODE_DISABLE_1M_CONTEXT", "CLAUDE_CODE_OAUTH_TOKEN", "FORCE_COLOR", "LANG", "LC_ALL", "LC_CTYPE", "NO_COLOR", "TERM", ]); const CLEAR_ENV_ALLOWLIST = new Set([ "ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_OLD", "ANTHROPIC_API_TOKEN", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_UNIX_SOCKET", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "CLAUDE_CODE_DISABLE_1M_CONTEXT", "CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_OAUTH_REFRESH_TOKEN", "CLAUDE_CODE_OAUTH_SCOPES", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", "CLAUDE_CODE_PLUGIN_CACHE_DIR", "CLAUDE_CODE_PLUGIN_SEED_DIR", "CLAUDE_CODE_REMOTE", "CLAUDE_CODE_USE_COWORK_PLUGINS", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_FOUNDRY", "CLAUDE_CODE_USE_VERTEX", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "OTEL_EXPORTER_OTLP_LOGS_HEADERS", "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_HEADERS", "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_LOGS_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_SDK_DISABLED", "OTEL_TRACES_EXPORTER", ]); export type ClaudeCliNodeRunParams = { /** Opt-in to the negotiated invocation-owned resource and Workshop duplex. */ skillRuntime?: true; argv: string[]; stdin?: string; cwd?: string; env?: Record; clearEnv?: string[]; systemPrompt?: string; agentId?: string; sessionKey?: string; approvalDecision?: "allow-once" | "allow-always"; systemRunPlan?: SystemRunApprovalPlan; idleTimeoutMs: number; timeoutMs: number; }; export type ClaudeCliNodeRunResult = { exitCode: number; stderrTail: string; truncated: boolean; timeoutKind?: "hard" | "idle"; }; function decodeJson(raw?: string | null): unknown { if (!raw) { throw new Error("INVALID_REQUEST: paramsJSON required"); } try { return JSON.parse(raw) as unknown; } catch { throw new Error("INVALID_REQUEST: paramsJSON malformed JSON"); } } function requireBoundedString(value: unknown, label: string, maxBytes: number): string { if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > maxBytes) { throw new Error(`INVALID_REQUEST: ${label} must be a bounded string`); } return value; } /** Claude CLI session ids are bounded, non-option argv values. */ export function validateClaudeSessionId(value: unknown): string { const sessionId = requireBoundedString(value, "threadId", MAX_ARG_BYTES).trim(); if (!sessionId || sessionId.startsWith("-")) { throw new Error("INVALID_REQUEST: threadId must be a Claude session id"); } return sessionId; } function validateArgs(value: unknown): string[] { if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ARG_COUNT) { throw new Error("INVALID_REQUEST: argv must be a bounded non-empty array"); } const args = value.map((entry, index) => requireBoundedString(entry, `argv[${index}]`, MAX_ARG_BYTES), ); for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; const equalsIndex = arg.indexOf("="); const name = equalsIndex > 0 ? arg.slice(0, equalsIndex) : arg; if (BARE_ARGS.has(arg)) { continue; } if (!VALUE_ARGS.has(name)) { throw new Error(`INVALID_REQUEST: unsupported Claude CLI argument: ${arg || ""}`); } if (equalsIndex > 0) { const inlineValue = arg.slice(equalsIndex + 1); if (!inlineValue || inlineValue.startsWith("-")) { throw new Error( `INVALID_REQUEST: Claude CLI argument requires a non-option value: ${name}`, ); } if (name === "--permission-mode" && inlineValue === "bypassPermissions") { throw new Error("INVALID_REQUEST: bypassPermissions is not allowed for node agent runs"); } continue; } if (index + 1 >= args.length) { throw new Error(`INVALID_REQUEST: Claude CLI argument requires a value: ${name}`); } if (args[index + 1]?.startsWith("-")) { throw new Error(`INVALID_REQUEST: Claude CLI argument requires a non-option value: ${name}`); } if (name === "--permission-mode" && args[index + 1] === "bypassPermissions") { throw new Error("INVALID_REQUEST: bypassPermissions is not allowed for node agent runs"); } index += 1; } return args; } function validateTimeout(value: unknown, label: string, min: number, max: number): number { if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) { throw new Error(`INVALID_REQUEST: ${label} must be an integer from ${min} to ${max}`); } return value as number; } /** Select framing before the command handler validates the complete narrow request. */ export function requestsClaudeNodeSkillRuntime(raw?: string | null): boolean { if (!raw || Buffer.byteLength(raw, "utf8") > MAX_REQUEST_BYTES) { return false; } try { return asRecord(JSON.parse(raw))?.skillRuntime === true; } catch { return false; } } /** Resource bytes use the negotiated duplex, never argv or node filesystem paths. */ export async function decodeClaudeCliNodeRunParams( raw?: string | null, ): Promise { if (Buffer.byteLength(raw ?? "", "utf8") > MAX_REQUEST_BYTES) { throw new Error("INVALID_REQUEST: Claude CLI request is too large"); } const value = asRecord(decodeJson(raw)); if (!value) { throw new Error("INVALID_REQUEST: Claude CLI params must be an object"); } const allowed = new Set([ "argv", "stdin", "cwd", "env", "clearEnv", "systemPrompt", "agentId", "sessionKey", "approvalDecision", "systemRunPlan", "idleTimeoutMs", "timeoutMs", "skillRuntime", ]); const unknown = Object.keys(value).find((key) => !allowed.has(key)); if (unknown) { throw new Error(`INVALID_REQUEST: unknown Claude CLI parameter: ${unknown}`); } const argv = validateArgs(value.argv); if (value.skillRuntime !== undefined && value.skillRuntime !== true) { throw new Error("INVALID_REQUEST: skillRuntime must be true when supplied"); } const stdin = value.stdin === undefined ? undefined : requireBoundedString(value.stdin, "stdin", MAX_REQUEST_BYTES); const systemPrompt = value.systemPrompt === undefined ? undefined : requireBoundedString(value.systemPrompt, "systemPrompt", MAX_REQUEST_BYTES); const agentId = value.agentId === undefined ? undefined : requireBoundedString(value.agentId, "agentId", MAX_ARG_BYTES); const sessionKey = value.sessionKey === undefined ? undefined : requireBoundedString(value.sessionKey, "sessionKey", MAX_ARG_BYTES); const approvalDecision = value.approvalDecision === "allow-once" || value.approvalDecision === "allow-always" ? value.approvalDecision : undefined; if (value.approvalDecision !== undefined && !approvalDecision) { throw new Error("INVALID_REQUEST: approvalDecision is invalid"); } const systemRunPlan = value.systemRunPlan === undefined ? undefined : asRecord(value.systemRunPlan); if (value.systemRunPlan !== undefined && !systemRunPlan) { throw new Error("INVALID_REQUEST: systemRunPlan must be an object"); } const cwd = value.cwd === undefined ? undefined : requireBoundedString(value.cwd, "cwd", MAX_ARG_BYTES); if (cwd) { const stat = await fs.stat(cwd).catch(() => undefined); if (!stat?.isDirectory()) { throw new Error("INVALID_REQUEST: cwd must be an existing directory on the node"); } } let env: Record | undefined; if (value.env !== undefined) { const envValue = asRecord(value.env); if (!envValue) { throw new Error("INVALID_REQUEST: env must be an object"); } env = {}; for (const [key, candidate] of Object.entries(envValue)) { if (!ENV_ALLOWLIST.has(key)) { throw new Error(`INVALID_REQUEST: environment key is not allowed: ${key}`); } env[key] = requireBoundedString(candidate, `env.${key}`, MAX_ARG_BYTES); } if (Object.hasOwn(env, "ANTHROPIC_API_KEY") && Object.hasOwn(env, "CLAUDE_CODE_OAUTH_TOKEN")) { throw new Error("INVALID_REQUEST: exactly one Claude credential may be provided"); } } let clearEnv: string[] | undefined; if (value.clearEnv !== undefined) { if (!Array.isArray(value.clearEnv) || value.clearEnv.length > CLEAR_ENV_ALLOWLIST.size) { throw new Error("INVALID_REQUEST: clearEnv must be a bounded array"); } clearEnv = []; for (const candidate of value.clearEnv) { const key = requireBoundedString(candidate, "clearEnv entry", MAX_ARG_BYTES); if (!CLEAR_ENV_ALLOWLIST.has(key)) { throw new Error(`INVALID_REQUEST: clearEnv key is not allowed: ${key}`); } if (!clearEnv.includes(key)) { clearEnv.push(key); } } } return { argv, ...(value.skillRuntime === true ? { skillRuntime: true as const } : {}), ...(stdin !== undefined ? { stdin } : {}), ...(systemPrompt !== undefined ? { systemPrompt } : {}), ...(agentId ? { agentId } : {}), ...(sessionKey ? { sessionKey } : {}), ...(approvalDecision ? { approvalDecision } : {}), ...(systemRunPlan ? { systemRunPlan: systemRunPlan as SystemRunApprovalPlan } : {}), ...(cwd ? { cwd } : {}), ...(env ? { env } : {}), ...(clearEnv ? { clearEnv } : {}), idleTimeoutMs: validateTimeout( value.idleTimeoutMs, "idleTimeoutMs", MIN_IDLE_TIMEOUT_MS, MAX_IDLE_TIMEOUT_MS, ), timeoutMs: validateTimeout(value.timeoutMs, "timeoutMs", 1, MAX_TIMEOUT_MS), }; }