mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
Reference in New Issue
Block a user