diff --git a/packages/coding-agent/.changes/eng-5342-kernel-secret-retention.md b/packages/coding-agent/.changes/eng-5342-kernel-secret-retention.md new file mode 100644 index 0000000000..7e4093c206 --- /dev/null +++ b/packages/coding-agent/.changes/eng-5342-kernel-secret-retention.md @@ -0,0 +1,4 @@ +- Changed the Python kernel to receive an allowlisted environment instead of the full host environment, so provider credentials such as `PRIME_API_KEY` are no longer visible to kernel or `bash()` cells; `kernel.envPassthrough` admits extra variables. +- Added `--no-kernel-snapshots` and the `kernel.stateSnapshots` setting to disable persisting and reviving Python kernel state for a session. +- Fixed kernel state snapshots persisting variables equal to a credential from the host environment; such names are now skipped and reported. +- Changed new session transcripts to be created owner-only (0600), matching the kernel snapshot files. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 5f6bd39454..61fb43537e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -569,6 +569,7 @@ Use `prime-agent model list [search]` to list available models. | `--fork ` | Fork specific session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode (don't save) | +| `--no-kernel-snapshots` | Do not persist or revive Python kernel state for this session | Use `prime-agent session export [output]` to export a saved session to HTML. diff --git a/packages/coding-agent/docs/rlm-runtime.md b/packages/coding-agent/docs/rlm-runtime.md index 63625ccbc9..f194a1d4f6 100644 --- a/packages/coding-agent/docs/rlm-runtime.md +++ b/packages/coding-agent/docs/rlm-runtime.md @@ -236,7 +236,9 @@ Exact artifact files are created only when their features are used. Non-persiste The REPL runtime process executes model-generated Python and `bash()` commands with the worker's OS permissions. The process boundary isolates protocol and lifecycle concerns; it is not a security sandbox. Installed Python packages, skills, and extensions are trusted code. Use an external sandbox or restricted execution environment when the workspace or generated code is untrusted. -Provider credentials are resolved by the TypeScript host. The bounded model catalog crosses into Python as metadata; the full auth store does not. +Provider credentials are resolved by the TypeScript host. The bounded model catalog crosses into Python as metadata; the full auth store does not. The kernel process is spawned with an allowlisted environment rather than the host's full environment: shell and locale essentials, `RLM_*`, `PRIME_AGENT_*`, Python/uv/pip/git variables, and whatever the session injects (subagent depth, bash shell, the websearch key). Provider API keys and other credential-shaped variables are dropped; `kernel.envPassthrough` admits extra names. `bash()` children inherit the kernel environment. + +Kernel state snapshots (`kernel-state.dill`) are owner-only files. The host sends the snapshot request digests of the credential values it saw at spawn, and the runtime skips any top-level `str`/`bytes` name holding one of them (reported in `skipped`). `kernel.stateSnapshots: false` or `--no-kernel-snapshots` disables snapshots for a session. ## Failure Modes diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index c86b94ac52..9ff5bbe4b8 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -17,6 +17,16 @@ Use `/session` in interactive mode to see the current session file, session ID, For the JSONL file format and SessionManager API, see [Session Format](session-format.md). +Transcripts contain tool output and anything the model echoed, so new session files are created owner-only (`0600`). Existing files keep the mode they already have. + +## Kernel State Snapshots + +Persisted sessions also save the Python kernel namespace to `~/.prime/agent/session-artifacts//kernel-state.dill` (owner-only), with the saved and skipped names listed in `kernel-state.json`. Resuming the session revives those variables. Anything assigned in the kernel is persisted this way, so removing a value from the transcript does not remove it from the snapshot. + +Two safeguards apply automatically: the kernel never inherits provider credentials from the host environment (see [Kernel settings](settings.md#kernel)), and a top-level variable whose value equals a credential the host saw in its environment is skipped and reported instead of being written. + +To keep no kernel state on disk for a session, start it with `--no-kernel-snapshots`, or set `kernel.stateSnapshots` to `false` in [Settings](settings.md#kernel). The kernel stderr log is still written to the artifact directory. Deleting a session with `/resume` removes its artifact directory as well; to scrub a single snapshot by hand, delete `kernel-state.dill` and `kernel-state.json`. + ## Session Commands | Command | Description | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 1fa0cfbef1..c33f8bf79f 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -194,6 +194,24 @@ When a provider requests a retry delay longer than `retry.provider.maxRetryDelay Normally the package manager's global modules location is queried using `root -g`. As a special case, if the first element of `npmCommand` is `"bun"`, the modules location will instead be queried with `pm bin -g`. +### Kernel + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `kernel.stateSnapshots` | boolean | `true` | Persist the Python kernel namespace to `session-artifacts//kernel-state.dill` and revive it on resume. `false` disables writing and restoring the snapshot for the session (same as `--no-kernel-snapshots`). | +| `kernel.envPassthrough` | string[] | `[]` | Extra host environment variable names the kernel (and its `bash()` commands) may inherit, as exact names or `PREFIX*` globs. | + +```json +{ + "kernel": { + "stateSnapshots": false, + "envPassthrough": ["DATABASE_URL", "MYAPP_*"] + } +} +``` + +The kernel does not inherit the host environment wholesale. It receives an allowlist (`PATH`, `HOME`, locale and terminal variables, temp dirs, proxy and TLS settings, common toolchain homes, `RLM_*`, `PRIME_AGENT_*`, `PYTHON*`, `UV_*`, `PIP_*`, `LC_*`, `XDG_*`, `GIT_*`, and the Windows system set) plus what the session injects. Provider credentials such as `PRIME_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `*_TOKEN`, and AWS/GCP credential variables are never inherited; list a name in `kernel.envPassthrough` only when model-run code genuinely needs it. Values of credentials present in the host environment are also never written to the kernel snapshot: a top-level variable equal to one of them is reported as skipped in `kernel-state.json`. + ### Daemon | Setting | Type | Default | Description | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 3d68af75bd..b9f144cb04 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -222,6 +222,7 @@ Use `prime-agent model list [search]` to list available models. | `--fork ` | Fork a session file or partial UUID into a new session | | `--session-dir ` | Custom session storage directory | | `--no-session` | Ephemeral mode; do not save | +| `--no-kernel-snapshots` | Do not persist or revive Python kernel state (`kernel-state.dill`) for this session; see [Sessions](sessions.md#kernel-state-snapshots) | Use `prime-agent session export [output]` to export a session to HTML. @@ -365,6 +366,8 @@ prime-agent --tools ipython -p "Review the code" | `PRIME_AGENT_KERNEL_PYTHON` | Use an existing Python environment with `prime-agent-runtime` instead of bootstrapping `~/.prime/agent/kernel-venv` | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | +Provider credentials in the environment are read by the TypeScript host only. The Python kernel receives an allowlisted environment without them; see [Kernel settings](settings.md#kernel) for the allowlist and `kernel.envPassthrough`. + The remaining `PI_*` variables are compatibility names still read by the current runtime. They do not change the application name, command, or default `~/.prime/agent` configuration path. ## Design Principles diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 15593eb428..bdf4b11392 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -44,7 +44,7 @@ "postinstall": "node postinstall.cjs", "prepublishOnly": "npm run clean && npm run build", "bundle": "node scripts/bundle.mjs", - "test:kernel": "vitest --run --no-file-parallelism --tagsFilter kernel-heavy test/acp-kernel-features.test.ts test/acp-cold-cli.test.ts test/kernel-goal-skill.test.ts test/repl-kernel-state-roundtrip.test.ts test/repl-kernel-mcp-shutdown.test.ts" + "test:kernel": "vitest --run --no-file-parallelism --tagsFilter kernel-heavy test/acp-kernel-features.test.ts test/acp-cold-cli.test.ts test/kernel-goal-skill.test.ts test/repl-kernel-state-roundtrip.test.ts test/repl-kernel-secret-retention.test.ts test/repl-kernel-mcp-shutdown.test.ts" }, "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", diff --git a/packages/coding-agent/skills/prime-intellect/references/inference.md b/packages/coding-agent/skills/prime-intellect/references/inference.md index 0839d48254..805dd2e5b7 100644 --- a/packages/coding-agent/skills/prime-intellect/references/inference.md +++ b/packages/coding-agent/skills/prime-intellect/references/inference.md @@ -13,6 +13,8 @@ Live docs: `inference/overview.md`, `inference/usage.md`, `inference/adapter-dep export PRIME_API_KEY="your-api-key-here" ``` +Note: the Python kernel does not inherit `PRIME_API_KEY` (or other provider keys) from the host environment. The `prime` CLI still authenticates from `~/.prime/config.json`; for direct API calls from kernel code, read the key from `~/.prime/config.json` (`api_key`) or ask the user to add `PRIME_API_KEY` to `kernel.envPassthrough` in settings. + ## Via the CLI (recommended for evaluations) ```bash diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index fed99107ef..47ac1fee32 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -23,6 +23,7 @@ export interface Args { mode?: Mode; daemonSocket?: string; noSession?: boolean; + noKernelSnapshots?: boolean; fork?: string; sessionDir?: string; models?: string[]; @@ -143,6 +144,8 @@ export function parseArgs(args: string[]): Args { result.appendSystemPrompt.push(args[++i]); } else if (arg === "--no-session") { result.noSession = true; + } else if (arg === "--no-kernel-snapshots") { + result.noKernelSnapshots = true; } else if (arg === "--fork" && i + 1 < args.length) { result.fork = args[++i]; } else if (arg === "--session-dir" && i + 1 < args.length) { diff --git a/packages/coding-agent/src/cli/command-registry.ts b/packages/coding-agent/src/cli/command-registry.ts index e1c37e126c..48f7dcd92e 100644 --- a/packages/coding-agent/src/cli/command-registry.ts +++ b/packages/coding-agent/src/cli/command-registry.ts @@ -216,6 +216,7 @@ const TOP_LEVEL_OPTION_GROUPS: ReadonlyArray<{ heading: string; options: readonl ["--fork ", "Fork a saved session into a new session"], ["--session-dir ", "Use a custom session directory"], ["--no-session", "Do not save the session"], + ["--no-kernel-snapshots", "Do not persist or revive Python kernel state for this session"], ["--goal ", "Seed a persistent goal for a new root session"], ["--goal-token-budget ", "Set a positive token budget for --goal"], ], diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 0154275727..b5454e5960 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -313,6 +313,7 @@ const SESSION_BOOLEAN_FLAGS = new Set([ "--continue", "-c", "--no-session", + "--no-kernel-snapshots", "--no-tools", "-nt", "--no-builtin-tools", @@ -542,6 +543,9 @@ function parseSessionOption( case "-nc": config.noContextFiles = true; return boolean(arg); + case "--no-kernel-snapshots": + config.noKernelSnapshots = true; + return boolean(arg); case "--goal": { const value = readValue(arg); if (!value.trim()) { diff --git a/packages/coding-agent/src/core/agent-session-config.ts b/packages/coding-agent/src/core/agent-session-config.ts index f855f4c075..b8f552a752 100644 --- a/packages/coding-agent/src/core/agent-session-config.ts +++ b/packages/coding-agent/src/core/agent-session-config.ts @@ -26,6 +26,8 @@ export interface AgentSessionRuntimeConfig { themes?: string[]; noThemes?: boolean; noContextFiles?: boolean; + /** Disable persisting/reviving the Python kernel namespace (kernel-state.dill) for the session. */ + noKernelSnapshots?: boolean; autonomous?: AgentAutonomousConfig; extensionFlagValues?: Record; /** @@ -90,6 +92,7 @@ export function mergeAgentSessionRuntimeConfig( themes: cloneArray(override.themes ?? base.themes), noThemes: override.noThemes ?? base.noThemes, noContextFiles: override.noContextFiles ?? base.noContextFiles, + noKernelSnapshots: override.noKernelSnapshots ?? base.noKernelSnapshots, autonomous: mergeAutonomousConfig(base.autonomous, override.autonomous), extensionFlagValues: base.extensionFlagValues || override.extensionFlagValues diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 249ffc0773..c1c6a01202 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -68,6 +68,8 @@ export interface AgentSessionCreationOptions { subagentRuntimeHost?: SubagentRuntimeHost; rlmHeartbeatController?: AgentRlmHeartbeatController; prewarmIpythonKernel?: boolean; + /** Override for the kernel.stateSnapshots setting; false disables kernel-state.dill for this session. */ + kernelStateSnapshots?: boolean; autonomous?: AgentAutonomousConfig; serializedRefine?: boolean; executionMode?: AgentExecutionMode; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index eec21e2341..54b41bf5cf 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -489,6 +489,8 @@ export interface AgentSessionConfig { subagentRuntimeHost?: SubagentRuntimeHost; autonomous?: AgentAutonomousConfig; prewarmIpythonKernel?: boolean; + /** Override for the kernel.stateSnapshots setting; false disables kernel-state.dill for this session. */ + kernelStateSnapshots?: boolean; autoRefineReviewer?: AutoRefineReviewer; /** * When true, auto-refine runs synchronously between turns at the @@ -1242,6 +1244,7 @@ export class AgentSession { /** True once the runtime has been built once; later builds are in-process rebuilds (/reload). */ private _ipythonRuntimeBuilt = false; private readonly _prewarmIpythonKernel: boolean; + private readonly _kernelStateSnapshotsOverride: boolean | undefined; private _rlmDepth: number; private readonly _configuredRlmMaxDepth: number | undefined; private _rlmMaxDepth: number; @@ -1355,6 +1358,7 @@ export class AgentSession { this._rlmMaxDepth = resolvedRlmMaxDepth.maxDepth; this._rlmMaxDepthSource = resolvedRlmMaxDepth.source; this._prewarmIpythonKernel = (config.prewarmIpythonKernel ?? false) && this._rlmDepth === 0; + this._kernelStateSnapshotsOverride = config.kernelStateSnapshots; this._autoRefineReviewer = config.autoRefineReviewer; this._serializedRefine = config.serializedRefine ?? false; this._rlmSessionDir = config.rlmSessionDir; @@ -9427,18 +9431,21 @@ export class AgentSession { // reload can't restore from a snapshot the old kernel is still writing. const previousDispose = this._ipythonKernelProvisioner?.dispose(); this._ipythonKernelSnapshotDir = this.sessionManager.getSessionArtifactDir(); + const stateSnapshots = this._kernelStateSnapshotsEnabled(); // Only surface the "revived from your previous session" notice on the first // build (a genuine resume). A later rebuild (/reload) restores state silently // for continuity — the conversation is unchanged, so there's nothing to flag. const notifyRestore = !this._ipythonRuntimeBuilt; this._ipythonKernelProvisioner = new IpythonKernelProvisioner(this._cwd, { env: this._rlmKernelEnv(), + hostEnvPassthrough: this.settingsManager.getKernelEnvPassthrough(), commandPrefix: this.settingsManager.getShellCommandPrefix(), shellPath: this.settingsManager.getShellPath(), sessionId: this.sessionId, hostHandlers: this._createKernelHostHandlers(), pythonSkills, snapshotDir: this._ipythonKernelSnapshotDir, + stateSnapshots, readyGate: previousDispose, onRestore: notifyRestore ? (result) => this._onIpythonStateRestored(result) : undefined, }); @@ -9512,7 +9519,9 @@ export class AgentSession { // came back before the first turn, rather than a turn later when the kernel // would otherwise lazily start on first use. const hasSnapshot = - !!this._ipythonKernelSnapshotDir && existsSync(snapshotPathIn(this._ipythonKernelSnapshotDir)); + this._kernelStateSnapshotsEnabled() && + !!this._ipythonKernelSnapshotDir && + existsSync(snapshotPathIn(this._ipythonKernelSnapshotDir)); if ((this._prewarmIpythonKernel || hasSnapshot) && this.getActiveToolNames().includes("ipython")) { this._ipythonKernelProvisioner?.prewarm(); } @@ -9713,6 +9722,11 @@ export class AgentSession { } } + /** CLI override first, then the kernel.stateSnapshots setting (default on). */ + private _kernelStateSnapshotsEnabled(): boolean { + return this._kernelStateSnapshotsOverride ?? this.settingsManager.getKernelStateSnapshots(); + } + private _rlmKernelEnv(): Record { // Kernel env is provisioning-time only: RLM_MAX_DEPTH may be stale in an already-running kernel; // the TypeScript-side spawn check remains authoritative. @@ -9738,14 +9752,18 @@ export class AgentSession { env.PRIME_AGENT_CODING_AGENT_DIR = this._agentDir; } - if (process.env[SERPER_ENV_VAR]?.trim()) { - return; - } // Inject only when a websearch skill (bundled or custom) is actually loaded, // so the key isn't exposed to kernels that can't use it. if (!this._resourceLoader.getSkills().skills.some((skill) => skill.name === WEBSEARCH_SKILL_NAME)) { return; } + // The kernel no longer inherits the host env wholesale, so a host-provided + // key must be handed over explicitly; it still wins over the stored credential. + const fromHost = process.env[SERPER_ENV_VAR]?.trim(); + if (fromHost) { + env[SERPER_ENV_VAR] = fromHost; + return; + } const cred = this._modelRegistry.authStorage.get(SERPER_CREDENTIAL_ID); if (cred?.type !== "api_key") { return; diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index bf27e24777..6caef88491 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -1,2 +1,11 @@ +export { + buildKernelEnv, + collectCredentialDigests, + credentialDigest, + droppedCredentialEnvNames, + isCredentialEnvName, + KERNEL_ENV_CREDENTIAL_NAMES, + type KernelEnvOptions, +} from "./kernel-env.js"; export { ReplKernelManager } from "./repl-manager.js"; export * from "./shared.js"; diff --git a/packages/coding-agent/src/core/kernel/kernel-env.ts b/packages/coding-agent/src/core/kernel/kernel-env.ts new file mode 100644 index 0000000000..86d0ef1d01 --- /dev/null +++ b/packages/coding-agent/src/core/kernel/kernel-env.ts @@ -0,0 +1,278 @@ +// The REPL kernel runs model-generated code, so it must not inherit the host's +// whole environment: provider credentials (PRIME_API_KEY, OPENAI_API_KEY, ...) +// would be readable from any cell and, once assigned to a name, pickled into +// the session's kernel-state snapshot. The kernel gets an allowlisted subset +// instead; everything the session itself injects (RLM_*, PRIME_AGENT_BASH_*, +// the websearch key) is passed explicitly by the caller. +import { createHash } from "node:crypto"; + +/** Exact host variable names the kernel (and its bash() children) may inherit. */ +const KERNEL_ENV_ALLOWLIST = new Set([ + // Process identity and filesystem roots. + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "PWD", + "TMPDIR", + "TMP", + "TEMP", + // Locale and terminal. + "LANG", + "LANGUAGE", + "TZ", + "TERM", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + // Editors and pagers project commands (git, man) may invoke. + "EDITOR", + "VISUAL", + "PAGER", + "LESS", + "LESSCHARSET", + // Agent and display sockets (paths, not secrets). + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "DISPLAY", + "WAYLAND_DISPLAY", + "DBUS_SESSION_BUS_ADDRESS", + // TLS trust and proxies. + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + // Toolchain homes project commands resolve binaries and caches through. + "VIRTUAL_ENV", + "CONDA_PREFIX", + "CONDA_DEFAULT_ENV", + "PYENV_ROOT", + "NVM_DIR", + "NODE_PATH", + "NPM_CONFIG_PREFIX", + "PNPM_HOME", + "BUN_INSTALL", + "VOLTA_HOME", + "DENO_DIR", + "CARGO_HOME", + "RUSTUP_HOME", + "GOPATH", + "GOROOT", + "GOMODCACHE", + "JAVA_HOME", + "SDKMAN_DIR", + "ANDROID_HOME", + "ANDROID_SDK_ROOT", + "DOTNET_ROOT", + "CI", + // Legacy agent dir read by the runtime. + "PI_CODING_AGENT_DIR", + // Prime team selection (an identifier, not a credential). + "PRIME_TEAM_ID", + // Windows process environment. + "SYSTEMROOT", + "SYSTEMDRIVE", + "WINDIR", + "COMSPEC", + "PATHEXT", + "USERPROFILE", + "USERNAME", + "USERDOMAIN", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMDATA", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", + "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", + "ALLUSERSPROFILE", + "PUBLIC", + "NUMBER_OF_PROCESSORS", + "PROCESSOR_ARCHITECTURE", + "OS", +]); + +/** Name prefixes the kernel may inherit (still subject to the credential-name filter). */ +const KERNEL_ENV_ALLOWLIST_PREFIXES = [ + "RLM_", + "PRIME_AGENT_", + "PYTHON", + "UV_", + "PIP_", + "LC_", + "XDG_", + "TERM_", + "GIT_", + "TMUX", +]; + +/** + * Provider credential variables (packages/ai/src/env-api-keys.ts, prime-agent.sh + * --no-env) plus ambient cloud credential sources. Never inherited, and their + * host values are recorded so the snapshot can skip names holding them. + */ +export const KERNEL_ENV_CREDENTIAL_NAMES: readonly string[] = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_OAUTH_TOKEN", + "OPENAI_API_KEY", + "AZURE_OPENAI_API_KEY", + "PRIME_API_KEY", + "PRIME_AGENT_TRACES_API_KEY", + "DEEPSEEK_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_CLOUD_API_KEY", + "GROQ_API_KEY", + "CEREBRAS_API_KEY", + "XAI_API_KEY", + "OPENROUTER_API_KEY", + "AI_GATEWAY_API_KEY", + "ZAI_API_KEY", + "MISTRAL_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", + "MOONSHOT_API_KEY", + "HF_TOKEN", + "FIREWORKS_API_KEY", + "OPENCODE_API_KEY", + "KIMI_API_KEY", + "CLOUDFLARE_API_KEY", + "XIAOMI_API_KEY", + "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "SERPER_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_WEB_IDENTITY_TOKEN_FILE", +]; + +/** Name shapes that denote a credential regardless of the allowlist above. */ +const CREDENTIAL_NAME_PATTERN = /(API_KEY|APIKEY|ACCESS_KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY)/i; + +/** Values shorter than this are too generic to treat as a credential match. */ +const MIN_CREDENTIAL_VALUE_LENGTH = 8; + +const credentialNameSet = new Set(KERNEL_ENV_CREDENTIAL_NAMES); + +/** True when the variable name denotes a credential and must never reach the kernel by inheritance. */ +export function isCredentialEnvName(name: string): boolean { + return credentialNameSet.has(name) || CREDENTIAL_NAME_PATTERN.test(name); +} + +export interface KernelEnvOptions { + /** Extra host names (exact) or `PREFIX*` globs the kernel may inherit; bypasses the credential filter. */ + passthrough?: readonly string[]; + /** Platform whose env-name semantics apply (Windows names are case-insensitive). Default: process.platform. */ + platform?: NodeJS.Platform; +} + +function normalizeName(name: string, caseInsensitive: boolean): string { + return caseInsensitive ? name.toUpperCase() : name; +} + +function matchesPassthrough(name: string, passthrough: readonly string[], caseInsensitive: boolean): boolean { + const normalized = normalizeName(name, caseInsensitive); + for (const rule of passthrough) { + const trimmed = rule.trim(); + if (!trimmed) continue; + const normalizedRule = normalizeName(trimmed, caseInsensitive); + if (normalizedRule.endsWith("*")) { + const prefix = normalizedRule.slice(0, -1); + if (prefix && normalized.startsWith(prefix)) return true; + } else if (normalized === normalizedRule) { + return true; + } + } + return false; +} + +function inheritedByAllowlist(name: string, caseInsensitive: boolean): boolean { + // The Windows set is stored upper-case; on POSIX names are matched exactly. + const upper = name.toUpperCase(); + if (KERNEL_ENV_ALLOWLIST.has(name) || (caseInsensitive && KERNEL_ENV_ALLOWLIST.has(upper))) return true; + const candidate = caseInsensitive ? upper : name; + return KERNEL_ENV_ALLOWLIST_PREFIXES.some((prefix) => candidate.startsWith(prefix)); +} + +/** + * Build the environment for a kernel process: the allowlisted subset of `hostEnv` + * with `extra` layered on top. `extra` is always kept verbatim — it is what the + * session deliberately hands the kernel (RLM_*, PRIME_AGENT_BASH_*, skill keys). + */ +export function buildKernelEnv( + hostEnv: NodeJS.ProcessEnv, + extra: Record = {}, + options: KernelEnvOptions = {}, +): Record { + const platform = options.platform ?? process.platform; + const caseInsensitive = platform === "win32"; + const passthrough = options.passthrough ?? []; + const env: Record = {}; + for (const [name, value] of Object.entries(hostEnv)) { + if (value === undefined) continue; + if (matchesPassthrough(name, passthrough, caseInsensitive)) { + env[name] = value; + continue; + } + if (!inheritedByAllowlist(name, caseInsensitive)) continue; + if (isCredentialEnvName(name)) continue; + env[name] = value; + } + return { ...env, ...extra }; +} + +/** Host variable names dropped by {@link buildKernelEnv} because they name a credential. */ +export function droppedCredentialEnvNames(hostEnv: NodeJS.ProcessEnv, options: KernelEnvOptions = {}): string[] { + const platform = options.platform ?? process.platform; + const caseInsensitive = platform === "win32"; + const passthrough = options.passthrough ?? []; + return Object.keys(hostEnv) + .filter((name) => hostEnv[name] !== undefined && hostEnv[name] !== "") + .filter((name) => isCredentialEnvName(name) && !matchesPassthrough(name, passthrough, caseInsensitive)) + .sort(); +} + +/** SHA-256 hex digest of a credential value, as the runtime computes it for snapshot redaction. */ +export function credentialDigest(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +/** + * Digests of every credential-looking value in `env` (host env plus the session's + * injected variables). The kernel receives digests only, never the values, and + * skips top-level names whose value hashes to one of them when snapshotting. + */ +export function collectCredentialDigests(env: NodeJS.ProcessEnv): string[] { + const digests = new Set(); + for (const [name, value] of Object.entries(env)) { + if (typeof value !== "string" || !isCredentialEnvName(name)) continue; + const trimmed = value.trim(); + if (trimmed.length < MIN_CREDENTIAL_VALUE_LENGTH) continue; + digests.add(credentialDigest(value)); + if (trimmed !== value) digests.add(credentialDigest(trimmed)); + } + return [...digests].sort(); +} diff --git a/packages/coding-agent/src/core/kernel/repl-manager.ts b/packages/coding-agent/src/core/kernel/repl-manager.ts index 5023e48729..0c81392cbf 100644 --- a/packages/coding-agent/src/core/kernel/repl-manager.ts +++ b/packages/coding-agent/src/core/kernel/repl-manager.ts @@ -9,6 +9,7 @@ import { v4 as uuid } from "uuid"; import { spawnHidden } from "../../utils/child-process.js"; import { reapKernelOrphanProcesses, recordOrphanProcessState } from "../orphan-process-journal.js"; import { ensureKernelPython } from "./bootstrap.js"; +import { buildKernelEnv, collectCredentialDigests, droppedCredentialEnvNames } from "./kernel-env.js"; import { AGENT_MESSAGE_DISPLAY_MIME, ATTACHMENT_DISPLAY_MIME, @@ -155,6 +156,7 @@ export class ReplKernelManager { | "python" | "cwd" | "env" + | "hostEnvPassthrough" | "sessionId" | "hostHandlers" | "pythonSkills" @@ -163,6 +165,8 @@ export class ReplKernelManager { | "stderrLogPath" >; private readonly handledHostRequestIds = new Set(); + /** SHA-256 digests of credential values seen at spawn; the runtime skips names holding them when snapshotting. */ + private credentialDigests: string[] = []; private child?: ChildProcess; private readyDeferred?: ReturnType>; private kernelStderr = ""; @@ -213,6 +217,7 @@ export class ReplKernelManager { python: options.python, cwd: options.cwd, env: options.env, + hostEnvPassthrough: options.hostEnvPassthrough, sessionId: options.sessionId, hostHandlers: options.hostHandlers, pythonSkills: options.pythonSkills, @@ -312,16 +317,29 @@ export class ReplKernelManager { throw new Error("Kernel was disposed during startup"); } - const child = spawnHidden(python, ["-m", "rlm.repl"], { - cwd: this.options.cwd, - // bash.py journals its process groups under this pid so the host can - // reap them if the runtime dies without running its shutdown hook. - env: { - ...process.env, + // The kernel runs model code: it gets an allowlisted host environment (no + // provider credentials) plus what the session injects. Credential values + // present on the host are remembered as digests so a snapshot can refuse to + // persist a name that holds one of them. + const kernelEnv = buildKernelEnv( + process.env, + { ...this.options.env, ...(process.platform === "win32" ? { PYTHONUTF8: "1" } : {}), + // bash.py journals its process groups under this pid so the host can + // reap them if the runtime dies without running its shutdown hook. PRIME_AGENT_KERNEL_OWNER_PID: String(process.pid), }, + { passthrough: this.options.hostEnvPassthrough }, + ); + this.credentialDigests = collectCredentialDigests({ ...process.env, ...this.options.env }); + const withheld = droppedCredentialEnvNames(process.env, { passthrough: this.options.hostEnvPassthrough }); + if (withheld.length > 0) { + this.appendKernelDiagnostic(`host credentials withheld from the kernel environment: ${withheld.join(", ")}`); + } + const child = spawnHidden(python, ["-m", "rlm.repl"], { + cwd: this.options.cwd, + env: kernelEnv, stdio: ["pipe", "pipe", "pipe"], }); this.child = child; @@ -1496,6 +1514,7 @@ export class ReplKernelManager { max_bytes: cfg.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES, max_variable_bytes: cfg.maxVariableBytes ?? DEFAULT_SNAPSHOT_MAX_VARIABLE_BYTES, prune_oversized: options.pruneOversized ?? false, + redact_sha256: this.credentialDigests, }, "", { internal: true }, diff --git a/packages/coding-agent/src/core/kernel/shared.ts b/packages/coding-agent/src/core/kernel/shared.ts index 71b7ef7377..2186043051 100644 --- a/packages/coding-agent/src/core/kernel/shared.ts +++ b/packages/coding-agent/src/core/kernel/shared.ts @@ -48,7 +48,10 @@ export interface KernelManagerOptions { /** Python interpreter with the kernel runtime available. Defaults to the auto-bootstrapped kernel. */ python?: string; cwd?: string; + /** Variables handed to the kernel verbatim, on top of the allowlisted host environment. */ env?: Record; + /** Extra host variable names (exact or `PREFIX*`) the kernel may inherit beyond the built-in allowlist. */ + hostEnvPassthrough?: readonly string[]; sessionId?: string; hostHandlers?: HostRequestHandlers; pythonSkills?: readonly KernelPythonSkill[]; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 723209fef0..fd3f4abd2e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -372,6 +372,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} subagentRuntimeHost: options.subagentRuntimeHost, sessionStartEvent: options.sessionStartEvent, prewarmIpythonKernel: options.prewarmIpythonKernel, + kernelStateSnapshots: options.kernelStateSnapshots, autonomous: options.autonomous, serializedRefine: options.serializedRefine, initialGoal: options.initialGoal, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index dc7b593072..e7bf6db41d 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -3,6 +3,7 @@ import type { AssistantMessage, ImageContent, Message, ServiceTier, TextContent, import { randomUUID } from "crypto"; import { appendFileSync, + chmodSync, chownSync, closeSync, existsSync, @@ -60,6 +61,9 @@ const CONTENT_ENTRY_TYPES = new Set([ "branch_summary", ]); +/** Owner-only mode for newly created session transcripts (matches the kernel snapshot files). */ +export const SESSION_FILE_MODE = 0o600; + function statMetadataIfPresent(path: string): { mode: number; uid: number; gid: number } | undefined { try { const { mode, uid, gid } = statSync(path); @@ -1573,8 +1577,10 @@ export class SessionManager { const directory = dirname(targetPath); mkdirSync(directory, { recursive: true }); const metadata = statMetadataIfPresent(targetPath); + // Transcripts carry tool output and anything the model echoed; a new file is + // owner-only like the kernel snapshot, while an existing file keeps its mode. writeFileAtomicSync(targetPath, content, { - ...(metadata === undefined ? {} : { mode: metadata.mode }), + mode: metadata === undefined ? SESSION_FILE_MODE : metadata.mode, beforeRename: (tempPath) => { if (metadata !== undefined) chownSync(tempPath, metadata.uid, metadata.gid); }, @@ -2359,7 +2365,8 @@ export class SessionManager { rlmDepth: resolveSessionRlmDepth(sourceHeader, sourcePath), git: captureGitContext(targetCwd) ?? undefined, }; - appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); + appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { mode: SESSION_FILE_MODE }); + chmodSync(newSessionFile, SESSION_FILE_MODE); // exact bits despite the umask // Drop the source's git_state entries (re-linking children): they describe the source repo, // so the fork would otherwise report the source's git instead of its own target context. diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 1fb87cbdb4..32938fa6cb 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -173,6 +173,14 @@ export interface Settings { markdown?: MarkdownSettings; warnings?: WarningSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + kernel?: KernelSettings; +} + +export interface KernelSettings { + /** Persist the Python kernel namespace to the session artifact dir and revive it on resume. Default: true. */ + stateSnapshots?: boolean; + /** Extra host environment variable names (exact or `PREFIX*`) the kernel may inherit beyond the built-in allowlist. */ + envPassthrough?: string[]; } export interface AgentTracesSettings { @@ -992,6 +1000,15 @@ export class SettingsManager { return this.settings.shellCommandPrefix; } + getKernelStateSnapshots(): boolean { + return this.settings.kernel?.stateSnapshots ?? true; + } + + getKernelEnvPassthrough(): string[] { + const raw = this.settings.kernel?.envPassthrough; + return Array.isArray(raw) ? raw.filter((name): name is string => typeof name === "string" && !!name.trim()) : []; + } + setShellCommandPrefix(prefix: string | undefined): void { this.globalSettings.shellCommandPrefix = prefix; this.markModified("shellCommandPrefix"); diff --git a/packages/coding-agent/src/core/tools/ipython.ts b/packages/coding-agent/src/core/tools/ipython.ts index bae3ba41c1..08075c7080 100644 --- a/packages/coding-agent/src/core/tools/ipython.ts +++ b/packages/coding-agent/src/core/tools/ipython.ts @@ -278,6 +278,8 @@ export interface IpythonToolOptions { /** Python override. Must have prime-agent-runtime installed. */ python?: string; env?: Record; + /** Extra host variable names (exact or `PREFIX*`) the kernel may inherit beyond the built-in allowlist. */ + hostEnvPassthrough?: readonly string[]; /** Command prefix prepended to every bash() command. */ commandPrefix?: string; /** Shell used by bash(). */ @@ -286,8 +288,10 @@ export interface IpythonToolOptions { /** Typed host request handlers for the kernel↔host bridge (rlm.run, goal.*, …). */ hostHandlers?: HostRequestHandlers; pythonSkills?: readonly PythonSkillRuntimeInfo[]; - /** Per-session artifact dir where the kernel namespace snapshot is stored. Omit to disable snapshots. */ + /** Per-session artifact dir where the kernel namespace snapshot and stderr log are stored. Omit to disable both. */ snapshotDir?: string; + /** Persist and revive the kernel namespace in `snapshotDir`. Default: true. False keeps only the stderr log. */ + stateSnapshots?: boolean; /** Resolves before this kernel starts — e.g. the previous provisioner's dispose, so a * /reload's old-kernel snapshot flush can't race the new kernel's restore. */ readyGate?: Promise; @@ -462,6 +466,7 @@ export class IpythonKernelProvisioner { ); } const snapshotDir = this.options?.snapshotDir; + const stateSnapshotDir = this.options?.stateSnapshots === false ? undefined : snapshotDir; // Always inject an absolute trusted shell (undefined only on win32 // without bash, where the runtime's teaching error fires instead). const shellPath = resolveKernelBashShell(this.options?.shellPath); @@ -476,12 +481,13 @@ export class IpythonKernelProvisioner { ...(shellPath ? { PRIME_AGENT_BASH_SHELL: shellPath } : {}), ...(commandPrefix ? { PRIME_AGENT_BASH_COMMAND_PREFIX: commandPrefix } : {}), }, + hostEnvPassthrough: this.options?.hostEnvPassthrough, sessionId: this.options?.sessionId, hostHandlers: this.options?.hostHandlers, pythonSkills: this.options?.pythonSkills, // Only persistent sessions (which have an artifact dir) get a revivable snapshot. - snapshot: snapshotDir - ? { path: snapshotPathIn(snapshotDir), manifestPath: manifestPathIn(snapshotDir) } + snapshot: stateSnapshotDir + ? { path: snapshotPathIn(stateSnapshotDir), manifestPath: manifestPathIn(stateSnapshotDir) } : undefined, stderrLogPath: snapshotDir ? join(snapshotDir, "kernel-stderr.log") : undefined, bootstrapCode, @@ -506,12 +512,12 @@ export class IpythonKernelProvisioner { }, startupSignal); // Revive a prior session's namespace before the bootstrap, so the bootstrap // then overwrites live handles (rlm, skills) on top of anything restored. - if (snapshotDir) { - const snapshotExisted = existsSync(snapshotPathIn(snapshotDir)); + if (stateSnapshotDir) { + const snapshotExisted = existsSync(snapshotPathIn(stateSnapshotDir)); this.emitStartupProgress("Restoring Python state..."); const restore = await raceWithAbort(m.restoreState(), startupSignal); if (snapshotExisted) { - pendingRestore = restore ?? { restored: [], failed: [], path: snapshotPathIn(snapshotDir) }; + pendingRestore = restore ?? { restored: [], failed: [], path: snapshotPathIn(stateSnapshotDir) }; } } this.emitStartupProgress("Preparing Python runtime..."); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 64641cf5b2..dca646c22e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -694,6 +694,7 @@ function runtimeConfigFromArgs( themes: resolveCliPaths(cwd, parsed.themes), noThemes: parsed.noThemes, noContextFiles: parsed.noContextFiles, + noKernelSnapshots: parsed.noKernelSnapshots, autonomous: runtimeAutonomousConfigFromArgs(parsed), extensionFlagValues: parsed.unknownFlags.size > 0 ? Object.fromEntries(parsed.unknownFlags.entries()) : undefined, executionMode: appMode === "daemon" ? undefined : appMode, @@ -786,6 +787,8 @@ export function createDefaultRuntimeFactory( // Main agents boot their kernel in the background at session creation; // subagent sessions (rlmDepth > 0) keep the lazy first-call start. prewarmIpythonKernel: true, + // An explicit --no-kernel-snapshots wins over the kernel.stateSnapshots setting. + kernelStateSnapshots: config.noKernelSnapshots ? false : undefined, // Read serializedRefine from the merged runtime config (passed // from the JSON/print client through AgentSessionRuntimeConfig) // so it survives the daemon worker's appMode="daemon" context. diff --git a/packages/coding-agent/test/ipython-provisioner.test.ts b/packages/coding-agent/test/ipython-provisioner.test.ts index 27e7263d62..fc060ce8ac 100644 --- a/packages/coding-agent/test/ipython-provisioner.test.ts +++ b/packages/coding-agent/test/ipython-provisioner.test.ts @@ -145,6 +145,24 @@ describe("IpythonKernelProvisioner", () => { } }); + it("stateSnapshots: false never asks the kernel to snapshot or restore (ENG-5342)", async () => { + const marker = join(tempDir, "snapshot-flushed"); + const snapshotDir = join(tempDir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + const python = writeFakeReplRuntime(marker); + const provisioner = new IpythonKernelProvisioner(tempDir, { python, snapshotDir, stateSnapshots: false }); + try { + await expect(provisioner.ensure()).rejects.toThrow(/Failed to initialize rlm runtime/); + // The teardown flushes a final snapshot only when one is configured. + expect(existsSync(marker)).toBe(false); + expect(existsSync(join(snapshotDir, "kernel-state.dill"))).toBe(false); + // The stderr log still lives in the artifact dir. + expect(existsSync(join(snapshotDir, "kernel-stderr.log"))).toBe(true); + } finally { + await provisioner.dispose(); + } + }); + it("memoizes concurrent ensure() calls into one startup", async () => { const { python, countRuns } = writeFakePython(); const provisioner = new IpythonKernelProvisioner(tempDir, { python }); diff --git a/packages/coding-agent/test/kernel-env.test.ts b/packages/coding-agent/test/kernel-env.test.ts new file mode 100644 index 0000000000..d1d2d26ba2 --- /dev/null +++ b/packages/coding-agent/test/kernel-env.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + buildKernelEnv, + collectCredentialDigests, + credentialDigest, + droppedCredentialEnvNames, + isCredentialEnvName, +} from "../src/core/kernel/kernel-env.js"; + +// ENG-5342: the kernel used to inherit the whole host environment, so provider +// keys were readable from any cell and ended up in the state snapshot. +describe("buildKernelEnv", () => { + const host: NodeJS.ProcessEnv = { + PATH: "/usr/bin:/bin", + HOME: "/home/tester", + LANG: "en_US.UTF-8", + LC_ALL: "C.UTF-8", + TMPDIR: "/tmp", + TERM: "xterm-256color", + RLM_MAX_DEPTH: "3", + PRIME_AGENT_KERNEL_VENV: "/home/tester/.prime/agent/kernel-venv", + PYTHONPATH: "/opt/lib", + PRIME_TEAM_ID: "team-123", + PRIME_API_KEY: "sk-synthetic-prime-5342", + OPENAI_API_KEY: "sk-synthetic-openai-5342", + ANTHROPIC_OAUTH_TOKEN: "oauth-synthetic-5342", + AWS_SECRET_ACCESS_KEY: "aws-synthetic-secret-5342", + PRIME_AGENT_TRACES_API_KEY: "traces-synthetic-5342", + MY_SERVICE_TOKEN: "custom-synthetic-token-5342", + DATABASE_URL: "postgres://localhost/app", + NPM_TOKEN: "npm-synthetic-token", + }; + + it("keeps the runtime and shell essentials and drops everything else", () => { + const env = buildKernelEnv(host, {}, { platform: "linux" }); + expect(env).toMatchObject({ + PATH: "/usr/bin:/bin", + HOME: "/home/tester", + LANG: "en_US.UTF-8", + LC_ALL: "C.UTF-8", + TMPDIR: "/tmp", + TERM: "xterm-256color", + RLM_MAX_DEPTH: "3", + PRIME_AGENT_KERNEL_VENV: "/home/tester/.prime/agent/kernel-venv", + PYTHONPATH: "/opt/lib", + PRIME_TEAM_ID: "team-123", + }); + expect(env).not.toHaveProperty("DATABASE_URL"); + }); + + it("never passes provider or ambient credentials, even under an allowlisted prefix", () => { + const env = buildKernelEnv(host, {}, { platform: "linux" }); + for (const name of [ + "PRIME_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_OAUTH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "PRIME_AGENT_TRACES_API_KEY", + "MY_SERVICE_TOKEN", + "NPM_TOKEN", + ]) { + expect(env, name).not.toHaveProperty(name); + } + }); + + it("layers the session's injected variables verbatim on top", () => { + const env = buildKernelEnv( + host, + { RLM_DEPTH: "1", PRIME_AGENT_BASH_SHELL: "/bin/bash", SERPER_API_KEY: "serper-synthetic-5342" }, + { platform: "linux" }, + ); + expect(env.RLM_DEPTH).toBe("1"); + expect(env.PRIME_AGENT_BASH_SHELL).toBe("/bin/bash"); + expect(env.SERPER_API_KEY).toBe("serper-synthetic-5342"); + }); + + it("honours passthrough names and PREFIX* globs, including credential names the user opted in", () => { + const env = buildKernelEnv( + host, + {}, + { platform: "linux", passthrough: ["DATABASE_URL", "PRIME_API_KEY", "NPM_*"] }, + ); + expect(env.DATABASE_URL).toBe("postgres://localhost/app"); + expect(env.PRIME_API_KEY).toBe("sk-synthetic-prime-5342"); + expect(env.NPM_TOKEN).toBe("npm-synthetic-token"); + expect(env).not.toHaveProperty("OPENAI_API_KEY"); + }); + + it("matches names case-insensitively on Windows", () => { + const env = buildKernelEnv( + { Path: "C:\\Windows", SystemRoot: "C:\\Windows", ComSpec: "cmd.exe", Openai_Api_Key: "x".repeat(20) }, + {}, + { platform: "win32" }, + ); + expect(env).toMatchObject({ Path: "C:\\Windows", SystemRoot: "C:\\Windows", ComSpec: "cmd.exe" }); + expect(env).not.toHaveProperty("Openai_Api_Key"); + }); + + it("reports which credential names were dropped", () => { + expect(droppedCredentialEnvNames(host, { platform: "linux" })).toEqual([ + "ANTHROPIC_OAUTH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "MY_SERVICE_TOKEN", + "NPM_TOKEN", + "OPENAI_API_KEY", + "PRIME_AGENT_TRACES_API_KEY", + "PRIME_API_KEY", + ]); + expect(droppedCredentialEnvNames(host, { platform: "linux", passthrough: ["PRIME_API_KEY"] })).not.toContain( + "PRIME_API_KEY", + ); + }); +}); + +describe("isCredentialEnvName", () => { + it("recognises the documented provider variables and generic credential shapes", () => { + for (const name of ["PRIME_API_KEY", "GH_TOKEN", "HF_TOKEN", "AWS_SESSION_TOKEN", "FOO_SECRET", "DB_PASSWORD"]) { + expect(isCredentialEnvName(name), name).toBe(true); + } + for (const name of ["PATH", "HOME", "RLM_SESSION_DIR", "PRIME_TEAM_ID", "GIT_SSH_COMMAND", "TERM_PROGRAM"]) { + expect(isCredentialEnvName(name), name).toBe(false); + } + }); +}); + +describe("collectCredentialDigests", () => { + it("hashes credential values (and their trimmed form) and ignores short or non-credential values", () => { + const digests = collectCredentialDigests({ + PRIME_API_KEY: " sk-synthetic-prime-5342\n", + PATH: "/usr/bin", + SHORT_TOKEN: "abc", + }); + expect(digests).toEqual( + [credentialDigest(" sk-synthetic-prime-5342\n"), credentialDigest("sk-synthetic-prime-5342")].sort(), + ); + expect(digests).not.toContain(credentialDigest("/usr/bin")); + expect(digests).not.toContain(credentialDigest("abc")); + }); + + it("produces lowercase sha256 hex digests, as the runtime expects", () => { + expect(credentialDigest("sk-synthetic-prime-5342")).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/packages/coding-agent/test/repl-kernel-secret-retention.test.ts b/packages/coding-agent/test/repl-kernel-secret-retention.test.ts new file mode 100644 index 0000000000..0cb688215e --- /dev/null +++ b/packages/coding-agent/test/repl-kernel-secret-retention.test.ts @@ -0,0 +1,124 @@ +// ENG-5342: provider credentials must not reach the kernel through the host +// environment, and a name holding a known credential value must not be pickled +// into the session's kernel-state snapshot. +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ReplKernelManager } from "../src/core/kernel/index.js"; + +function resolveReplPython(): string | null { + const candidates = [ + process.env.PRIME_AGENT_KERNEL_PYTHON, + resolve(__dirname, "..", "..", "..", "prime-agent-runtime", ".venv", "bin", "python"), + join(homedir(), ".prime", "agent", "kernel-venv", "bin", "python"), + ].filter((p): p is string => Boolean(p)); + for (const python of candidates) { + if (!existsSync(python)) continue; + const check = spawnSync(python, ["-c", "import rlm.repl, dill"], { encoding: "utf8" }); + if (check.status === 0) return python; + } + return null; +} + +const python = resolveReplPython(); +const describeIf = python && process.platform !== "win32" ? describe : describe.skip; + +const SYNTHETIC_PRIME_KEY = "sk-synthetic-prime-5342-do-not-use"; +const SYNTHETIC_OPENAI_KEY = "sk-synthetic-openai-5342-do-not-use"; + +describeIf("repl kernel secret retention (real runtime)", { tags: ["kernel-heavy"] }, () => { + let dir = ""; + let manager: ReplKernelManager | undefined; + let savedPrime: string | undefined; + let savedOpenai: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "prime-agent-repl-secrets-")); + savedPrime = process.env.PRIME_API_KEY; + savedOpenai = process.env.OPENAI_API_KEY; + process.env.PRIME_API_KEY = SYNTHETIC_PRIME_KEY; + process.env.OPENAI_API_KEY = SYNTHETIC_OPENAI_KEY; + }); + + afterEach(async () => { + if (savedPrime === undefined) delete process.env.PRIME_API_KEY; + else process.env.PRIME_API_KEY = savedPrime; + if (savedOpenai === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = savedOpenai; + await manager?.shutdown({ snapshot: false, drainHostRequests: true }); + manager = undefined; + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it("does not expose host provider credentials to kernel cells or bash cells", async () => { + manager = new ReplKernelManager({ python: python as string, cwd: dir, env: { RLM_DEPTH: "0" } }); + const probe = await manager.execute( + [ + "import os, json", + "print(json.dumps({", + " 'prime': os.environ.get('PRIME_API_KEY'),", + " 'openai': os.environ.get('OPENAI_API_KEY'),", + " 'path': bool(os.environ.get('PATH')),", + " 'home': bool(os.environ.get('HOME')),", + " 'rlm_depth': os.environ.get('RLM_DEPTH'),", + " 'owner': bool(os.environ.get('PRIME_AGENT_KERNEL_OWNER_PID')),", + " 'credential_like': sorted(k for k in os.environ if any(t in k for t in ('API_KEY', 'TOKEN', 'SECRET'))),", + "}))", + ].join("\n"), + ); + expect(probe.status).toBe("ok"); + const seen = JSON.parse(probe.stdout.trim()) as Record; + expect(seen.prime).toBeNull(); + expect(seen.openai).toBeNull(); + expect(seen.credential_like).toEqual([]); + expect(seen.path).toBe(true); + expect(seen.home).toBe(true); + expect(seen.rlm_depth).toBe("0"); + expect(seen.owner).toBe(true); + + // bash() children inherit the kernel env, so they run (PATH) without the key. + const shell = await manager.execute( + "import subprocess\nprint(subprocess.run(['sh', '-c', 'if [ -z \"$PRIME_API_KEY\" ]; then echo prime=unset; else echo prime=set; fi; command -v sh >/dev/null && echo path-ok'], capture_output=True, text=True).stdout)", + ); + expect(shell.status).toBe("ok"); + expect(shell.stdout).toContain("prime=unset"); + expect(shell.stdout).toContain("path-ok"); + }); + + it("skips top-level names holding a known host credential value when snapshotting", async () => { + const snapshotPath = join(dir, "kernel-state.dill"); + const manifestPath = join(dir, "kernel-state.json"); + manager = new ReplKernelManager({ + python: python as string, + cwd: dir, + snapshot: { path: snapshotPath, manifestPath }, + }); + // The value arrives through some other channel (a config file, a paste); the + // host still recognises it as the credential it saw in its own environment. + await manager.execute( + `launcher_key = ${JSON.stringify(SYNTHETIC_PRIME_KEY)}\npadded_key = ${JSON.stringify(` ${SYNTHETIC_OPENAI_KEY}\n`)}\nkey_bytes = ${JSON.stringify(SYNTHETIC_PRIME_KEY)}.encode()\nplain = 'not a secret at all'\nnumber = 42`, + ); + const snap = await manager.snapshotState(); + expect(snap).not.toBeNull(); + expect(snap?.saved).toEqual(expect.arrayContaining(["plain", "number"])); + expect(snap?.saved).not.toContain("launcher_key"); + expect(snap?.saved).not.toContain("padded_key"); + expect(snap?.saved).not.toContain("key_bytes"); + const skipped = new Map(snap?.skipped.map((entry) => [entry.name, entry.reason])); + for (const name of ["launcher_key", "padded_key", "key_bytes"]) { + expect(skipped.get(name), name).toBe("matches a credential from the host environment"); + } + + const dill = readFileSync(snapshotPath); + expect(dill.includes(SYNTHETIC_PRIME_KEY)).toBe(false); + expect(dill.includes(SYNTHETIC_OPENAI_KEY)).toBe(false); + expect(dill.includes("not a secret at all")).toBe(true); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { savedNames: string[]; skipped: unknown[] }; + expect(manifest.savedNames).not.toContain("launcher_key"); + expect(manifest.skipped).toEqual( + expect.arrayContaining([{ name: "launcher_key", reason: "matches a credential from the host environment" }]), + ); + }); +}); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index f26c1028bf..dcd979ea6a 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -681,6 +681,27 @@ describe("SessionManager.setSessionFile with corrupted files", () => { } }); + it.skipIf(process.platform === "win32")( + "creates new transcripts owner-only and keeps an existing file's mode (ENG-5342)", + () => { + const sessionsDir = join(tempDir, "sessions"); + const sm = SessionManager.create(tempDir, sessionsDir); + sm.appendMessage({ role: "user", content: "tool output lands here", timestamp: Date.now() }); + sm.flushNow(); + const file = sm.getSessionFile()!; + expect(statSync(file).mode & 0o777).toBe(0o600); + + // Appends and rewrites of an existing transcript never widen or tighten it. + chmodSync(file, 0o640); + sm.appendMessage({ role: "user", content: "second", timestamp: Date.now() }); + sm.flushNow(); + expect(statSync(file).mode & 0o777).toBe(0o640); + + const forked = SessionManager.forkFrom(file, tempDir, sessionsDir); + expect(statSync(forked.getSessionFile()!).mode & 0o777).toBe(0o600); + }, + ); + it("truncates and rewrites empty file with valid header", () => { const emptyFile = join(tempDir, "empty.jsonl"); writeFileSync(emptyFile, ""); diff --git a/prime-agent-runtime/src/rlm/repl.md b/prime-agent-runtime/src/rlm/repl.md index c0cb012775..4e31409fff 100644 --- a/prime-agent-runtime/src/rlm/repl.md +++ b/prime-agent-runtime/src/rlm/repl.md @@ -30,7 +30,7 @@ event. | `execute` | `{"type":"execute","id":str,"code":str}` | | `interrupt` | `{"type":"interrupt","id"?:str}` — no reply | | `host_reply` | `{"type":"host_reply","id":str,"data":{"status":"ok","result":{...}}}` or an error envelope — no reply | -| `snapshot` | `{"type":"snapshot","id":str,"path":str,"manifest_path":str,"max_bytes"?:int,"max_variable_bytes"?:int,"prune_oversized"?:bool}` | +| `snapshot` | `{"type":"snapshot","id":str,"path":str,"manifest_path":str,"max_bytes"?:int,"max_variable_bytes"?:int,"prune_oversized"?:bool,"redact_sha256"?:[str]}` | | `restore` | `{"type":"restore","id":str,"path":str}` | | `list_names` | `{"type":"list_names","id":str}` | | `shutdown` | `{"type":"shutdown","id"?:str}` | @@ -151,7 +151,11 @@ from the namespace and listed in `pruned`; names skipped for the aggregate payload is written atomically (tmp file + `os.replace`) and a JSON manifest (`version`, `savedNames`, `skipped`, `pruned`, `bytes`, `pythonVersion`, `timestamp`) is written to `manifest_path`. A manifest write failure fails the -snapshot (and nothing is pruned). +snapshot (and nothing is pruned). `redact_sha256` (optional, a list of lowercase +SHA-256 hex digests) names credential values the host saw in its environment at +spawn: a top-level `str`/`bytes` value whose digest (or its stripped form's) +matches is skipped with reason `matches a credential from the host environment` +and never written to the payload. The host sends digests only, never the values. `restore` loads the payload and revives each name independently; a missing file yields an ok empty restore with `reason:"snapshot not found"`, a corrupt diff --git a/prime-agent-runtime/src/rlm/repl.py b/prime-agent-runtime/src/rlm/repl.py index d3324be774..94bdaa32f8 100644 --- a/prime-agent-runtime/src/rlm/repl.py +++ b/prime-agent-runtime/src/rlm/repl.py @@ -12,6 +12,7 @@ import codecs import contextvars import ctypes +import hashlib import inspect import io import json @@ -40,6 +41,7 @@ _ALWAYS_SKIP = {"rlm", "mcp", "bash", "asyncio", "In", "Out", "get_ipython", "exit", "quit", "open"} # IPython-injected names that may appear in a snapshot payload; never restored. _RESTORE_SKIP = {"In", "Out", "get_ipython"} +_CREDENTIAL_SKIP_REASON = "matches a credential from the host environment" _protocol_fd: int = -1 _write_lock = threading.Lock() @@ -611,6 +613,19 @@ class _SnapshotSizeLimitExceeded(Exception): pass +def _credential_digests(value: Any) -> tuple[str, ...]: + """SHA-256 hex digests of a str/bytes value (and its stripped form) for redaction matching.""" + if isinstance(value, str): + candidates = [value] + stripped = value.strip() + if stripped != value: + candidates.append(stripped) + return tuple(hashlib.sha256(c.encode("utf-8", "surrogatepass")).hexdigest() for c in candidates) + if isinstance(value, (bytes, bytearray)): + return (hashlib.sha256(bytes(value)).hexdigest(),) + return () + + class _CappedWriter: def __init__(self, sink: Any, limit: int) -> None: self._sink = sink @@ -634,6 +649,7 @@ def _snapshot_state( max_variable_bytes: int, prune_oversized: bool, committed: list[dict[str, Any]] | None = None, + redact_sha256: frozenset[str] = frozenset(), ) -> dict[str, Any]: import datetime @@ -656,6 +672,11 @@ def _snapshot_state( # A background thread deleted the name after the key listing. skipped.append({"name": name, "reason": "deleted during snapshot"}) continue + if redact_sha256 and any(d in redact_sha256 for d in _credential_digests(value)): + # The host only shares digests of the credentials it saw at spawn; a + # name holding one of those values must never be persisted to disk. + skipped.append({"name": name, "reason": _CREDENTIAL_SKIP_REASON}) + continue remaining = max_bytes - total limit = max_variable_bytes if prune_oversized else min(max_variable_bytes, remaining) buffer = io.BytesIO() @@ -861,6 +882,11 @@ async def run() -> dict[str, Any]: # realpath resolves symlinks, so aliased paths cannot silently clobber the payload. if os.path.realpath(req["path"]) == os.path.realpath(req["manifest_path"]): return {"error": "path and manifest_path must differ"} + redact = req.get("redact_sha256", []) + if not isinstance(redact, list) or not all( + isinstance(d, str) and len(d) == 64 and all(c in "0123456789abcdef" for c in d) for d in redact + ): + return {"error": "redact_sha256 must be a list of lowercase sha256 hex digests"} return _snapshot_state( ns, req["path"], @@ -869,6 +895,7 @@ async def run() -> dict[str, Any]: req.get("max_variable_bytes", DEFAULT_SNAPSHOT_MAX_VARIABLE_BYTES), prune, committed, + frozenset(redact), ) return _restore_state(ns, req["path"], committed) diff --git a/prime-agent-runtime/test/test_repl.py b/prime-agent-runtime/test/test_repl.py index 6a2b5daf5b..6bb6ba9f09 100644 --- a/prime-agent-runtime/test/test_repl.py +++ b/prime-agent-runtime/test/test_repl.py @@ -616,6 +616,75 @@ def test_snapshot_restore_roundtrip(self): self.assertEqual(one(events, "result")["text"], "42") self.assertEqual(fresh.shutdown(), 0) + def test_snapshot_skips_names_matching_host_credential_digests(self): + # ENG-5342: the host shares digests of the credentials it saw at spawn; a + # top-level str/bytes holding one of those values is never persisted. + import hashlib + + secret = "sk-synthetic-prime-5342-do-not-use" + digest = hashlib.sha256(secret.encode()).hexdigest() + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "kernel-state.dill") + manifest_path = os.path.join(tmp, "kernel-state.json") + setup = "\n".join( + [ + f"launcher_key = {secret!r}", + f"padded_key = {' ' + secret + chr(10)!r}", + f"key_bytes = {secret!r}.encode()", + "plain = 'not a secret'", + "number = 42", + ] + ) + self.assertEqual(one(self.repl.execute("c1", setup), "done")["status"], "ok") + self.repl.send( + { + "type": "snapshot", + "id": "c2", + "path": path, + "manifest_path": manifest_path, + "redact_sha256": [digest], + } + ) + done = one(self.repl.until_done("c2"), "done") + self.assertEqual(done["status"], "ok") + self.assertEqual(sorted(done["saved"]), ["number", "plain"]) + self.assertEqual( + sorted((s["name"], s["reason"]) for s in done["skipped"]), + [ + (name, "matches a credential from the host environment") + for name in ("key_bytes", "launcher_key", "padded_key") + ], + ) + with open(path, "rb") as fh: + payload = fh.read() + self.assertNotIn(secret.encode(), payload) + with open(manifest_path) as fh: + manifest = json.load(fh) + self.assertEqual(manifest["savedNames"], ["number", "plain"]) + self.assertIn({"name": "launcher_key", "reason": "matches a credential from the host environment"}, manifest["skipped"]) + # The variables stay live in the namespace; only persistence is refused. + self.assertEqual(one(self.repl.execute("c3", "launcher_key == %r" % secret), "result")["text"], "True") + + def test_snapshot_rejects_malformed_redact_digests(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "kernel-state.dill") + manifest_path = os.path.join(tmp, "kernel-state.json") + self.repl.execute("m1", "x = 1") + for bad in ("not-a-list", ["short"], [123]): + self.repl.send( + { + "type": "snapshot", + "id": f"m2-{len(str(bad))}", + "path": path, + "manifest_path": manifest_path, + "redact_sha256": bad, + } + ) + done = one(self.repl.until_done(f"m2-{len(str(bad))}"), "done") + self.assertEqual(done["status"], "error") + self.assertIn("redact_sha256", done["reason"]) + self.assertFalse(os.path.exists(path)) + def test_restore_skips_ipython_injected_names(self): import dill