Harden macOS shell wrapper allowlist parsing [AI] (#78518)

* fix: harden shell wrapper allowlist parsing

* fix: harden shell wrapper approval binding

* docs: add changelog entry for PR merge

---------

Co-authored-by: Ishaan <ishaan@Ishaans-Mac-mini.local>
This commit is contained in:
Pavan Kumar Gondhi
2026-05-08 10:18:41 +05:30
committed by GitHub
parent eabae023eb
commit fc065b2693
23 changed files with 1200 additions and 204 deletions

View File

@@ -10,6 +10,7 @@ import {
import { normalizeExecutableToken } from "../exec-wrapper-resolution.js";
import {
extractShellWrapperCommand,
extractShellWrapperInlineCommand,
isShellWrapperExecutable,
POSIX_SHELL_WRAPPERS,
resolveShellWrapperTransportArgv,
@@ -876,14 +877,11 @@ function shellWrapperPayloadForParsing(
dynamicArguments: DynamicArgument[],
): { command: string; spanBase: SpanBase } | null {
const shellWrapper = extractShellWrapperCommand(argv);
if (
!shellWrapper.isWrapper ||
!shellWrapper.command ||
isDynamicPayload(shellWrapper.command, dynamicArguments)
) {
const payload = shellWrapper.command ?? extractShellWrapperInlineCommand(argv);
if (!shellWrapper.isWrapper || !payload || isDynamicPayload(payload, dynamicArguments)) {
return null;
}
const spanBase = payloadBaseFromArguments(shellWrapper.command, argumentsList);
const spanBase = payloadBaseFromArguments(payload, argumentsList);
if (!spanBase) {
return null;
}
@@ -892,7 +890,7 @@ function shellWrapperPayloadForParsing(
if (!canParseShellWrapperPayload(transportArgv, commandFlag?.flag ?? null)) {
return null;
}
return { command: shellWrapper.command, spanBase };
return { command: payload, spanBase };
}
type InlineEvalHit = InterpreterInlineEvalHit;
@@ -947,7 +945,8 @@ function recordCommandRisks(
}
const shellWrapper = extractShellWrapperCommand(argv);
if (shellWrapper.isWrapper && shellWrapper.command) {
const shellWrapperPayload = shellWrapper.command ?? extractShellWrapperInlineCommand(argv);
if (shellWrapper.isWrapper && shellWrapperPayload) {
const transportArgv = resolveShellWrapperTransportArgv(argv) ?? argv;
const shellExecutable = transportArgv[0] ?? executable;
const commandFlag = shellCommandFlag(transportArgv, 1) ?? shellCommandFlag(argv, 1);
@@ -956,7 +955,7 @@ function recordCommandRisks(
kind: "shell-wrapper",
executable: shellExecutable,
flag: commandFlag?.flag ?? "-c",
payload: shellWrapper.command,
payload: shellWrapperPayload,
text,
span,
});

View File

@@ -324,8 +324,8 @@ describe("resolveAllowAlwaysPatterns", () => {
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/bin/zsh -lc 'whoami'",
argv: ["/bin/zsh", "-lc", "whoami"],
raw: "/bin/zsh -c 'whoami'",
argv: ["/bin/zsh", "-c", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
@@ -353,8 +353,8 @@ describe("resolveAllowAlwaysPatterns", () => {
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/bin/zsh -lc 'whoami && ls && whoami'",
argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"],
raw: "/bin/zsh -c 'whoami && ls && whoami'",
argv: ["/bin/zsh", "-c", "whoami && ls && whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
@@ -437,12 +437,49 @@ describe("resolveAllowAlwaysPatterns", () => {
}
});
it("rejects startup shell inline payloads for allow-always and inline-chain allowlist fallback", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const tool = makeExecutable(dir, "openclaw-ok");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const safeBins = resolveSafeBins(undefined);
for (const command of [
`bash --login -c "openclaw-ok && openclaw-ok"`,
`bash -i -c "openclaw-ok && openclaw-ok"`,
`bash -lc "openclaw-ok && openclaw-ok"`,
`bash --login -c '$0 "$1"' ${tool} marker`,
`bash -i -c '$0 "$1"' ${tool} marker`,
`bash -lc '$0 "$1"' ${tool} marker`,
]) {
const { persisted } = resolvePersistedPatterns({
command,
dir,
env,
safeBins,
});
expect(persisted).toEqual([]);
const second = evaluateShellAllowlist({
command,
allowlist: [{ pattern: tool }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
}
});
it("rejects shell-wrapper positional argv carriers", () => {
if (process.platform === "win32") {
return;
}
expectPositionalArgvCarrierResult({
command: `sh -lc '$0 "$1"' touch {marker}`,
command: `sh -c '$0 "$1"' touch {marker}`,
expectPersisted: true,
});
});
@@ -452,7 +489,7 @@ describe("resolveAllowAlwaysPatterns", () => {
return;
}
expectPositionalArgvCarrierResult({
command: `sh -lc 'exec -- "$0" "$1"' touch {marker}`,
command: `sh -c 'exec -- "$0" "$1"' touch {marker}`,
expectPersisted: true,
});
});
@@ -462,7 +499,7 @@ describe("resolveAllowAlwaysPatterns", () => {
return;
}
expectPositionalArgvCarrierResult({
command: `sh -lc "'$0' "$1"" touch {marker}`,
command: `sh -c "'$0' "$1"" touch {marker}`,
expectPersisted: false,
});
});
@@ -472,7 +509,7 @@ describe("resolveAllowAlwaysPatterns", () => {
return;
}
expectPositionalArgvCarrierResult({
command: `sh -lc "exec
command: `sh -c "exec
$0 \\"$1\\"" touch {marker}`,
expectPersisted: false,
});
@@ -489,7 +526,7 @@ $0 \\"$1\\"" touch {marker}`,
const marker = path.join(dir, "marker");
const { persisted } = resolvePersistedPatterns({
command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`,
command: `sh -c 'echo blocked; $0 "$1"' touch ${marker}`,
dir,
env,
safeBins,
@@ -497,7 +534,7 @@ $0 \\"$1\\"" touch {marker}`,
expect(persisted).not.toContain(touch);
const second = evaluateShellAllowlist({
command: `sh -lc 'echo blocked; $0 "$1"' touch ${marker}`,
command: `sh -c 'echo blocked; $0 "$1"' touch ${marker}`,
allowlist: [{ pattern: touch }],
safeBins,
cwd: dir,
@@ -515,7 +552,7 @@ $0 \\"$1\\"" touch {marker}`,
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "bash scripts/save_crystal.sh",
secondCommand: "bash -lc 'scripts/save_crystal.sh'",
secondCommand: "bash -c 'scripts/save_crystal.sh'",
env,
persistedPattern: script,
});
@@ -564,8 +601,8 @@ $0 \\"$1\\"" touch {marker}`,
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/usr/local/bin/zsh -lc whoami",
argv: ["/usr/local/bin/zsh", "-lc", "whoami"],
raw: "/usr/local/bin/zsh -c whoami",
argv: ["/usr/local/bin/zsh", "-c", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/local/bin/zsh",
@@ -591,8 +628,8 @@ $0 \\"$1\\"" touch {marker}`,
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/usr/bin/nice /bin/zsh -lc whoami",
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
raw: "/usr/bin/nice /bin/zsh -c whoami",
argv: ["/usr/bin/nice", "/bin/zsh", "-c", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/bin/nice",
@@ -619,8 +656,8 @@ $0 \\"$1\\"" touch {marker}`,
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: "/usr/bin/time -p /bin/zsh -lc whoami",
argv: ["/usr/bin/time", "-p", "/bin/zsh", "-lc", "whoami"],
raw: "/usr/bin/time -p /bin/zsh -c whoami",
argv: ["/usr/bin/time", "-p", "/bin/zsh", "-c", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/bin/time",
@@ -650,8 +687,8 @@ $0 \\"$1\\"" touch {marker}`,
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: `${busybox} sh -lc whoami`,
argv: [busybox, "sh", "-lc", "whoami"],
raw: `${busybox} sh -c whoami`,
argv: [busybox, "sh", "-c", "whoami"],
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: busybox,
@@ -744,8 +781,8 @@ $0 \\"$1\\"" touch {marker}`,
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -lc 'id > marker'",
firstCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/caffeinate -d -w 42 /bin/zsh -c 'id > marker'",
env,
persistedPattern: echo,
});
@@ -761,8 +798,8 @@ $0 \\"$1\\"" touch {marker}`,
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/nice /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/nice /bin/zsh -lc 'id > marker'",
firstCommand: "/usr/bin/nice /bin/zsh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/nice /bin/zsh -c 'id > marker'",
env,
persistedPattern: echo,
});
@@ -779,8 +816,8 @@ $0 \\"$1\\"" touch {marker}`,
expectAllowAlwaysBypassBlocked({
dir,
firstCommand:
"/usr/bin/sandbox-exec -p '(deny default) (allow process*)' /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/sandbox-exec -p '(allow default)' /bin/zsh -lc 'id > marker'",
"/usr/bin/sandbox-exec -p '(deny default) (allow process*)' /bin/zsh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/sandbox-exec -p '(allow default)' /bin/zsh -c 'id > marker'",
env,
persistedPattern: echo,
});
@@ -796,8 +833,8 @@ $0 \\"$1\\"" touch {marker}`,
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/time -p /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/time -p /bin/zsh -lc 'id > marker'",
firstCommand: "/usr/bin/time -p /bin/zsh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/time -p /bin/zsh -c 'id > marker'",
env,
persistedPattern: echo,
});
@@ -813,15 +850,15 @@ $0 \\"$1\\"" touch {marker}`,
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/arch -arm64 /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/arch -arm64 /bin/zsh -lc 'id > marker-arch'",
firstCommand: "/usr/bin/arch -arm64 /bin/zsh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/arch -arm64 /bin/zsh -c 'id > marker-arch'",
env,
persistedPattern: echo,
});
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/xcrun /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/xcrun /bin/zsh -lc 'id > marker-xcrun'",
firstCommand: "/usr/bin/xcrun /bin/zsh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/xcrun /bin/zsh -c 'id > marker-xcrun'",
env,
persistedPattern: echo,
});
@@ -873,7 +910,7 @@ $0 \\"$1\\"" touch {marker}`,
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: `sh -lc '$0 "$@"' awk '{print $1}' data.csv`,
command: `sh -c '$0 "$@"' awk '{print $1}' data.csv`,
dir,
env,
safeBins,
@@ -881,7 +918,7 @@ $0 \\"$1\\"" touch {marker}`,
expect(persisted).toEqual([]);
const second = evaluateShellAllowlist({
command: `sh -lc '$0 "$@"' awk 'BEGIN{system("id > /tmp/pwned")}'`,
command: `sh -c '$0 "$@"' awk 'BEGIN{system("id > /tmp/pwned")}'`,
allowlist: persisted.map((pattern) => ({ pattern })),
safeBins,
cwd: dir,
@@ -901,8 +938,8 @@ $0 \\"$1\\"" touch {marker}`,
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/script -q /dev/null /bin/sh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/script -q /dev/null /bin/sh -lc 'id > marker'",
firstCommand: "/usr/bin/script -q /dev/null /bin/sh -c 'echo warmup-ok'",
secondCommand: "/usr/bin/script -q /dev/null /bin/sh -c 'id > marker'",
env,
persistedPattern: echo,
});
@@ -935,7 +972,7 @@ $0 \\"$1\\"" touch {marker}`,
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: `sh -lc '$0 "$@"' env echo SAFE`,
command: `sh -c '$0 "$@"' env echo SAFE`,
dir,
env,
safeBins,
@@ -943,7 +980,7 @@ $0 \\"$1\\"" touch {marker}`,
expect(persisted).toEqual([]);
const second = evaluateShellAllowlist({
command: `sh -lc '$0 "$@"' env BASH_ENV=/tmp/payload.sh bash -lc 'id > /tmp/pwned'`,
command: `sh -c '$0 "$@"' env BASH_ENV=/tmp/payload.sh bash -c 'id > /tmp/pwned'`,
allowlist: [{ pattern: envPath }],
safeBins,
cwd: dir,
@@ -963,7 +1000,7 @@ $0 \\"$1\\"" touch {marker}`,
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: `sh -lc '$0 "$@"' bash -lc 'echo safe'`,
command: `sh -c '$0 "$@"' bash -c 'echo safe'`,
dir,
env,
safeBins,
@@ -971,7 +1008,7 @@ $0 \\"$1\\"" touch {marker}`,
expect(persisted).toEqual([]);
const second = evaluateShellAllowlist({
command: `sh -lc '$0 "$@"' bash -lc 'id > /tmp/pwned'`,
command: `sh -c '$0 "$@"' bash -c 'id > /tmp/pwned'`,
allowlist: [{ pattern: bashPath }],
safeBins,
cwd: dir,
@@ -991,7 +1028,7 @@ $0 \\"$1\\"" touch {marker}`,
const safeBins = resolveSafeBins(undefined);
const { persisted } = resolvePersistedPatterns({
command: `sh -lc '$0 "$@"' xargs echo SAFE`,
command: `sh -c '$0 "$@"' xargs echo SAFE`,
dir,
env,
safeBins,
@@ -999,7 +1036,7 @@ $0 \\"$1\\"" touch {marker}`,
expect(persisted).toEqual([]);
const second = evaluateShellAllowlist({
command: `sh -lc '$0 "$@"' xargs sh -lc 'id > /tmp/pwned'`,
command: `sh -c '$0 "$@"' xargs sh -c 'id > /tmp/pwned'`,
allowlist: [{ pattern: xargsPath }],
safeBins,
cwd: dir,

View File

@@ -32,7 +32,7 @@ import {
} from "./exec-safe-bin-policy.js";
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
import {
extractShellWrapperInlineCommand,
extractBindableShellWrapperInlineCommand,
isShellWrapperExecutable,
normalizeExecutableToken,
POWERSHELL_WRAPPERS,
@@ -426,7 +426,7 @@ function resolveSegmentAllowlistMatch(params: {
candidatePath && executableResolution
? { ...executableResolution, resolvedPath: candidatePath }
: executableResolution;
const inlineCommand = extractShellWrapperInlineCommand(allowlistSegment.argv);
const inlineCommand = extractBindableShellWrapperInlineCommand(allowlistSegment.argv);
const isPositionalCarrierInvocation =
inlineCommand !== null && isDirectShellPositionalCarrierInvocation(inlineCommand);
const executableMatch = isPositionalCarrierInvocation
@@ -437,11 +437,14 @@ function resolveSegmentAllowlistMatch(params: {
effectiveArgv,
params.context.platform,
);
const shellPositionalArgvCandidatePath = resolveShellWrapperPositionalArgvCandidatePath({
segment: allowlistSegment,
cwd: params.context.cwd,
env: params.context.env,
});
const shellPositionalArgvCandidatePath =
inlineCommand !== null
? resolveShellWrapperPositionalArgvCandidatePath({
segment: allowlistSegment,
cwd: params.context.cwd,
env: params.context.env,
})
: undefined;
const shellPositionalArgvMatch = shellPositionalArgvCandidatePath
? matchAllowlist(
params.context.allowlist,
@@ -971,15 +974,6 @@ function collectAllowAlwaysPatterns(params: {
addAllowAlwaysPattern(params.out, candidatePath, argPattern);
return;
}
const positionalArgvPath = resolveShellWrapperPositionalArgvCandidatePath({
segment,
cwd: params.cwd,
env: params.env,
});
if (positionalArgvPath) {
addAllowAlwaysPattern(params.out, positionalArgvPath);
return;
}
const isPowerShellFileInvocation =
POWERSHELL_WRAPPERS.has(normalizeExecutableToken(segment.argv[0] ?? "")) &&
segment.argv.some((t) => {
@@ -990,9 +984,19 @@ function collectAllowAlwaysPatterns(params: {
const lower = normalizeLowercaseStringOrEmpty(t);
return lower === "-command" || lower === "-c" || lower === "--command";
});
const inlineCommand = isPowerShellFileInvocation
? null
: (trustPlan.shellInlineCommand ?? extractShellWrapperInlineCommand(segment.argv));
const inlineCommand = isPowerShellFileInvocation ? null : trustPlan.shellInlineCommand;
const positionalArgvPath =
inlineCommand !== null
? resolveShellWrapperPositionalArgvCandidatePath({
segment,
cwd: params.cwd,
env: params.env,
})
: undefined;
if (positionalArgvPath) {
addAllowAlwaysPattern(params.out, positionalArgvPath);
return;
}
if (!inlineCommand) {
const scriptPath = resolveShellWrapperScriptCandidatePath({
segment,

View File

@@ -471,12 +471,12 @@ describe("extractShellWrapperCommand", () => {
{
argv: ["bash", "-lc", "echo hi"],
expectedInline: "echo hi",
expectedCommand: { isWrapper: true, command: "echo hi" },
expectedCommand: { isWrapper: true, command: null },
},
{
argv: ["busybox", "sh", "-lc", "echo hi"],
expectedInline: "echo hi",
expectedCommand: { isWrapper: true, command: "echo hi" },
expectedCommand: { isWrapper: true, command: null },
},
{
argv: ["env", "--", "pwsh", "-Command", "Get-Date"],
@@ -494,7 +494,7 @@ describe("extractShellWrapperCommand", () => {
});
test("prefers an explicit raw command override when provided", () => {
expect(extractShellWrapperCommand(["bash", "-lc", "echo hi"], " run this instead ")).toEqual({
expect(extractShellWrapperCommand(["bash", "-c", "echo hi"], " run this instead ")).toEqual({
isWrapper: true,
command: "run this instead",
});

View File

@@ -8,9 +8,11 @@ export {
unwrapKnownDispatchWrapperInvocation,
} from "./dispatch-wrapper-resolution.js";
export {
extractBindableShellWrapperInlineCommand,
extractShellWrapperCommand,
extractShellWrapperInlineCommand,
hasEnvManipulationBeforeShellWrapper,
isBlockedShellWrapperCommand,
isShellWrapperExecutable,
isShellWrapperInvocation,
POSIX_SHELL_WRAPPERS,

View File

@@ -6,10 +6,10 @@ describe("resolveExecWrapperTrustPlan", () => {
{
name: "unwraps transparent caffeinate wrappers before shell policy checks",
enabled: process.platform !== "win32",
argv: ["/usr/bin/caffeinate", "-d", "-w", "42", "sh", "-lc", "echo hi"],
argv: ["/usr/bin/caffeinate", "-d", "-w", "42", "sh", "-c", "echo hi"],
expected: {
argv: ["sh", "-lc", "echo hi"],
policyArgv: ["sh", "-lc", "echo hi"],
argv: ["sh", "-c", "echo hi"],
policyArgv: ["sh", "-c", "echo hi"],
wrapperChain: ["caffeinate"],
policyBlocked: false,
shellWrapperExecutable: true,
@@ -19,10 +19,10 @@ describe("resolveExecWrapperTrustPlan", () => {
{
name: "unwraps dispatch wrappers and shell multiplexers into one trust plan",
enabled: process.platform !== "win32",
argv: ["/usr/bin/time", "-p", "busybox", "sh", "-lc", "echo hi"],
argv: ["/usr/bin/time", "-p", "busybox", "sh", "-c", "echo hi"],
expected: {
argv: ["sh", "-lc", "echo hi"],
policyArgv: ["busybox", "sh", "-lc", "echo hi"],
argv: ["sh", "-c", "echo hi"],
policyArgv: ["busybox", "sh", "-c", "echo hi"],
wrapperChain: ["time", "busybox"],
policyBlocked: false,
shellWrapperExecutable: true,
@@ -32,10 +32,10 @@ describe("resolveExecWrapperTrustPlan", () => {
{
name: "unwraps script wrappers before evaluating nested shell payloads",
enabled: process.platform === "darwin" || process.platform === "freebsd",
argv: ["/usr/bin/script", "-q", "/dev/null", "sh", "-lc", "echo hi"],
argv: ["/usr/bin/script", "-q", "/dev/null", "sh", "-c", "echo hi"],
expected: {
argv: ["sh", "-lc", "echo hi"],
policyArgv: ["sh", "-lc", "echo hi"],
argv: ["sh", "-c", "echo hi"],
policyArgv: ["sh", "-c", "echo hi"],
wrapperChain: ["script"],
policyBlocked: false,
shellWrapperExecutable: true,
@@ -45,16 +45,29 @@ describe("resolveExecWrapperTrustPlan", () => {
{
name: "unwraps sandbox-exec wrappers before evaluating nested shell payloads",
enabled: process.platform !== "win32",
argv: ["/usr/bin/sandbox-exec", "-p", "(allow default)", "sh", "-lc", "echo hi"],
argv: ["/usr/bin/sandbox-exec", "-p", "(allow default)", "sh", "-c", "echo hi"],
expected: {
argv: ["sh", "-lc", "echo hi"],
policyArgv: ["sh", "-lc", "echo hi"],
argv: ["sh", "-c", "echo hi"],
policyArgv: ["sh", "-c", "echo hi"],
wrapperChain: ["sandbox-exec"],
policyBlocked: false,
shellWrapperExecutable: true,
shellInlineCommand: "echo hi",
},
},
{
name: "omits startup shell inline payloads from trust plans",
enabled: process.platform !== "win32",
argv: ["bash", "--login", "-c", "echo hi"],
expected: {
argv: ["bash", "--login", "-c", "echo hi"],
policyArgv: ["bash", "--login", "-c", "echo hi"],
wrapperChain: [],
policyBlocked: false,
shellWrapperExecutable: true,
shellInlineCommand: null,
},
},
{
name: "fails closed for unsupported shell multiplexer applets",
enabled: true,

View File

@@ -4,7 +4,7 @@ import {
unwrapKnownDispatchWrapperInvocation,
} from "./dispatch-wrapper-resolution.js";
import {
extractShellWrapperInlineCommand,
extractBindableShellWrapperInlineCommand,
isShellWrapperExecutable,
unwrapKnownShellMultiplexerInvocation,
} from "./shell-wrapper-resolution.js";
@@ -46,15 +46,20 @@ function finalizeExecWrapperTrustPlan(
const rawExecutable = argv[0]?.trim() ?? "";
const shellWrapperExecutable =
!policyBlocked && rawExecutable.length > 0 && isShellWrapperExecutable(rawExecutable);
return {
const plan: ExecWrapperTrustPlan = {
argv,
policyArgv,
wrapperChain,
policyBlocked,
blockedWrapper,
shellWrapperExecutable,
shellInlineCommand: shellWrapperExecutable ? extractShellWrapperInlineCommand(argv) : null,
shellInlineCommand: shellWrapperExecutable
? extractBindableShellWrapperInlineCommand(argv)
: null,
};
if (blockedWrapper !== undefined) {
plan.blockedWrapper = blockedWrapper;
}
return plan;
}
export function resolveExecWrapperTrustPlan(

View File

@@ -38,6 +38,20 @@ describe("resolveInlineCommandMatch", () => {
opts: { allowCombinedC: true },
expected: { command: "echo hi", valueTokenIndex: 1 },
},
{
name: "keeps post-c no-argument shell flags separate from the command",
argv: ["bash", "-cx", "echo hi"],
flags: POSIX_INLINE_COMMAND_FLAGS,
opts: { allowCombinedC: true },
expected: { command: "echo hi", valueTokenIndex: 2 },
},
{
name: "keeps post-c stdin shell flags separate from the command",
argv: ["bash", "-cs", "echo hi"],
flags: POSIX_INLINE_COMMAND_FLAGS,
opts: { allowCombinedC: true },
expected: { command: "echo hi", valueTokenIndex: 2 },
},
{
name: "rejects combined -c forms when disabled",
argv: ["sh", "-cecho hi"],

View File

@@ -12,35 +12,212 @@ export const POWERSHELL_INLINE_COMMAND_FLAGS = new Set([
"-e",
]);
const POSIX_SHELL_OPTIONS_WITH_SEPARATE_VALUES = new Set([
"--init-file",
"--rcfile",
"-O",
"-o",
"+O",
"+o",
]);
function isCombinedCommandFlag(token: string): boolean {
return parseCombinedCommandFlag(token) !== null;
}
function parseCombinedCommandFlag(
token: string,
): { attachedCommand: string | null; separateValueCount: number } | null {
if (token.length < 2 || token[0] !== "-" || token[1] === "-") {
return null;
}
const optionChars = token.slice(1);
const commandFlagIndex = optionChars.indexOf("c");
if (commandFlagIndex === -1 || optionChars.includes("-")) {
return null;
}
const suffix = optionChars.slice(commandFlagIndex + 1);
if (suffix && !/^[A-Za-z]+$/.test(suffix)) {
return { attachedCommand: suffix, separateValueCount: 0 };
}
return {
attachedCommand: null,
separateValueCount: [...optionChars].filter((char) => char === "o" || char === "O").length,
};
}
function combinedSeparateValueOptionCount(token: string): number {
if (
token.length < 2 ||
(token[0] !== "-" && token[0] !== "+") ||
token[1] === "-" ||
token.slice(1).includes("-")
) {
return 0;
}
return [...token.slice(1)].filter((char) => char === "o" || char === "O").length;
}
function consumesSeparateValue(token: string): boolean {
return POSIX_SHELL_OPTIONS_WITH_SEPARATE_VALUES.has(token);
}
function isPosixInteractiveModeOption(token: string): boolean {
return token === "--interactive" || isPosixShortOption(token, "i");
}
function isPosixShortOption(token: string, option: string): boolean {
if (token.length < 2 || token[0] !== "-" || token[1] === "-") {
return false;
}
const optionChars = token.slice(1);
return !optionChars.includes("-") && optionChars.includes(option);
}
function advancePosixInlineOptionScan(token: string): number {
const combinedValueCount = combinedSeparateValueOptionCount(token);
if (combinedValueCount > 0) {
return 1 + combinedValueCount;
}
if (consumesSeparateValue(token)) {
return 2;
}
return 1;
}
export function resolveInlineCommandMatch(
argv: string[],
flags: ReadonlySet<string>,
options: { allowCombinedC?: boolean } = {},
): { command: string | null; valueTokenIndex: number | null } {
for (let i = 1; i < argv.length; i += 1) {
for (let i = 1; i < argv.length; ) {
const token = argv[i]?.trim();
if (!token) {
i += 1;
continue;
}
const lower = normalizeLowercaseStringOrEmpty(token);
if (lower === "--") {
break;
}
if (flags.has(lower)) {
const comparableToken = options.allowCombinedC ? token : lower;
if (flags.has(comparableToken)) {
const valueTokenIndex = i + 1 < argv.length ? i + 1 : null;
const command = argv[i + 1]?.trim();
return { command: command ? command : null, valueTokenIndex };
}
if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) {
const commandIndex = lower.indexOf("c");
const inline = token.slice(commandIndex + 1).trim();
if (inline) {
return { command: inline, valueTokenIndex: i };
if (options.allowCombinedC && isCombinedCommandFlag(token)) {
const combined = parseCombinedCommandFlag(token);
if (combined?.attachedCommand != null) {
return { command: combined.attachedCommand.trim() || null, valueTokenIndex: i };
}
const valueTokenIndex = i + 1 < argv.length ? i + 1 : null;
const command = argv[i + 1]?.trim();
const valueTokenIndex = i + 1 + (combined?.separateValueCount ?? 0);
const command = argv[valueTokenIndex]?.trim();
return { command: command ? command : null, valueTokenIndex };
}
if (options.allowCombinedC && !token.startsWith("-") && !token.startsWith("+")) {
break;
}
i += options.allowCombinedC ? advancePosixInlineOptionScan(token) : 1;
}
return { command: null, valueTokenIndex: null };
}
export function hasPosixInteractiveStartupBeforeInlineCommand(
argv: string[],
flags: ReadonlySet<string>,
): boolean {
let sawInteractiveMode = false;
for (let i = 1; i < argv.length; ) {
const token = argv[i]?.trim();
if (!token) {
i += 1;
continue;
}
if (token === "--") {
return false;
}
if (isPosixInteractiveModeOption(token)) {
sawInteractiveMode = true;
}
if (flags.has(token) || isCombinedCommandFlag(token)) {
return sawInteractiveMode;
}
if (!token.startsWith("-") && !token.startsWith("+")) {
return false;
}
i += advancePosixInlineOptionScan(token);
}
return false;
}
export function hasPosixLoginStartupBeforeInlineCommand(
argv: string[],
flags: ReadonlySet<string>,
): boolean {
let sawLoginMode = false;
for (let i = 1; i < argv.length; ) {
const token = argv[i]?.trim();
if (!token) {
i += 1;
continue;
}
if (token === "--") {
return false;
}
if (token === "--login" || isPosixShortOption(token, "l")) {
sawLoginMode = true;
}
if (flags.has(token) || isCombinedCommandFlag(token)) {
return sawLoginMode;
}
if (!token.startsWith("-") && !token.startsWith("+")) {
return false;
}
i += advancePosixInlineOptionScan(token);
}
return false;
}
export function hasFishInitCommandOption(argv: string[]): boolean {
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i]?.trim();
if (!token) {
continue;
}
if (token === "--") {
return false;
}
if (
token === "-C" ||
token === "--init-command" ||
(token.startsWith("-C") && token !== "-C") ||
token.startsWith("--init-command=")
) {
return true;
}
if (!token.startsWith("-") && !token.startsWith("+")) {
return false;
}
}
return false;
}
export function hasFishAttachedCommandOption(argv: string[]): boolean {
for (let i = 1; i < argv.length; i += 1) {
const token = argv[i]?.trim();
if (!token) {
continue;
}
if (token === "--") {
return false;
}
if (token.startsWith("-c") && token !== "-c") {
return true;
}
if (!token.startsWith("-") && !token.startsWith("+")) {
return false;
}
}
return false;
}

View File

@@ -6,6 +6,10 @@ import {
} from "./dispatch-wrapper-resolution.js";
import { normalizeExecutableToken } from "./exec-wrapper-tokens.js";
import {
hasFishAttachedCommandOption,
hasFishInitCommandOption,
hasPosixInteractiveStartupBeforeInlineCommand,
hasPosixLoginStartupBeforeInlineCommand,
POSIX_INLINE_COMMAND_FLAGS,
POWERSHELL_INLINE_COMMAND_FLAGS,
resolveInlineCommandMatch,
@@ -37,6 +41,7 @@ const SHELL_WRAPPER_CANONICAL = new Set<string>([
...WINDOWS_CMD_WRAPPER_NAMES,
...POWERSHELL_WRAPPER_NAMES,
]);
const LOGIN_STARTUP_SHELL_WRAPPER_CANONICAL = new Set<string>(POSIX_SHELL_WRAPPER_NAMES);
type ShellWrapperKind = "posix" | "cmd" | "powershell";
@@ -235,6 +240,49 @@ function extractShellWrapperPayload(argv: string[], spec: ShellWrapperSpec): str
throw new Error("Unsupported shell wrapper kind");
}
function isLegacyLoginInlineForm(argv: string[]): boolean {
return argv[1]?.trim() === "-lc";
}
function isLegacyShLoginInlineForm(argv: string[], baseExecutable: string): boolean {
return baseExecutable === "sh" && isLegacyLoginInlineForm(argv);
}
function formatShellWrapperArgv(argv: string[]): string {
return argv
.map((arg) => {
if (arg.length === 0) {
return '""';
}
return /\s|"/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
})
.join(" ");
}
function startupWrapperRequiresFullArgv(params: {
argv: string[];
spec: ShellWrapperSpec;
baseExecutable: string;
includeLegacyLoginInlineForm: boolean;
}): boolean {
if (params.spec.kind !== "posix") {
return false;
}
if (params.baseExecutable === "fish" && hasFishInitCommandOption(params.argv)) {
return true;
}
if (
LOGIN_STARTUP_SHELL_WRAPPER_CANONICAL.has(params.baseExecutable) &&
hasPosixLoginStartupBeforeInlineCommand(params.argv, POSIX_INLINE_COMMAND_FLAGS)
) {
return (
params.includeLegacyLoginInlineForm ||
!isLegacyShLoginInlineForm(params.argv, params.baseExecutable)
);
}
return hasPosixInteractiveStartupBeforeInlineCommand(params.argv, POSIX_INLINE_COMMAND_FLAGS);
}
function hasEnvManipulationBeforeShellWrapperInternal(
argv: string[],
depth: number,
@@ -270,12 +318,52 @@ function extractShellWrapperCommandInternal(
rawCommand: string | null,
depth: number,
): ShellWrapperCommand {
const resolved = resolveShellWrapperSpecAndArgvInternal(argv, depth);
const candidate = resolveShellWrapperCandidate({ argv, depth, state: null });
if (!candidate) {
return { isWrapper: false, command: null };
}
const baseExecutable = normalizeExecutableToken(candidate.token0);
const wrapper = findShellWrapperSpec(baseExecutable);
if (!wrapper) {
return { isWrapper: false, command: null };
}
const payload = extractShellWrapperPayload(candidate.argv, wrapper);
if (!payload) {
return { isWrapper: false, command: null };
}
if (
wrapper.kind === "posix" &&
baseExecutable === "fish" &&
hasFishAttachedCommandOption(candidate.argv)
) {
return { isWrapper: true, command: null };
}
const rawMatchesPayload = rawCommand === payload;
const rawMatchesCanonicalArgv = rawCommand === formatShellWrapperArgv(candidate.argv);
const allowLegacyShLoginPayloadBinding =
isLegacyShLoginInlineForm(candidate.argv, baseExecutable) &&
(rawMatchesPayload || rawMatchesCanonicalArgv);
if (
startupWrapperRequiresFullArgv({
argv: candidate.argv,
spec: wrapper,
baseExecutable,
includeLegacyLoginInlineForm: !allowLegacyShLoginPayloadBinding,
})
) {
return { isWrapper: true, command: null };
}
const resolved = resolveShellWrapperSpecAndArgvInternal(candidate.argv, depth);
if (!resolved) {
return { isWrapper: false, command: null };
}
return { isWrapper: true, command: rawCommand ?? resolved.payload };
return {
isWrapper: true,
command: rawMatchesCanonicalArgv ? resolved.payload : (rawCommand ?? resolved.payload),
};
}
export function resolveShellWrapperTransportArgv(argv: string[]): string[] | null {
@@ -283,8 +371,14 @@ export function resolveShellWrapperTransportArgv(argv: string[]): string[] | nul
}
export function extractShellWrapperInlineCommand(argv: string[]): string | null {
const extracted = extractShellWrapperCommandInternal(argv, null, 0);
return extracted.isWrapper ? extracted.command : null;
return resolveShellWrapperSpecAndArgvInternal(argv, 0)?.payload ?? null;
}
export function extractBindableShellWrapperInlineCommand(
argv: string[],
rawCommand?: string | null,
): string | null {
return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0).command;
}
export function extractShellWrapperCommand(
@@ -293,3 +387,8 @@ export function extractShellWrapperCommand(
): ShellWrapperCommand {
return extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0);
}
export function isBlockedShellWrapperCommand(argv: string[], rawCommand?: string | null): boolean {
const extracted = extractShellWrapperCommandInternal(argv, normalizeRawCommand(rawCommand), 0);
return extracted.isWrapper && extracted.command === null;
}

View File

@@ -34,8 +34,12 @@ describe("system run command helpers", () => {
expect(formatExecCommand(["runner "])).toBe('"runner "');
});
test("extractShellCommandFromArgv extracts sh -lc command", () => {
expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe("echo hi");
test("extractShellCommandFromArgv fails closed for rawless sh -lc command", () => {
expect(extractShellCommandFromArgv(["/bin/sh", "-lc", "echo hi"])).toBe(null);
});
test("extractShellCommandFromArgv extracts sh -c command", () => {
expect(extractShellCommandFromArgv(["/bin/sh", "-c", "echo hi"])).toBe("echo hi");
});
test("extractShellCommandFromArgv extracts cmd.exe /c command", () => {
@@ -43,16 +47,16 @@ describe("system run command helpers", () => {
});
test("extractShellCommandFromArgv unwraps /usr/bin/env shell wrappers", () => {
expect(extractShellCommandFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"])).toBe("echo hi");
expect(extractShellCommandFromArgv(["/usr/bin/env", "bash", "-c", "echo hi"])).toBe("echo hi");
expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "zsh", "-c", "echo hi"])).toBe(
"echo hi",
);
});
test.each([
{ argv: ["/usr/bin/nice", "/bin/bash", "-lc", "echo hi"], expected: "echo hi" },
{ argv: ["/usr/bin/nice", "/bin/bash", "-c", "echo hi"], expected: "echo hi" },
{
argv: ["/usr/bin/timeout", "--signal=TERM", "5", "zsh", "-lc", "echo hi"],
argv: ["/usr/bin/timeout", "--signal=TERM", "5", "zsh", "-c", "echo hi"],
expected: "echo hi",
},
{
@@ -74,7 +78,7 @@ describe("system run command helpers", () => {
{ argv: ["pwsh", "-EncodedCommand", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" },
{ argv: ["powershell", "-enc", "ZQBjAGgAbwA="], expected: "ZQBjAGgAbwA=" },
{ argv: ["busybox", "sh", "-c", "echo hi"], expected: "echo hi" },
{ argv: ["toybox", "ash", "-lc", "echo hi"], expected: "echo hi" },
{ argv: ["toybox", "ash", "-c", "echo hi"], expected: "echo hi" },
])("extractShellCommandFromArgv unwraps %j", ({ argv, expected }) => {
expect(extractShellCommandFromArgv(argv)).toBe(expected);
});
@@ -131,6 +135,26 @@ describe("system run command helpers", () => {
expect(res.previewText).toBe("echo hi");
});
test("validateSystemRunCommandConsistency preserves legacy sh -lc payload binding only for sh", () => {
const sh = expectValidResult(
validateSystemRunCommandConsistency({
argv: ["/bin/sh", "-lc", "/usr/bin/printf ok"],
rawCommand: "/usr/bin/printf ok",
allowLegacyShellText: true,
}),
);
expect(sh.previewText).toBe("/usr/bin/printf ok");
expectRawCommandMismatch({
argv: ["/bin/bash", "-lc", "/usr/bin/printf ok"],
rawCommand: "/usr/bin/printf ok",
});
});
test("extractShellCommandFromArgv treats uppercase posix C as a shell option, not command mode", () => {
expect(extractShellCommandFromArgv(["/bin/bash", "-C", "echo hi"])).toBe(null);
});
test("validateSystemRunCommandConsistency rejects shell-only rawCommand for positional-argv carrier wrappers", () => {
expectRawCommandMismatch({
argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"],
@@ -141,7 +165,7 @@ describe("system run command helpers", () => {
test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => {
const res = expectValidResult(
validateSystemRunCommandConsistency({
argv: ["/usr/bin/env", "bash", "-lc", "echo hi"],
argv: ["/usr/bin/env", "bash", "-c", "echo hi"],
rawCommand: "echo hi",
allowLegacyShellText: true,
}),
@@ -156,6 +180,33 @@ describe("system run command helpers", () => {
});
});
test.each([
{ argv: ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"] },
{ argv: ["/bin/bash", "-i", "-c", "/usr/bin/printf ok"] },
{ argv: ["/usr/bin/fish", "--init-command=/tmp/payload.fish", "-c", "/usr/bin/printf ok"] },
])(
"validateSystemRunCommandConsistency rejects shell-only rawCommand for startup wrapper %j",
({ argv }) => {
expectRawCommandMismatch({
argv,
rawCommand: "/usr/bin/printf ok",
});
},
);
test("validateSystemRunCommandConsistency accepts full rawCommand for startup wrapper argv", () => {
const raw = '/bin/bash --login -c "/usr/bin/printf ok"';
const res = expectValidResult(
validateSystemRunCommandConsistency({
argv: ["/bin/bash", "--login", "-c", "/usr/bin/printf ok"],
rawCommand: raw,
}),
);
expect(res.shellPayload).toBe(null);
expect(res.commandText).toBe(raw);
expect(res.previewText).toBe(null);
});
test("validateSystemRunCommandConsistency accepts full rawCommand for env assignment prelude", () => {
const raw = '/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"';
const res = expectValidResult(
@@ -164,7 +215,7 @@ describe("system run command helpers", () => {
rawCommand: raw,
}),
);
expect(res.shellPayload).toBe("echo hi");
expect(res.shellPayload).toBe(null);
expect(res.commandText).toBe(raw);
expect(res.previewText).toBe(null);
});
@@ -241,9 +292,9 @@ describe("system run command helpers", () => {
resolveSystemRunCommand({
command: ["/usr/bin/arch", "-arm64", "/bin/sh", "-lc", "echo hi"],
}),
expectedShellPayload: process.platform === "darwin" ? "echo hi" : null,
expectedShellPayload: null,
expectedCommandText: '/usr/bin/arch -arm64 /bin/sh -lc "echo hi"',
expectedPreviewText: process.platform === "darwin" ? "echo hi" : null,
expectedPreviewText: null,
},
{
name: "resolveSystemRunCommand unwraps xcrun before deriving shell previews",
@@ -251,9 +302,9 @@ describe("system run command helpers", () => {
resolveSystemRunCommand({
command: ["/usr/bin/xcrun", "/bin/sh", "-lc", "echo hi"],
}),
expectedShellPayload: process.platform === "darwin" ? "echo hi" : null,
expectedShellPayload: null,
expectedCommandText: '/usr/bin/xcrun /bin/sh -lc "echo hi"',
expectedPreviewText: process.platform === "darwin" ? "echo hi" : null,
expectedPreviewText: null,
},
{
name: "resolveSystemRunCommandRequest accepts legacy shell payloads but returns canonical command text",
@@ -273,7 +324,7 @@ describe("system run command helpers", () => {
resolveSystemRunCommand({
command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"],
}),
expectedShellPayload: '$0 "$1"',
expectedShellPayload: null,
expectedCommandText: '/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker',
expectedPreviewText: null,
},
@@ -283,7 +334,7 @@ describe("system run command helpers", () => {
resolveSystemRunCommand({
command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"],
}),
expectedShellPayload: "echo hi",
expectedShellPayload: null,
expectedCommandText: '/usr/bin/env BASH_ENV=/tmp/payload.sh bash -lc "echo hi"',
expectedPreviewText: null,
},

View File

@@ -105,8 +105,15 @@ function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean {
return wrapperArgv.slice(inlineCommandIndex + 1).some((entry) => entry.trim().length > 0);
}
function buildSystemRunCommandDisplay(argv: string[]): SystemRunCommandDisplay {
const shellWrapperResolution = extractShellWrapperCommand(argv);
function buildSystemRunCommandDisplay(
argv: string[],
rawCommand: string | null,
): SystemRunCommandDisplay {
const rawlessShellWrapperResolution = extractShellWrapperCommand(argv);
const shellWrapperResolution =
rawlessShellWrapperResolution.command === null && rawCommand !== null
? extractShellWrapperCommand(argv, rawCommand)
: rawlessShellWrapperResolution;
const shellPayload = shellWrapperResolution.command;
const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(argv);
const envManipulationBeforeShellWrapper =
@@ -133,7 +140,7 @@ export function validateSystemRunCommandConsistency(params: {
allowLegacyShellText?: boolean;
}): SystemRunCommandValidation {
const raw = normalizeRawCommandText(params.rawCommand);
const display = buildSystemRunCommandDisplay(params.argv);
const display = buildSystemRunCommandDisplay(params.argv, raw);
if (raw) {
const matchesCanonicalArgv = raw === display.commandText;

View File

@@ -8,6 +8,7 @@ import type {
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
import { isInterpreterLikeSafeBin } from "../infra/exec-safe-bin-runtime-policy.js";
import {
isBlockedShellWrapperCommand,
POSIX_SHELL_WRAPPERS,
normalizeExecutableToken,
unwrapKnownDispatchWrapperInvocation,
@@ -1303,6 +1304,12 @@ export function buildSystemRunApprovalPlan(params: {
if (command.argv.length === 0) {
return { ok: false, message: "command required" };
}
if (command.shellPayload === null && isBlockedShellWrapperCommand(command.argv)) {
return {
ok: false,
message: "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command",
};
}
const hardening = hardenApprovedExecutionPaths({
approvedByAsk: true,
argv: command.argv,

View File

@@ -1528,7 +1528,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const tempDir = createFixtureDir("openclaw-shell-wrapper-allow-");
const prepared = buildSystemRunApprovalPlan({
command: ["/bin/sh", "-lc", "cd ."],
command: ["/bin/sh", "-c", "cd ."],
cwd: tempDir,
});
expect(prepared.ok).toBe(true);