diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a4a8748..d6eb2d81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -305,6 +305,7 @@ Docs: https://docs.openclaw.ai - Media/mime unknown-kind handling: return `undefined` (not `"unknown"`) for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom `` Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset. - Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so `#`-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting. - Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through `MediaPaths`/`MediaUrls`/`MediaTypes` (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug. +- Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so `env` wrapper stacks cannot reach `/bin/sh -c` execution without the expected approval gate. Thanks @tdjackey for reporting. ## 2026.3.2 diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 95489abe8..aaa56c221 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -509,7 +509,9 @@ function hasEnvManipulationBeforeShellWrapperInternal( depth: number, envManipulationSeen: boolean, ): boolean { - if (depth >= MAX_DISPATCH_WRAPPER_DEPTH) { + // The wrapper found exactly at the configured dispatch depth boundary still needs + // to participate in approval classification; only paths beyond that boundary fail closed. + if (depth > MAX_DISPATCH_WRAPPER_DEPTH) { return false; } @@ -607,7 +609,9 @@ function extractShellWrapperCommandInternal( rawCommand: string | null, depth: number, ): ShellWrapperCommand { - if (depth >= MAX_DISPATCH_WRAPPER_DEPTH) { + // The shell wrapper reached at the boundary depth is still semantically relevant. + // Only deeper wrapper stacks should be dropped as overflow. + if (depth > MAX_DISPATCH_WRAPPER_DEPTH) { return { isWrapper: false, command: null }; } diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 83d64533b..421f3522a 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -54,6 +54,17 @@ describe("system run command helpers", () => { "echo hi", ]), ).toBe("echo hi"); + expect( + extractShellCommandFromArgv([ + "/usr/bin/env", + "/usr/bin/env", + "/usr/bin/env", + "/usr/bin/env", + "/bin/sh", + "-c", + "echo hi", + ]), + ).toBe("echo hi"); }); test("extractShellCommandFromArgv supports fish and pwsh wrappers", () => { diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 3134629af..9b1fbecd8 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -858,6 +858,46 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); + it("denies env-wrapped shell payloads at the dispatch depth boundary", async () => { + if (process.platform === "win32") { + return; + } + const { runCommand, sendInvokeResult, sendNodeEvent } = createInvokeSpies({ + runCommand: vi.fn(async () => { + throw new Error("runCommand should not be called for depth-boundary shell wrappers"); + }), + }); + + await withTempApprovalsHome({ + approvals: createAllowlistOnMissApprovals({ + agents: { + main: { + allowlist: [{ pattern: "/usr/bin/env" }], + }, + }, + }), + run: async ({ tempHome }) => { + const marker = path.join(tempHome, "depth4-pwned.txt"); + await runSystemInvoke({ + preferMacAppExecHost: false, + command: buildNestedEnvShellCommand({ + depth: 4, + payload: `echo PWNED > ${marker}`, + }), + security: "allowlist", + ask: "on-miss", + runCommand, + sendInvokeResult, + sendNodeEvent, + }); + expect(fs.existsSync(marker)).toBe(false); + }, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); + }); + it("denies nested env shell payloads when wrapper depth is exceeded", async () => { if (process.platform === "win32") { return;