From 1de4a099caa12943188af8c2eff733add86bb8cc Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Mon, 20 Jul 2026 14:06:03 +0530 Subject: [PATCH] fix: keep exec approval carriers scoped [AI] (#111652) * fix: unwrap shell argv carriers for exec approvals * fix: constrain shell carrier unwraps * fix: block command path-search carriers --- src/infra/exec-approvals-allow-always.test.ts | 47 ++++++++ src/infra/exec-wrapper-trust-plan.test.ts | 111 +++++++++++++++++- src/infra/exec-wrapper-trust-plan.ts | 80 +++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index 1a5ac351b72..991f6ffc3a1 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -1412,6 +1412,53 @@ $0 \\"$1\\"" touch {marker}`, }); }); + it("prevents allow-always bypass for command argv carrier chains", async () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + makeExecutable(dir, "command"); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const env = makePathEnv(dir); + await expectAllowAlwaysBypassBlocked({ + dir, + firstCommand: "command echo warmup-ok", + secondCommand: "command id > marker", + env, + persistedPattern: echo, + }); + }); + + it("requires approval for command carriers that use default PATH lookup", async () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + makeExecutable(dir, "command"); + const echo = makeExecutable(dir, "echo"); + const env = makePathEnv(dir); + const safeBins = resolveSafeBins(undefined); + + const result = await evaluateShellAllowlistWithAuthorization({ + command: "command -p echo warmup-ok", + allowlist: [{ pattern: echo, source: "allow-always" }], + safeBins, + cwd: dir, + env, + platform: process.platform, + }); + expect(result.allowlistSatisfied).toBe(false); + expect( + requiresExecApproval({ + ask: "on-miss", + security: "allowlist", + analysisOk: result.analysisOk, + allowlistSatisfied: result.allowlistSatisfied, + }), + ).toBe(true); + }); + it("prevents allow-always bypass for time wrapper chains", async () => { if (process.platform === "win32") { return; diff --git a/src/infra/exec-wrapper-trust-plan.test.ts b/src/infra/exec-wrapper-trust-plan.test.ts index 782c862304a..ae55f342e8e 100644 --- a/src/infra/exec-wrapper-trust-plan.test.ts +++ b/src/infra/exec-wrapper-trust-plan.test.ts @@ -4,6 +4,113 @@ import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js"; describe("resolveExecWrapperTrustPlan", () => { test.each([ + { + name: "unwraps command argv carriers before evaluating allowlist policy", + enabled: process.platform !== "win32", + argv: ["command", "curl", "https://example.invalid"], + expected: { + argv: ["curl", "https://example.invalid"], + policyArgv: ["curl", "https://example.invalid"], + wrapperChain: ["command"], + policyBlocked: false, + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, + { + name: "does not unwrap path-qualified command tokens as shell builtins", + enabled: process.platform !== "win32", + argv: ["/tmp/openclaw-test/command", "curl", "https://example.invalid"], + expected: { + argv: ["/tmp/openclaw-test/command", "curl", "https://example.invalid"], + policyArgv: ["/tmp/openclaw-test/command", "curl", "https://example.invalid"], + wrapperChain: [], + policyBlocked: false, + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, + { + name: "does not unwrap command tokens on Windows", + enabled: true, + argv: ["command", "curl", "https://example.invalid"], + platform: "win32" as const, + expected: { + argv: ["command", "curl", "https://example.invalid"], + policyArgv: ["command", "curl", "https://example.invalid"], + wrapperChain: [], + policyBlocked: false, + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, + { + name: "unwraps command argv carriers through transparent dispatch wrappers", + enabled: process.platform !== "win32", + argv: ["env", "command", "--", "python3", "/tmp/run.py"], + expected: { + argv: ["python3", "/tmp/run.py"], + policyArgv: ["python3", "/tmp/run.py"], + wrapperChain: ["env", "command"], + policyBlocked: false, + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, + { + name: "unwraps builtin argv carriers before evaluating allowlist policy", + enabled: process.platform !== "win32", + argv: ["builtin", "printf", "ok"], + expected: { + argv: ["printf", "ok"], + policyArgv: ["printf", "ok"], + wrapperChain: ["builtin"], + policyBlocked: false, + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, + { + name: "unwraps exec argv carriers before evaluating allowlist policy", + enabled: process.platform !== "win32", + argv: ["exec", "-a", "friendly-name", "bash", "/tmp/run.sh"], + expected: { + argv: ["bash", "/tmp/run.sh"], + policyArgv: ["bash", "/tmp/run.sh"], + wrapperChain: ["exec"], + policyBlocked: false, + shellWrapperExecutable: true, + shellInlineCommand: null, + }, + }, + { + name: "fails closed for non-executing command argv carrier queries", + enabled: process.platform !== "win32", + argv: ["command", "-v", "curl"], + expected: { + argv: ["command", "-v", "curl"], + policyArgv: ["command", "-v", "curl"], + wrapperChain: [], + policyBlocked: true, + blockedWrapper: "command", + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, + { + name: "fails closed for command carriers that request default PATH lookup", + enabled: process.platform !== "win32", + argv: ["command", "-p", "curl", "https://example.invalid"], + expected: { + argv: ["command", "-p", "curl", "https://example.invalid"], + policyArgv: ["command", "-p", "curl", "https://example.invalid"], + wrapperChain: [], + policyBlocked: true, + blockedWrapper: "command", + shellWrapperExecutable: false, + shellInlineCommand: null, + }, + }, { name: "unwraps transparent caffeinate wrappers before shell policy checks", enabled: process.platform !== "win32", @@ -139,10 +246,10 @@ describe("resolveExecWrapperTrustPlan", () => { shellInlineCommand: null, }, }, - ])("$name", ({ enabled, argv, depth, expected }) => { + ])("$name", ({ enabled, argv, depth, platform, expected }) => { if (!enabled) { return; } - expect(resolveExecWrapperTrustPlan(argv, depth)).toEqual(expected); + expect(resolveExecWrapperTrustPlan(argv, depth, platform)).toEqual(expected); }); }); diff --git a/src/infra/exec-wrapper-trust-plan.ts b/src/infra/exec-wrapper-trust-plan.ts index 96c6fca7c81..0a7390a16c5 100644 --- a/src/infra/exec-wrapper-trust-plan.ts +++ b/src/infra/exec-wrapper-trust-plan.ts @@ -1,4 +1,5 @@ // Builds the trust plan for exec wrappers before commands are launched. +import { resolveCarrierCommandArgv } from "./command-carriers.js"; import { MAX_DISPATCH_WRAPPER_DEPTH, resolveDispatchWrapperTrustPlan, @@ -59,6 +60,52 @@ function finalizeExecWrapperTrustPlan( return plan; } +const TRANSPARENT_SHELL_ARGV_CARRIERS = new Set(["builtin", "command", "exec"]); + +type ShellArgvCarrierUnwrapResult = + | { kind: "not-wrapper" } + | { kind: "blocked"; wrapper: string } + | { kind: "unwrapped"; wrapper: string; argv: string[] }; + +function commandCarrierUsesDefaultPathSearch(argv: string[]): boolean { + if (argv[0]?.trim() !== "command") { + return false; + } + for (let index = 1; index < argv.length; index += 1) { + const token = argv[index]?.trim() ?? ""; + if (token === "--" || !token.startsWith("-")) { + return false; + } + if (/^-[^-]*p/u.test(token)) { + return true; + } + } + return false; +} + +function unwrapTransparentShellArgvCarrierInvocation( + argv: string[], + platform: NodeJS.Platform = process.platform, +): ShellArgvCarrierUnwrapResult { + if (platform === "win32") { + return { kind: "not-wrapper" }; + } + const token0 = argv[0]?.trim(); + if (!token0) { + return { kind: "not-wrapper" }; + } + if (!TRANSPARENT_SHELL_ARGV_CARRIERS.has(token0)) { + return { kind: "not-wrapper" }; + } + if (commandCarrierUsesDefaultPathSearch(argv)) { + return { kind: "blocked", wrapper: token0 }; + } + const unwrapped = resolveCarrierCommandArgv(argv, 0, { includeExec: true }); + return unwrapped && unwrapped.length > 0 + ? { kind: "unwrapped", wrapper: token0, argv: unwrapped } + : { kind: "blocked", wrapper: token0 }; +} + /** * Resolves transparent dispatch wrappers into the executable that policy should inspect. * Shell multiplexers keep their original argv as the trust target while exposing the @@ -99,6 +146,27 @@ export function resolveExecWrapperTrustPlan( continue; } + const shellArgvCarrierUnwrap = unwrapTransparentShellArgvCarrierInvocation(current, platform); + if (shellArgvCarrierUnwrap.kind === "blocked") { + return blockedExecWrapperTrustPlan({ + argv: current, + policyArgv, + wrapperChain, + blockedWrapper: shellArgvCarrierUnwrap.wrapper, + }); + } + if (shellArgvCarrierUnwrap.kind === "unwrapped") { + wrapperChain.push(shellArgvCarrierUnwrap.wrapper); + current = shellArgvCarrierUnwrap.argv; + if (!sawShellMultiplexer) { + policyArgv = current; + } + if (wrapperChain.length >= maxDepth) { + break; + } + continue; + } + const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(current); if (shellMultiplexerUnwrap.kind === "blocked") { return blockedExecWrapperTrustPlan({ @@ -135,6 +203,18 @@ export function resolveExecWrapperTrustPlan( blockedWrapper: dispatchOverflow.wrapper, }); } + const shellArgvCarrierOverflow = unwrapTransparentShellArgvCarrierInvocation(current, platform); + if ( + shellArgvCarrierOverflow.kind === "blocked" || + shellArgvCarrierOverflow.kind === "unwrapped" + ) { + return blockedExecWrapperTrustPlan({ + argv: current, + policyArgv, + wrapperChain, + blockedWrapper: shellArgvCarrierOverflow.wrapper, + }); + } const shellMultiplexerOverflow = unwrapKnownShellMultiplexerInvocation(current); if ( shellMultiplexerOverflow.kind === "blocked" ||