mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 01:50:41 +00:00
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:
committed by
GitHub
parent
eabae023eb
commit
fc065b2693
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ export {
|
||||
unwrapKnownDispatchWrapperInvocation,
|
||||
} from "./dispatch-wrapper-resolution.js";
|
||||
export {
|
||||
extractBindableShellWrapperInlineCommand,
|
||||
extractShellWrapperCommand,
|
||||
extractShellWrapperInlineCommand,
|
||||
hasEnvManipulationBeforeShellWrapper,
|
||||
isBlockedShellWrapperCommand,
|
||||
isShellWrapperExecutable,
|
||||
isShellWrapperInvocation,
|
||||
POSIX_SHELL_WRAPPERS,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user