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
This commit is contained in:
Pavan Kumar Gondhi
2026-07-20 14:06:03 +05:30
committed by GitHub
parent 0e62293104
commit 1de4a099ca
3 changed files with 236 additions and 2 deletions
@@ -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;
+109 -2
View File
@@ -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);
});
});
+80
View File
@@ -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" ||