diff --git a/docs/help/environment.md b/docs/help/environment.md index c50c46be8582..f4e6da6a8b58 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -106,11 +106,10 @@ Env var equivalents: ## Exec shell snapshots -`OPENCLAW_EXEC_SHELL_SNAPSHOT=1` is an opt-in gateway exec optimization for bash and zsh. Set it in the Gateway -process environment; per-call `exec.env` values cannot enable it or redirect its cache directory. When enabled, -OpenClaw captures sourceable aliases/functions and a small safe environment set from shell startup files into -`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each `exec` command. Secret-looking -variables are excluded from the snapshot. Sandbox and node exec do not use it. +On non-Windows Gateway hosts, bash and zsh `exec` commands use a startup snapshot by default. +Set `OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this path. +Values `false`, `no`, and `off` also disable it. Per-call `exec.env` values cannot toggle +snapshots or redirect the snapshot cache. ## Runtime-injected env vars diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 4329d49170c8..b6650dbe36a0 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -80,11 +80,11 @@ Notes: from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists. - On Windows hosts, exec prefers PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, then PATH), then falls back to Windows PowerShell 5.1. -- On non-Windows gateway hosts, setting `OPENCLAW_EXEC_SHELL_SNAPSHOT=1` in the Gateway process environment - enables an opt-in startup snapshot for bash and zsh. OpenClaw captures sourceable aliases/functions and a small - safe environment set from shell startup files into `$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources - that snapshot before each exec command. Secret-looking variables are excluded; sandbox and node exec do not use - this snapshot. +- On non-Windows gateway hosts, bash and zsh exec commands use a startup snapshot. OpenClaw captures sourceable + aliases/functions and a small safe environment set from shell startup files into + `$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command. + Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set + `OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. - OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context. diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index be3a4a2bffb0..ca290223fea8 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -829,7 +829,6 @@ export async function runExecProcess(opts: { shellArgs, cwd: opts.workdir, env: shellRuntimeEnv, - enabled: true, }); const childArgv = [shell, ...shellArgs, commandWithShellSnapshot]; diff --git a/src/agents/shell-snapshot.test.ts b/src/agents/shell-snapshot.test.ts index 155db5373b96..0cca31123be5 100644 --- a/src/agents/shell-snapshot.test.ts +++ b/src/agents/shell-snapshot.test.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { - EXEC_SHELL_SNAPSHOT_ENV, maybeWrapCommandWithShellSnapshot, resetShellSnapshotCacheForTests, resolveShellSnapshotDir, @@ -13,6 +12,7 @@ import { import { getPosixShellArgs, resolveShellFromPath } from "./shell-utils.js"; const isWin = process.platform === "win32"; +const EXEC_SHELL_SNAPSHOT_ENV = "OPENCLAW_EXEC_SHELL_SNAPSHOT"; function resolveBashForTest(): string | null { if (isWin) { @@ -34,9 +34,19 @@ function resolveZshForTest(): string | null { return resolveShellFromPath("zsh") ?? null; } -function enableSnapshotForTest(stateDir: string): void { +function setSnapshotStateForTest( + stateDir: string, + options: { home?: string; zdotdir?: string } = {}, +): void { process.env.OPENCLAW_STATE_DIR = stateDir; - process.env[EXEC_SHELL_SNAPSHOT_ENV] = "1"; + if (options.home) { + process.env.HOME = options.home; + } + if (options.zdotdir) { + process.env.ZDOTDIR = options.zdotdir; + } else { + delete process.env.ZDOTDIR; + } } describe("exec shell snapshots", () => { @@ -44,7 +54,13 @@ describe("exec shell snapshots", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["HOME", "OPENCLAW_STATE_DIR", EXEC_SHELL_SNAPSHOT_ENV]); + envSnapshot = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_EXEC_SHELL_SNAPSHOT", + "PNPM_HOME", + "ZDOTDIR", + ]); }); afterEach(() => { @@ -55,40 +71,82 @@ describe("exec shell snapshots", () => { } }); - it("leaves commands unchanged unless explicitly enabled", async () => { + it("leaves commands unchanged for unsupported shells", async () => { const command = "echo unchanged"; const wrapped = await maybeWrapCommandWithShellSnapshot({ command, - shell: "/bin/bash", - shellArgs: ["--noprofile", "--norc", "-c"], + shell: "/bin/fish", + shellArgs: ["-c"], cwd: os.tmpdir(), env: {}, - enabled: true, }); expect(wrapped).toBe(command); }); - it("does not honor per-call env for enabling snapshots or selecting state dir", async () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-untrusted-state-")); - tempDirs.push(stateDir); + it("leaves commands unchanged when trusted process env disables snapshots", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-state-")); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-disabled-home-")); + tempDirs.push(stateDir, home); + setSnapshotStateForTest(stateDir, { home }); + process.env[EXEC_SHELL_SNAPSHOT_ENV] = "0"; const command = "echo unchanged"; const wrapped = await maybeWrapCommandWithShellSnapshot({ command, shell: "/bin/bash", - shellArgs: ["--noprofile", "--norc", "-c"], + shellArgs: ["-c"], cwd: os.tmpdir(), env: { - OPENCLAW_STATE_DIR: stateDir, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", + ...process.env, }, - enabled: true, }); expect(wrapped).toBe(command); expect(fs.existsSync(resolveShellSnapshotDir({ OPENCLAW_STATE_DIR: stateDir }))).toBe(false); }); + it("does not honor per-call env for selecting the snapshot state dir", async () => { + const trustedStateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-snapshot-trusted-state-"), + ); + const untrustedStateDir = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-snapshot-untrusted-state-"), + ); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-state-home-")); + const untrustedHome = fs.mkdtempSync( + path.join(os.tmpdir(), "openclaw-snapshot-untrusted-home-"), + ); + const sideEffectPath = path.join(untrustedHome, "side-effect"); + tempDirs.push(trustedStateDir, untrustedStateDir, home, untrustedHome); + setSnapshotStateForTest(trustedStateDir, { home }); + fs.writeFileSync( + path.join(untrustedHome, ".bashrc"), + `touch ${JSON.stringify(sideEffectPath)}\n`, + ); + const command = "echo unchanged"; + const wrapped = await maybeWrapCommandWithShellSnapshot({ + command, + shell: "/bin/bash", + shellArgs: ["-c"], + cwd: os.tmpdir(), + env: { + ...process.env, + HOME: untrustedHome, + [EXEC_SHELL_SNAPSHOT_ENV]: "0", + OPENCLAW_STATE_DIR: untrustedStateDir, + }, + }); + + expect(wrapped).not.toBe(command); + expect(fs.existsSync(resolveShellSnapshotDir({ OPENCLAW_STATE_DIR: untrustedStateDir }))).toBe( + false, + ); + expect(fs.existsSync(resolveShellSnapshotDir({ OPENCLAW_STATE_DIR: trustedStateDir }))).toBe( + true, + ); + expect(fs.existsSync(sideEffectPath)).toBe(false); + }); + it("captures bash startup aliases, functions, and safe environment without secrets", async () => { const bash = resolveBashForTest(); if (!bash) { @@ -99,12 +157,13 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); fs.writeFileSync( path.join(home, ".bashrc"), [ "alias oc_snap_alias='printf alias-ok'", 'alias oc_snap_secret="printf $OPENAI_API_KEY"', + '[ "$OPENCLAW_SHELL" = exec ] && alias oc_snap_exec_alias="printf marker-ok"', "oc_snap_fn() { printf fn-ok; }", 'export PATH="/snapshot/bin:$PATH"', 'export OPENAI_API_KEY="snapshot-secret"', @@ -116,18 +175,17 @@ describe("exec shell snapshots", () => { ...process.env, HOME: home, OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_SHELL: "exec", OPENAI_API_KEY: "inherited-secret", - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const shellArgs = getPosixShellArgs(bash); const wrapped = await maybeWrapCommandWithShellSnapshot({ command: - "oc_snap_fn; printf ' '; oc_snap_alias; printf ' '; case \":$PATH:\" in *:/snapshot/bin:*) printf path-ok;; *) printf path-missing;; esac", + "oc_snap_fn; printf ' '; oc_snap_alias; printf ' '; oc_snap_exec_alias; printf ' '; case \":$PATH:\" in *:/snapshot/bin:*) printf path-ok;; *) printf path-missing;; esac", shell: bash, shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(bash, [...shellArgs, wrapped], { @@ -138,7 +196,7 @@ describe("exec shell snapshots", () => { }); expect(result.status).toBe(0); - expect(result.stdout).toBe("fn-ok alias-ok path-ok"); + expect(result.stdout).toBe("fn-ok alias-ok marker-ok path-ok"); const snapshotFiles = fs .readdirSync(resolveShellSnapshotDir(env)) @@ -165,7 +223,7 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-interactive-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-interactive-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); fs.writeFileSync( path.join(home, ".bashrc"), [ @@ -181,7 +239,6 @@ describe("exec shell snapshots", () => { const env = { ...process.env, HOME: home, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const shellArgs = getPosixShellArgs(bash); const wrapped = await maybeWrapCommandWithShellSnapshot({ @@ -190,7 +247,6 @@ describe("exec shell snapshots", () => { shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(bash, [...shellArgs, wrapped], { cwd, @@ -203,7 +259,7 @@ describe("exec shell snapshots", () => { expect(result.stdout).toBe("interactive-ok"); }); - it("does not reuse captured safe env exports across different exec environments", async () => { + it("preserves per-call safe env overrides after trusted capture", async () => { const bash = resolveBashForTest(); if (!bash) { return; @@ -213,7 +269,8 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-env-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-env-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); + process.env.PNPM_HOME = "/trusted"; fs.writeFileSync(path.join(home, ".bashrc"), 'export PNPM_HOME="${PNPM_HOME}/from-rc"\n'); const shellArgs = getPosixShellArgs(bash); @@ -223,7 +280,6 @@ describe("exec shell snapshots", () => { HOME: home, OPENCLAW_STATE_DIR: stateDir, PNPM_HOME: pnpmHome, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const wrapped = await maybeWrapCommandWithShellSnapshot({ command: 'printf "%s" "$PNPM_HOME"', @@ -231,7 +287,6 @@ describe("exec shell snapshots", () => { shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(bash, [...shellArgs, wrapped], { cwd, @@ -243,8 +298,8 @@ describe("exec shell snapshots", () => { return result.stdout; }; - await expect(runWithPnpmHome("/first")).resolves.toBe("/first/from-rc"); - await expect(runWithPnpmHome("/second")).resolves.toBe("/second/from-rc"); + await expect(runWithPnpmHome("/first")).resolves.toBe("/first"); + await expect(runWithPnpmHome("/second")).resolves.toBe("/second"); }); it("does not let non-fingerprinted env change captured shell state", async () => { @@ -257,7 +312,7 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-branch-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-branch-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); fs.writeFileSync( path.join(home, ".bashrc"), [ @@ -275,7 +330,6 @@ describe("exec shell snapshots", () => { HOME: home, OPENCLAW_STATE_DIR: stateDir, VIRTUAL_ENV: "/tmp/venv", - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const shellArgs = getPosixShellArgs(bash); const wrapped = await maybeWrapCommandWithShellSnapshot({ @@ -284,7 +338,6 @@ describe("exec shell snapshots", () => { shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(bash, [...shellArgs, wrapped], { cwd, @@ -317,7 +370,7 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-refresh-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-refresh-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); const aliasPath = path.join(home, ".bash_aliases"); fs.writeFileSync(path.join(home, ".bashrc"), `. ${JSON.stringify(aliasPath)}\n`); fs.writeFileSync(aliasPath, "alias oc_refresh_alias='printf old'\n"); @@ -325,7 +378,6 @@ describe("exec shell snapshots", () => { const env = { ...process.env, HOME: home, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const shellArgs = getPosixShellArgs(bash); const runAlias = async (): Promise => { @@ -335,7 +387,6 @@ describe("exec shell snapshots", () => { shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(bash, [...shellArgs, wrapped], { cwd, @@ -369,7 +420,7 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-secret-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-secret-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); fs.writeFileSync( path.join(home, ".bashrc"), [ @@ -383,7 +434,6 @@ describe("exec shell snapshots", () => { ...process.env, HOME: home, OPENCLAW_STATE_DIR: stateDir, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const command = "echo fallback"; const wrapped = await maybeWrapCommandWithShellSnapshot({ @@ -392,7 +442,6 @@ describe("exec shell snapshots", () => { shellArgs: getPosixShellArgs(bash), cwd, env, - enabled: true, }); expect(wrapped).toBe(command); @@ -413,7 +462,7 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zsh-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zsh-cwd-")); tempDirs.push(home, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home }); fs.writeFileSync( path.join(home, ".zshrc"), [ @@ -427,7 +476,6 @@ describe("exec shell snapshots", () => { ...process.env, HOME: home, OPENCLAW_STATE_DIR: stateDir, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const shellArgs = getPosixShellArgs(zsh); const wrapped = await maybeWrapCommandWithShellSnapshot({ @@ -436,7 +484,6 @@ describe("exec shell snapshots", () => { shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(zsh, [...shellArgs, wrapped], { @@ -461,7 +508,7 @@ describe("exec shell snapshots", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zdot-state-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-zdot-cwd-")); tempDirs.push(home, zdotdir, stateDir, cwd); - enableSnapshotForTest(stateDir); + setSnapshotStateForTest(stateDir, { home, zdotdir }); fs.writeFileSync(path.join(home, ".zshrc"), "alias oc_snap_zdot_alias='printf wrong-home'\n"); fs.writeFileSync( path.join(zdotdir, ".zshrc"), @@ -475,7 +522,6 @@ describe("exec shell snapshots", () => { HOME: home, OPENCLAW_STATE_DIR: stateDir, ZDOTDIR: zdotdir, - [EXEC_SHELL_SNAPSHOT_ENV]: "1", }; const shellArgs = getPosixShellArgs(zsh); const wrapped = await maybeWrapCommandWithShellSnapshot({ @@ -484,7 +530,6 @@ describe("exec shell snapshots", () => { shellArgs, cwd, env, - enabled: true, }); const result = spawnSync(zsh, [...shellArgs, wrapped], { diff --git a/src/agents/shell-snapshot.ts b/src/agents/shell-snapshot.ts index d7692dcf63ce..874f559a4a30 100644 --- a/src/agents/shell-snapshot.ts +++ b/src/agents/shell-snapshot.ts @@ -7,15 +7,15 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { killProcessTree } from "../process/kill-tree.js"; -export const EXEC_SHELL_SNAPSHOT_ENV = "OPENCLAW_EXEC_SHELL_SNAPSHOT"; - const SNAPSHOT_VERSION = 1; const SNAPSHOT_REFRESH_MS = 5 * 60 * 1000; const SNAPSHOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; const CAPTURE_MARKER = "__OPENCLAW_SHELL_SNAPSHOT_CAPTURE__"; const ENV_MARKER = "__OPENCLAW_SHELL_SNAPSHOT_ENV__"; +const EXEC_SHELL_SNAPSHOT_ENV = "OPENCLAW_EXEC_SHELL_SNAPSHOT"; const VALID_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const SNAPSHOT_SHELLS = new Set(["bash", "zsh"]); +const SNAPSHOT_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); const SAFE_ENV_NAMES = new Set([ "ASDF_DIR", "BUN_INSTALL", @@ -64,7 +64,6 @@ export type ShellSnapshotWrapOptions = { shellArgs: string[]; cwd: string; env: Record; - enabled?: boolean; }; const snapshotCache = new Map< @@ -73,24 +72,26 @@ const snapshotCache = new Map< >(); let cleanupPromise: Promise | null = null; -export function shouldEnableExecShellSnapshot(env: Record): boolean { - const value = env[EXEC_SHELL_SNAPSHOT_ENV]?.trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes" || value === "on"; -} - export async function maybeWrapCommandWithShellSnapshot( opts: ShellSnapshotWrapOptions, ): Promise { - if (!opts.enabled || !shouldEnableExecShellSnapshot(process.env)) { - return opts.command; - } - if (process.platform === "win32" || !isSupportedSnapshotShell(opts.shell, opts.shellArgs)) { + if ( + process.platform === "win32" || + isExecShellSnapshotDisabled(process.env) || + !isSupportedSnapshotShell(opts.shell, opts.shellArgs) + ) { return opts.command; } try { const snapshot = await getOrCreateShellSnapshot(opts); - return snapshot ? buildSnapshotWrappedCommand(opts.command, snapshot.path) : opts.command; + return snapshot + ? buildSnapshotWrappedCommand( + opts.command, + snapshot.path, + buildRuntimeEnvRestoreScript(opts.env), + ) + : opts.command; } catch { return opts.command; } @@ -111,6 +112,11 @@ function isSupportedSnapshotShell(shell: string, shellArgs: string[]): boolean { return shellArgs.includes("-c") && SNAPSHOT_SHELLS.has(path.basename(shell)); } +function isExecShellSnapshotDisabled(env: Record): boolean { + const value = env[EXEC_SHELL_SNAPSHOT_ENV]?.trim().toLowerCase(); + return Boolean(value && SNAPSHOT_DISABLE_VALUES.has(value)); +} + async function getOrCreateShellSnapshot( opts: ShellSnapshotWrapOptions, ): Promise { @@ -126,6 +132,8 @@ async function getOrCreateShellSnapshot( } function buildSnapshotKey(opts: ShellSnapshotWrapOptions): string { + // Snapshot capture executes shell startup files before the approved command. + // Use process-owned roots/env only; per-call exec.env is model-controlled. return createHash("sha256") .update( JSON.stringify({ @@ -133,10 +141,10 @@ function buildSnapshotKey(opts: ShellSnapshotWrapOptions): string { shell: opts.shell, shellArgs: opts.shellArgs, cwd: path.resolve(opts.cwd), - home: opts.env.HOME ?? opts.env.USERPROFILE ?? os.homedir(), + home: getTrustedShellHome(), stateDir: resolveStateDir(process.env), - env: buildSafeEnvSignature(opts.env), - startup: buildStartupSignature(opts), + env: buildSafeEnvSignature(process.env), + startup: buildStartupSignature(opts.shell), }), ) .digest("hex"); @@ -150,20 +158,16 @@ function buildSafeEnvSignature( .map((key): [string, string | null] => [key, env[key] ?? null]); } -function buildStartupSignature( - opts: ShellSnapshotWrapOptions, -): Array<[string, number, number] | [string, null]> { - const shellName = path.basename(opts.shell); - const home = opts.env.HOME ?? opts.env.USERPROFILE ?? os.homedir(); - const zdotdir = opts.env.ZDOTDIR?.trim() || home; +function buildStartupSignature(shell: string): Array<[string, number, number] | [string, null]> { + const shellName = path.basename(shell); + const home = getTrustedShellHome(); + const zdotdir = process.env.ZDOTDIR?.trim() || home; const candidates = shellName === "zsh" ? [path.join(zdotdir, ".zshrc")] : shellName === "bash" ? [path.join(home, ".bashrc")] - : opts.env.ENV - ? [opts.env.ENV] - : []; + : []; return candidates.map((candidate) => { try { const stat = statSync(candidate); @@ -174,6 +178,10 @@ function buildStartupSignature( }); } +function getTrustedShellHome(): string { + return process.env.HOME ?? process.env.USERPROFILE ?? os.homedir(); +} + async function createShellSnapshot( opts: ShellSnapshotWrapOptions, key: string, @@ -264,7 +272,7 @@ async function captureShellSnapshot(opts: ShellSnapshotWrapOptions): Promise, +): Record { + const env = buildSnapshotCaptureEnv(process.env); + // OPENCLAW_SHELL is injected by the exec runtime, so startup files can keep + // their documented exec-specific branches without trusting model input. + if (runtimeEnv.OPENCLAW_SHELL === "exec") { + env.OPENCLAW_SHELL = "exec"; + } + return env; +} + function buildStartupSourceScript(shellName: string): string { if (shellName === "zsh") { return `if [ -r "\${ZDOTDIR:-$HOME}/.zshrc" ]; then . "\${ZDOTDIR:-$HOME}/.zshrc"; fi`; @@ -380,13 +400,30 @@ function parseSafeEnvExports(envJson: string): string { .join("\n"); } -function buildSnapshotWrappedCommand(command: string, snapshotPath: string): string { +function buildRuntimeEnvRestoreScript(env: Record): string { + return [...SAFE_ENV_NAMES] + .toSorted() + .filter((key) => env[key] !== process.env[key] && !SECRET_ENV_PATTERN.test(key)) + .map((key) => + typeof env[key] === "string" ? `export ${key}=${shQuote(env[key])}` : `unset ${key}`, + ) + .join("\n"); +} + +function buildSnapshotWrappedCommand( + command: string, + snapshotPath: string, + runtimeEnvRestoreScript: string, +): string { return [ `if [ -r ${shQuote(snapshotPath)} ]; then . ${shQuote(snapshotPath)}; fi`, + runtimeEnvRestoreScript, // Alias expansion happens while shells parse a command. Re-parse the user command // after sourcing the snapshot so zsh/bash aliases captured from startup files work. `eval ${shQuote(command)}`, - ].join("\n"); + ] + .filter((part) => part.trim().length > 0) + .join("\n"); } function shQuote(value: string): string {