mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(security): harden exec wrapper allowlist execution parity
This commit is contained in:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution.
|
||||
- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung.
|
||||
- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
|
||||
- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
|
||||
|
||||
## 2026.2.23 (Unreleased)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import {
|
||||
addAllowlistEntry,
|
||||
type ExecAsk,
|
||||
type ExecSecurity,
|
||||
buildSafeBinsShellCommand,
|
||||
buildSafeShellCommand,
|
||||
buildEnforcedShellCommand,
|
||||
evaluateShellAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
@@ -83,6 +82,18 @@ export async function processGatewayAllowlist(
|
||||
const analysisOk = allowlistEval.analysisOk;
|
||||
const allowlistSatisfied =
|
||||
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
||||
let enforcedCommand: string | undefined;
|
||||
if (hostSecurity === "allowlist" && analysisOk && allowlistSatisfied) {
|
||||
const enforced = buildEnforcedShellCommand({
|
||||
command: params.command,
|
||||
segments: allowlistEval.segments,
|
||||
platform: process.platform,
|
||||
});
|
||||
if (!enforced.ok || !enforced.command) {
|
||||
throw new Error(`exec denied: allowlist execution plan unavailable (${enforced.reason})`);
|
||||
}
|
||||
enforcedCommand = enforced.command;
|
||||
}
|
||||
const obfuscation = detectCommandObfuscation(params.command);
|
||||
if (obfuscation.detected) {
|
||||
logInfo(`exec: obfuscation detected (gateway): ${obfuscation.reasons.join(", ")}`);
|
||||
@@ -216,6 +227,7 @@ export async function processGatewayAllowlist(
|
||||
try {
|
||||
run = await runExecProcess({
|
||||
command: params.command,
|
||||
execCommand: enforcedCommand,
|
||||
workdir: params.workdir,
|
||||
env: params.env,
|
||||
sandbox: undefined,
|
||||
@@ -294,43 +306,7 @@ export async function processGatewayAllowlist(
|
||||
throw new Error("exec denied: allowlist miss");
|
||||
}
|
||||
|
||||
let execCommandOverride: string | undefined;
|
||||
// If allowlist uses safeBins, sanitize only those stdin-only segments:
|
||||
// disable glob/var expansion by forcing argv tokens to be literal via single-quoting.
|
||||
if (
|
||||
hostSecurity === "allowlist" &&
|
||||
analysisOk &&
|
||||
allowlistSatisfied &&
|
||||
allowlistEval.segmentSatisfiedBy.some((by) => by === "safeBins")
|
||||
) {
|
||||
const safe = buildSafeBinsShellCommand({
|
||||
command: params.command,
|
||||
segments: allowlistEval.segments,
|
||||
segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
|
||||
platform: process.platform,
|
||||
});
|
||||
if (!safe.ok || !safe.command) {
|
||||
// Fallback: quote everything (safe, but may change glob behavior).
|
||||
const fallback = buildSafeShellCommand({
|
||||
command: params.command,
|
||||
platform: process.platform,
|
||||
});
|
||||
if (!fallback.ok || !fallback.command) {
|
||||
throw new Error(`exec denied: safeBins sanitize failed (${safe.reason ?? "unknown"})`);
|
||||
}
|
||||
params.warnings.push(
|
||||
"Warning: safeBins hardening used fallback quoting due to parser mismatch.",
|
||||
);
|
||||
execCommandOverride = fallback.command;
|
||||
} else {
|
||||
params.warnings.push(
|
||||
"Warning: safeBins hardening disabled glob/variable expansion for stdin-only segments.",
|
||||
);
|
||||
execCommandOverride = safe.command;
|
||||
}
|
||||
}
|
||||
|
||||
recordMatchedAllowlistUse(allowlistEval.segments[0]?.resolution?.resolvedPath);
|
||||
|
||||
return { execCommandOverride };
|
||||
return { execCommandOverride: enforcedCommand };
|
||||
}
|
||||
|
||||
@@ -122,6 +122,14 @@ function evaluateSegments(
|
||||
const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
|
||||
|
||||
const satisfied = segments.every((segment) => {
|
||||
if (segment.resolution?.policyBlocked === true) {
|
||||
segmentSatisfiedBy.push(null);
|
||||
return false;
|
||||
}
|
||||
const effectiveArgv =
|
||||
segment.resolution?.effectiveArgv && segment.resolution.effectiveArgv.length > 0
|
||||
? segment.resolution.effectiveArgv
|
||||
: segment.argv;
|
||||
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
|
||||
const candidateResolution =
|
||||
candidatePath && segment.resolution
|
||||
@@ -132,7 +140,7 @@ function evaluateSegments(
|
||||
matches.push(match);
|
||||
}
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
argv: effectiveArgv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: params.safeBins,
|
||||
safeBinProfiles: params.safeBinProfiles,
|
||||
|
||||
@@ -626,12 +626,30 @@ function renderQuotedArgv(argv: string[]): string {
|
||||
return argv.map((token) => shellEscapeSingleArg(token)).join(" ");
|
||||
}
|
||||
|
||||
function renderSafeBinSegmentArgv(segment: ExecCommandSegment): string {
|
||||
if (segment.argv.length === 0) {
|
||||
return "";
|
||||
function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null {
|
||||
if (segment.resolution?.policyBlocked === true) {
|
||||
return null;
|
||||
}
|
||||
const baseArgv =
|
||||
segment.resolution?.effectiveArgv && segment.resolution.effectiveArgv.length > 0
|
||||
? segment.resolution.effectiveArgv
|
||||
: segment.argv;
|
||||
if (baseArgv.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const argv = [...baseArgv];
|
||||
const resolvedExecutable = segment.resolution?.resolvedPath?.trim() ?? "";
|
||||
if (resolvedExecutable) {
|
||||
argv[0] = resolvedExecutable;
|
||||
}
|
||||
return argv;
|
||||
}
|
||||
|
||||
function renderSafeBinSegmentArgv(segment: ExecCommandSegment): string | null {
|
||||
const argv = resolvePlannedSegmentArgv(segment);
|
||||
if (!argv || argv.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const resolvedExecutable = segment.resolution?.resolvedPath?.trim();
|
||||
const argv = resolvedExecutable ? [resolvedExecutable, ...segment.argv.slice(1)] : segment.argv;
|
||||
return renderQuotedArgv(argv);
|
||||
}
|
||||
|
||||
@@ -659,7 +677,43 @@ export function buildSafeBinsShellCommand(params: {
|
||||
return { ok: false, reason: "segment mapping failed" };
|
||||
}
|
||||
const needsLiteral = by === "safeBins";
|
||||
return { ok: true, rendered: needsLiteral ? renderSafeBinSegmentArgv(seg) : raw.trim() };
|
||||
if (!needsLiteral) {
|
||||
return { ok: true, rendered: raw.trim() };
|
||||
}
|
||||
const rendered = renderSafeBinSegmentArgv(seg);
|
||||
if (!rendered) {
|
||||
return { ok: false, reason: "segment execution plan unavailable" };
|
||||
}
|
||||
return { ok: true, rendered };
|
||||
},
|
||||
});
|
||||
if (!rebuilt.ok) {
|
||||
return { ok: false, reason: rebuilt.reason };
|
||||
}
|
||||
if (rebuilt.segmentCount !== params.segments.length) {
|
||||
return { ok: false, reason: "segment count mismatch" };
|
||||
}
|
||||
return { ok: true, command: rebuilt.command };
|
||||
}
|
||||
|
||||
export function buildEnforcedShellCommand(params: {
|
||||
command: string;
|
||||
segments: ExecCommandSegment[];
|
||||
platform?: string | null;
|
||||
}): { ok: boolean; command?: string; reason?: string } {
|
||||
const rebuilt = rebuildShellCommandFromSource({
|
||||
command: params.command,
|
||||
platform: params.platform,
|
||||
renderSegment: (_raw, segmentIndex) => {
|
||||
const seg = params.segments[segmentIndex];
|
||||
if (!seg) {
|
||||
return { ok: false, reason: "segment mapping failed" };
|
||||
}
|
||||
const argv = resolvePlannedSegmentArgv(seg);
|
||||
if (!argv) {
|
||||
return { ok: false, reason: "segment execution plan unavailable" };
|
||||
}
|
||||
return { ok: true, rendered: renderQuotedArgv(argv) };
|
||||
},
|
||||
});
|
||||
if (!rebuilt.ok) {
|
||||
|
||||
@@ -221,6 +221,14 @@ describe("exec approvals safe bins", () => {
|
||||
safeBins: ["sort"],
|
||||
executableName: "sort",
|
||||
},
|
||||
{
|
||||
name: "rejects unknown short options in safe-bin mode",
|
||||
argv: ["tr", "-S", "a", "b"],
|
||||
resolvedPath: "/usr/bin/tr",
|
||||
expected: false,
|
||||
safeBins: ["tr"],
|
||||
executableName: "tr",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
@@ -464,4 +472,21 @@ describe("exec approvals safe bins", () => {
|
||||
expect(result.segmentSatisfiedBy).toEqual([null]);
|
||||
expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead);
|
||||
});
|
||||
|
||||
it("fails closed for semantic env wrappers in allowlist mode", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const result = evaluateShellAllowlist({
|
||||
command: "env -S 'sh -c \"echo pwned\"' tr",
|
||||
allowlist: [{ pattern: "/usr/bin/tr" }],
|
||||
safeBins: normalizeSafeBins(["tr"]),
|
||||
cwd: "/tmp",
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(result.analysisOk).toBe(true);
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentSatisfiedBy).toEqual([null]);
|
||||
expect(result.segments[0]?.resolution?.policyBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||
import {
|
||||
analyzeArgvCommand,
|
||||
analyzeShellCommand,
|
||||
buildEnforcedShellCommand,
|
||||
buildSafeBinsShellCommand,
|
||||
evaluateExecAllowlist,
|
||||
evaluateShellAllowlist,
|
||||
@@ -130,6 +131,27 @@ describe("exec approvals safe shell command builder", () => {
|
||||
// SafeBins segment is fully quoted and pinned to its resolved absolute path.
|
||||
expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/);
|
||||
});
|
||||
|
||||
it("enforces canonical planned argv for every approved segment", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const analysis = analyzeShellCommand({
|
||||
command: "env rg -n needle",
|
||||
cwd: "/tmp",
|
||||
env: { PATH: "/usr/bin:/bin" },
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(analysis.ok).toBe(true);
|
||||
const res = buildEnforcedShellCommand({
|
||||
command: "env rg -n needle",
|
||||
segments: analysis.segments,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/);
|
||||
expect(res.command).not.toContain("'env'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals command resolution", () => {
|
||||
@@ -202,7 +224,7 @@ describe("exec approvals command resolution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps env wrapper argv to resolve the effective executable", () => {
|
||||
it("unwraps transparent env wrapper argv to resolve the effective executable", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
@@ -212,7 +234,7 @@ describe("exec approvals command resolution", () => {
|
||||
fs.chmodSync(exe, 0o755);
|
||||
|
||||
const resolution = resolveCommandResolutionFromArgv(
|
||||
["/usr/bin/env", "FOO=bar", "rg", "-n", "needle"],
|
||||
["/usr/bin/env", "rg", "-n", "needle"],
|
||||
undefined,
|
||||
makePathEnv(binDir),
|
||||
);
|
||||
@@ -220,6 +242,18 @@ describe("exec approvals command resolution", () => {
|
||||
expect(resolution?.executableName).toBe(exeName);
|
||||
});
|
||||
|
||||
it("blocks semantic env wrappers from allowlist/safeBins auto-resolution", () => {
|
||||
const resolution = resolveCommandResolutionFromArgv([
|
||||
"/usr/bin/env",
|
||||
"FOO=bar",
|
||||
"rg",
|
||||
"-n",
|
||||
"needle",
|
||||
]);
|
||||
expect(resolution?.policyBlocked).toBe(true);
|
||||
expect(resolution?.rawExecutable).toBe("/usr/bin/env");
|
||||
});
|
||||
|
||||
it("unwraps env wrapper with shell inner executable", () => {
|
||||
const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]);
|
||||
expect(resolution?.rawExecutable).toBe("bash");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
||||
import { unwrapDispatchWrappersForResolution } from "./exec-wrapper-resolution.js";
|
||||
import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js";
|
||||
import { expandHomePrefix } from "./home-dir.js";
|
||||
|
||||
export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"];
|
||||
@@ -10,6 +10,10 @@ export type CommandResolution = {
|
||||
rawExecutable: string;
|
||||
resolvedPath?: string;
|
||||
executableName: string;
|
||||
effectiveArgv?: string[];
|
||||
wrapperChain?: string[];
|
||||
policyBlocked?: boolean;
|
||||
blockedWrapper?: string;
|
||||
};
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
@@ -93,7 +97,14 @@ export function resolveCommandResolution(
|
||||
}
|
||||
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
|
||||
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
|
||||
return { rawExecutable, resolvedPath, executableName };
|
||||
return {
|
||||
rawExecutable,
|
||||
resolvedPath,
|
||||
executableName,
|
||||
effectiveArgv: [rawExecutable],
|
||||
wrapperChain: [],
|
||||
policyBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommandResolutionFromArgv(
|
||||
@@ -101,14 +112,23 @@ export function resolveCommandResolutionFromArgv(
|
||||
cwd?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): CommandResolution | null {
|
||||
const effectiveArgv = unwrapDispatchWrappersForResolution(argv);
|
||||
const plan = resolveDispatchWrapperExecutionPlan(argv);
|
||||
const effectiveArgv = plan.argv;
|
||||
const rawExecutable = effectiveArgv[0]?.trim();
|
||||
if (!rawExecutable) {
|
||||
return null;
|
||||
}
|
||||
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
|
||||
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
|
||||
return { rawExecutable, resolvedPath, executableName };
|
||||
return {
|
||||
rawExecutable,
|
||||
resolvedPath,
|
||||
executableName,
|
||||
effectiveArgv,
|
||||
wrapperChain: plan.wrappers,
|
||||
policyBlocked: plan.policyBlocked,
|
||||
blockedWrapper: plan.blockedWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatchTarget(value: string): string {
|
||||
|
||||
@@ -363,7 +363,7 @@ function consumeLongOptionToken(
|
||||
function consumeShortOptionClusterToken(
|
||||
args: string[],
|
||||
index: number,
|
||||
raw: string,
|
||||
_raw: string,
|
||||
cluster: string,
|
||||
flags: string[],
|
||||
allowedValueFlags: ReadonlySet<string>,
|
||||
@@ -383,7 +383,7 @@ function consumeShortOptionClusterToken(
|
||||
}
|
||||
return isInvalidValueToken(args[index + 1]) ? -1 : index + 2;
|
||||
}
|
||||
return hasGlobToken(raw) ? -1 : index + 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function consumePositionalToken(token: string, positional: string[]): boolean {
|
||||
|
||||
@@ -79,6 +79,7 @@ const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]);
|
||||
const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]);
|
||||
const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]);
|
||||
const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]);
|
||||
const TRANSPARENT_DISPATCH_WRAPPERS = new Set(["nice", "nohup", "stdbuf", "timeout"]);
|
||||
|
||||
type ShellWrapperKind = "posix" | "cmd" | "powershell";
|
||||
|
||||
@@ -348,6 +349,13 @@ export type DispatchWrapperUnwrapResult =
|
||||
| { kind: "blocked"; wrapper: string }
|
||||
| { kind: "unwrapped"; wrapper: string; argv: string[] };
|
||||
|
||||
export type DispatchWrapperExecutionPlan = {
|
||||
argv: string[];
|
||||
wrappers: string[];
|
||||
policyBlocked: boolean;
|
||||
blockedWrapper?: string;
|
||||
};
|
||||
|
||||
function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult {
|
||||
return { kind: "blocked", wrapper };
|
||||
}
|
||||
@@ -394,15 +402,48 @@ export function unwrapDispatchWrappersForResolution(
|
||||
argv: string[],
|
||||
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
|
||||
): string[] {
|
||||
const plan = resolveDispatchWrapperExecutionPlan(argv, maxDepth);
|
||||
return plan.argv;
|
||||
}
|
||||
|
||||
function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolean {
|
||||
if (wrapper === "env") {
|
||||
return envInvocationUsesModifiers(argv);
|
||||
}
|
||||
return !TRANSPARENT_DISPATCH_WRAPPERS.has(wrapper);
|
||||
}
|
||||
|
||||
export function resolveDispatchWrapperExecutionPlan(
|
||||
argv: string[],
|
||||
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
|
||||
): DispatchWrapperExecutionPlan {
|
||||
let current = argv;
|
||||
const wrappers: string[] = [];
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
const unwrap = unwrapKnownDispatchWrapperInvocation(current);
|
||||
if (unwrap.kind === "blocked") {
|
||||
return {
|
||||
argv: current,
|
||||
wrappers,
|
||||
policyBlocked: true,
|
||||
blockedWrapper: unwrap.wrapper,
|
||||
};
|
||||
}
|
||||
if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) {
|
||||
break;
|
||||
}
|
||||
wrappers.push(unwrap.wrapper);
|
||||
if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) {
|
||||
return {
|
||||
argv: current,
|
||||
wrappers,
|
||||
policyBlocked: true,
|
||||
blockedWrapper: unwrap.wrapper,
|
||||
};
|
||||
}
|
||||
current = unwrap.argv;
|
||||
}
|
||||
return current;
|
||||
return { argv: current, wrappers, policyBlocked: false };
|
||||
}
|
||||
|
||||
function hasEnvManipulationBeforeShellWrapperInternal(
|
||||
|
||||
@@ -20,6 +20,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
async function runSystemInvoke(params: {
|
||||
preferMacAppExecHost: boolean;
|
||||
runViaResponse?: ExecHostResponse | null;
|
||||
command?: string[];
|
||||
security?: "full" | "allowlist";
|
||||
ask?: "off" | "on-miss" | "always";
|
||||
approved?: boolean;
|
||||
}) {
|
||||
const runCommand = vi.fn(async () => ({
|
||||
success: true,
|
||||
@@ -37,8 +41,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
await handleSystemRunInvoke({
|
||||
client: {} as never,
|
||||
params: {
|
||||
command: ["echo", "ok"],
|
||||
approved: true,
|
||||
command: params.command ?? ["echo", "ok"],
|
||||
approved: params.approved ?? false,
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
skillBins: {
|
||||
@@ -46,8 +50,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
},
|
||||
execHostEnforced: false,
|
||||
execHostFallbackAllowed: true,
|
||||
resolveExecSecurity: () => "full",
|
||||
resolveExecAsk: () => "off",
|
||||
resolveExecSecurity: () => params.security ?? "full",
|
||||
resolveExecAsk: () => params.ask ?? "off",
|
||||
isCmdExeInvocation: () => false,
|
||||
sanitizeEnv: () => undefined,
|
||||
runCommand,
|
||||
@@ -112,4 +116,35 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs canonical argv in allowlist mode for transparent env wrappers", async () => {
|
||||
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
security: "allowlist",
|
||||
command: ["env", "tr", "a", "b"],
|
||||
});
|
||||
expect(runCommand).toHaveBeenCalledWith(["tr", "a", "b"], undefined, undefined, undefined);
|
||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("denies semantic env wrappers in allowlist mode", async () => {
|
||||
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
security: "allowlist",
|
||||
command: ["env", "FOO=bar", "tr", "a", "b"],
|
||||
});
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringContaining("allowlist miss"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,10 +198,40 @@ export async function handleSystemRunInvoke(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
let plannedAllowlistArgv: string[] | undefined;
|
||||
if (
|
||||
security === "allowlist" &&
|
||||
!policy.approvedByAsk &&
|
||||
!shellCommand &&
|
||||
policy.analysisOk &&
|
||||
policy.allowlistSatisfied &&
|
||||
segments.length === 1
|
||||
) {
|
||||
plannedAllowlistArgv = segments[0]?.resolution?.effectiveArgv;
|
||||
if (!plannedAllowlistArgv || plannedAllowlistArgv.length === 0) {
|
||||
await opts.sendNodeEvent(
|
||||
opts.client,
|
||||
"exec.denied",
|
||||
opts.buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "execution-plan-miss",
|
||||
}),
|
||||
);
|
||||
await opts.sendInvokeResult({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: execution plan mismatch" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const useMacAppExec = opts.preferMacAppExecHost;
|
||||
if (useMacAppExec) {
|
||||
const execRequest: ExecHostRequest = {
|
||||
command: argv,
|
||||
command: plannedAllowlistArgv ?? argv,
|
||||
rawCommand: rawCommand || shellCommand || null,
|
||||
cwd: opts.params.cwd ?? null,
|
||||
env: envOverrides ?? null,
|
||||
@@ -315,7 +345,7 @@ export async function handleSystemRunInvoke(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
let execArgv = argv;
|
||||
let execArgv = plannedAllowlistArgv ?? argv;
|
||||
if (
|
||||
security === "allowlist" &&
|
||||
isWindows &&
|
||||
|
||||
@@ -8,22 +8,22 @@
|
||||
{
|
||||
"id": "env-assignment-prefix",
|
||||
"argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "env-option-with-separate-value",
|
||||
"argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "env-option-with-inline-value",
|
||||
"argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
|
||||
"expectedRawExecutable": "/usr/bin/printf"
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "nested-env-wrappers",
|
||||
"argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
|
||||
"expectedRawExecutable": "printf"
|
||||
"expectedRawExecutable": "/usr/bin/env"
|
||||
},
|
||||
{
|
||||
"id": "env-shell-wrapper-stops-at-shell",
|
||||
|
||||
Reference in New Issue
Block a user