From 97d2d40fb75b86a947b335c4fc7d1bed7d59e61a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 00:00:29 +0100 Subject: [PATCH] fix: allow safe exec secret passEnv inheritance --- CHANGELOG.md | 1 + src/commands/daemon-install-helpers.test.ts | 83 +++++++++++++++++++++ src/commands/daemon-install-helpers.ts | 13 +++- 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58177a59e29..2ce01e77a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai - Providers: preserve non-OK `text/event-stream` response bodies so provider HTTP errors keep their JSON detail instead of collapsing to generic streaming failures. Fixes #78180. - Tools/session status: render the active heartbeat/run model for `session_status({"sessionKey":"current"})` instead of falling back to the persisted session default. Fixes #77493. +- Doctor/secrets: allow safe inherited exec SecretRef `passEnv` names such as `HOME` while still blocking dangerous runtime env hooks. Fixes #78216. - Chat commands: make `/model default` reset the session model override instead of treating it as a literal model name. Fixes #78182. - Cron: make rejected `payload.model` errors show the configured `agents.defaults.models` allowlist instead of echoing the rejected model twice. Fixes #79058. - Agents/subagents: retry parent wake announces when the announce-summary model run fails with fallback cooldown exhaustion instead of dropping the wake on the first transient provider overload. Refs #78581. diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 469b8d17f7d..d069a9078b1 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -347,6 +347,89 @@ describe("buildGatewayInstallPlan", () => { expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined(); }); + it("allows safe inherited passEnv names while blocking dangerous exec SecretRef env", async () => { + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + OPENCLAW_PORT: "3000", + }, + }); + + const warn = vi.fn(); + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv({ + BASH_ENV: "/tmp/openclaw-test-bashenv", + XDG_CONFIG_HOME: "/tmp/openclaw-test-xdg-home", + XDG_CONFIG_DIRS: "/etc/xdg:/opt/xdg", + GH_TOKEN: "gh-test-token", + AWS_ACCESS_KEY_ID: "aws-access-key", + DOCKER_HOST: "tcp://docker.example.test:2376", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + }), + port: 3000, + runtime: "node", + warn, + config: { + secrets: { + providers: { + onepassword: { + source: "exec", + command: "/usr/bin/op", + args: ["read", "op://Private/Discord/password"], + passEnv: [ + "HOME", + "BASH_ENV", + "XDG_CONFIG_HOME", + "XDG_CONFIG_DIRS", + "GH_TOKEN", + "AWS_ACCESS_KEY_ID", + "DOCKER_HOST", + "NODE_TLS_REJECT_UNAUTHORIZED", + ], + allowInsecurePath: true, + }, + }, + }, + channels: { + discord: { + token: { source: "exec", provider: "onepassword", id: "value" }, + }, + }, + }, + }); + + expect(plan.environment.HOME).toBe(isolatedHome); + expect(plan.environment.BASH_ENV).toBeUndefined(); + expect(plan.environment.XDG_CONFIG_HOME).toBeUndefined(); + expect(plan.environment.XDG_CONFIG_DIRS).toBeUndefined(); + expect(plan.environment.GH_TOKEN).toBeUndefined(); + expect(plan.environment.AWS_ACCESS_KEY_ID).toBeUndefined(); + expect(plan.environment.DOCKER_HOST).toBeUndefined(); + expect(plan.environment.NODE_TLS_REJECT_UNAUTHORIZED).toBeUndefined(); + expect(warn).not.toHaveBeenCalledWith( + 'Exec SecretRef passEnv ref "HOME" blocked by host-env security policy', + "Config SecretRef", + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("XDG_CONFIG_HOME"), + "Config SecretRef", + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("XDG_CONFIG_DIRS"), + "Config SecretRef", + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("BASH_ENV"), "Config SecretRef"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("GH_TOKEN"), "Config SecretRef"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("AWS_ACCESS_KEY_ID"), + "Config SecretRef", + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("DOCKER_HOST"), "Config SecretRef"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("NODE_TLS_REJECT_UNAUTHORIZED"), + "Config SecretRef", + ); + }); + it("does not include passEnv values for unused exec SecretRef providers", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index aaa79979ebd..345d82623d0 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -60,6 +60,17 @@ const NON_PERSISTED_CONFIG_SECRET_ENV_TARGET_IDS = new Set([ "gateway.auth.password", "gateway.auth.token", ]); +const EXEC_SECRET_REF_PASS_ENV_ALLOWED_OVERRIDE_ONLY_KEYS = new Set(["HOME"]); + +function isBlockedExecSecretRefPassEnvKey(key: string): boolean { + if (isDangerousHostEnvVarName(key)) { + return true; + } + if (!isDangerousHostEnvOverrideVarName(key)) { + return false; + } + return !EXEC_SECRET_REF_PASS_ENV_ALLOWED_OVERRIDE_ONLY_KEYS.has(key.toUpperCase()); +} function loadDaemonInstallAuthProfileSourceRuntime() { daemonInstallAuthProfileSourceRuntimePromise ??= @@ -212,7 +223,7 @@ function collectExecSecretRefPassEnvServiceEnvVars(params: { ); continue; } - if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) { + if (isBlockedExecSecretRefPassEnvKey(key)) { params.warn?.( `Exec SecretRef passEnv ref "${key}" blocked by host-env security policy`, "Config SecretRef",