refactor: separate exec policy and execution targets

This commit is contained in:
Peter Steinberger
2026-03-23 19:33:29 -07:00
parent a96eded4a0
commit 7f373823b0
14 changed files with 444 additions and 170 deletions

View File

@@ -6,9 +6,9 @@ import {
buildEnforcedShellCommand,
evaluateShellAllowlist,
recordAllowlistUse,
resolveApprovalAuditCandidatePath,
requiresExecApproval,
resolveAllowAlwaysPatterns,
resolvePolicyAllowlistCandidatePath,
} from "../infra/exec-approvals.js";
import {
describeInterpreterInlineEval,
@@ -185,7 +185,7 @@ export async function processGatewayAllowlist(
agentId: params.agentId,
sessionKey: params.sessionKey,
}),
resolvedPath: resolvePolicyAllowlistCandidatePath(
resolvedPath: resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
),
@@ -204,7 +204,7 @@ export async function processGatewayAllowlist(
...requestArgs,
register: registerGatewayApproval,
});
const resolvedPath = resolvePolicyAllowlistCandidatePath(
const resolvedPath = resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
);
@@ -345,7 +345,7 @@ export async function processGatewayAllowlist(
}
recordMatchedAllowlistUse(
resolvePolicyAllowlistCandidatePath(
resolveApprovalAuditCandidatePath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,
),

View File

@@ -145,12 +145,12 @@ export function scanExecSafeBinTrustedDirHints(
for (const scope of collectExecSafeBinScopes(cfg)) {
for (const bin of scope.safeBins) {
const resolution = resolveCommandResolutionFromArgv([bin]);
if (!resolution?.resolvedPath) {
if (!resolution?.execution.resolvedPath) {
continue;
}
if (
isTrustedSafeBinPath({
resolvedPath: resolution.resolvedPath,
resolvedPath: resolution.execution.resolvedPath,
trustedDirs: scope.trustedSafeBinDirs,
})
) {
@@ -159,7 +159,7 @@ export function scanExecSafeBinTrustedDirHints(
hits.push({
scopePath: scope.scopePath,
bin,
resolvedPath: resolution.resolvedPath,
resolvedPath: resolution.execution.resolvedPath,
});
}
}

View File

@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
makePathEnv,
makeTempDir,
} from "./exec-approvals-test-helpers.js";
import {
evaluateShellAllowlist,
requiresExecApproval,
@@ -122,7 +127,13 @@ describe("resolveAllowAlwaysPatterns", () => {
{
raw: exe,
argv: [exe],
resolution: { rawExecutable: exe, resolvedPath: exe, executableName: "openclaw-tool" },
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: exe,
resolvedPath: exe,
executableName: "openclaw-tool",
}),
}),
},
],
});
@@ -140,11 +151,13 @@ describe("resolveAllowAlwaysPatterns", () => {
{
raw: "/bin/zsh -lc 'whoami'",
argv: ["/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
}),
}),
},
],
cwd: dir,
@@ -167,11 +180,13 @@ describe("resolveAllowAlwaysPatterns", () => {
{
raw: "/bin/zsh -lc 'whoami && ls && whoami'",
argv: ["/bin/zsh", "-lc", "whoami && ls && whoami"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
}),
}),
},
],
cwd: dir,
@@ -400,11 +415,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/bin/zsh -s",
argv: ["/bin/zsh", "-s"],
resolution: {
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/bin/zsh",
resolvedPath: "/bin/zsh",
executableName: "zsh",
}),
}),
},
],
platform: process.platform,
@@ -423,11 +440,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/usr/local/bin/zsh -lc whoami",
argv: ["/usr/local/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/local/bin/zsh",
resolvedPath: undefined,
executableName: "/usr/local/bin/zsh",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/local/bin/zsh",
resolvedPath: undefined,
executableName: "/usr/local/bin/zsh",
}),
}),
},
],
cwd: dir,
@@ -448,11 +467,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/usr/bin/nice /bin/zsh -lc whoami",
argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/bin/nice",
resolvedPath: "/usr/bin/nice",
executableName: "nice",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/bin/nice",
resolvedPath: "/usr/bin/nice",
executableName: "nice",
}),
}),
},
],
cwd: dir,
@@ -474,11 +495,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "/usr/bin/time -p /bin/zsh -lc whoami",
argv: ["/usr/bin/time", "-p", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "/usr/bin/time",
resolvedPath: "/usr/bin/time",
executableName: "time",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "/usr/bin/time",
resolvedPath: "/usr/bin/time",
executableName: "time",
}),
}),
},
],
cwd: dir,
@@ -503,11 +526,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: `${busybox} sh -lc whoami`,
argv: [busybox, "sh", "-lc", "whoami"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
}),
}),
},
],
cwd: dir,
@@ -529,11 +554,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: `${busybox} sed -n 1p`,
argv: [busybox, "sed", "-n", "1p"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
}),
}),
},
],
cwd: dir,
@@ -549,11 +576,13 @@ $0 \\"$1\\"" touch ${marker}`,
{
raw: "sudo /bin/zsh -lc whoami",
argv: ["sudo", "/bin/zsh", "-lc", "whoami"],
resolution: {
rawExecutable: "sudo",
resolvedPath: "/usr/bin/sudo",
executableName: "sudo",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "sudo",
resolvedPath: "/usr/bin/sudo",
executableName: "sudo",
}),
}),
},
],
platform: process.platform,

View File

@@ -3,13 +3,15 @@ import {
analyzeShellCommand,
isWindowsPlatform,
matchAllowlist,
resolveAllowlistCandidatePath,
resolveExecutionTargetCandidatePath,
resolveExecutionTargetResolution,
resolveCommandResolutionFromArgv,
resolvePolicyAllowlistCandidatePath,
resolvePolicyTargetCandidatePath,
resolvePolicyTargetResolution,
splitCommandChain,
type ExecCommandAnalysis,
type CommandResolution,
type ExecCommandSegment,
type ExecutableResolution,
} from "./exec-approvals-analysis.js";
import type { ExecAllowlistEntry } from "./exec-approvals.js";
import {
@@ -51,7 +53,7 @@ export function resolveSafeBins(entries?: readonly string[] | null): Set<string>
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
resolution: ExecutableResolution | null;
safeBins: Set<string>;
platform?: string | null;
trustedSafeBinDirs?: ReadonlySet<string>;
@@ -183,15 +185,16 @@ function isSkillAutoAllowedSegment(params: {
return false;
}
const resolution = params.segment.resolution;
if (!resolution?.resolvedPath) {
const execution = resolveExecutionTargetResolution(resolution);
if (!execution?.resolvedPath) {
return false;
}
const rawExecutable = resolution.rawExecutable?.trim() ?? "";
const rawExecutable = execution.rawExecutable?.trim() ?? "";
if (!rawExecutable || isPathScopedExecutableToken(rawExecutable)) {
return false;
}
const executableName = normalizeSkillBinName(resolution.executableName);
const resolvedPath = normalizeSkillBinResolvedPath(resolution.resolvedPath);
const executableName = normalizeSkillBinName(execution.executableName);
const resolvedPath = normalizeSkillBinResolvedPath(execution.resolvedPath);
if (!executableName || !resolvedPath) {
return false;
}
@@ -222,8 +225,8 @@ function evaluateSegments(
: segment.argv;
const allowlistSegment =
effectiveArgv === segment.argv ? segment : { ...segment, argv: effectiveArgv };
const executableResolution = segment.resolution?.policyResolution ?? segment.resolution;
const candidatePath = resolvePolicyAllowlistCandidatePath(segment.resolution, params.cwd);
const executableResolution = resolvePolicyTargetResolution(segment.resolution);
const candidatePath = resolvePolicyTargetCandidatePath(segment.resolution, params.cwd);
const candidateResolution =
candidatePath && executableResolution
? { ...executableResolution, resolvedPath: candidatePath }
@@ -262,7 +265,7 @@ function evaluateSegments(
}
const safe = isSafeBinUsage({
argv: effectiveArgv,
resolution: segment.resolution,
resolution: resolveExecutionTargetResolution(segment.resolution),
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
platform: params.platform,
@@ -337,11 +340,8 @@ function hasSegmentExecutableMatch(
segment: ExecCommandSegment,
predicate: (token: string) => boolean,
): boolean {
const candidates = [
segment.resolution?.executableName,
segment.resolution?.rawExecutable,
segment.argv[0],
];
const execution = resolveExecutionTargetResolution(segment.resolution);
const candidates = [execution?.executableName, execution?.rawExecutable, segment.argv[0]];
for (const candidate of candidates) {
const trimmed = candidate?.trim();
if (!trimmed) {
@@ -464,7 +464,7 @@ function resolveShellWrapperPositionalArgvCandidatePath(params: {
}
const resolution = resolveCommandResolutionFromArgv([carriedExecutable], params.cwd, params.env);
return resolveAllowlistCandidatePath(resolution, params.cwd);
return resolveExecutionTargetCandidatePath(resolution, params.cwd);
}
function isDirectShellPositionalCarrierInvocation(command: string): boolean {
@@ -509,7 +509,7 @@ function collectAllowAlwaysPatterns(params: {
resolution: resolveCommandResolutionFromArgv(trustPlan.argv, params.cwd, params.env),
};
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
const candidatePath = resolveExecutionTargetCandidatePath(segment.resolution, params.cwd);
if (!candidatePath) {
return;
}

View File

@@ -94,7 +94,8 @@ describe("exec approvals shell analysis", () => {
const planned = resolvePlannedSegmentArgv(segment);
expect(planned).toEqual([
segment.resolution?.resolvedRealPath ?? segment.resolution?.resolvedPath,
segment.resolution?.execution.resolvedRealPath ??
segment.resolution?.execution.resolvedPath,
"-lc",
"echo hi",
]);

View File

@@ -9,9 +9,14 @@ export {
matchAllowlist,
parseExecArgvToken,
resolveAllowlistCandidatePath,
resolveApprovalAuditCandidatePath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolveExecutionTargetCandidatePath,
resolveExecutionTargetResolution,
resolvePolicyAllowlistCandidatePath,
resolvePolicyTargetCandidatePath,
resolvePolicyTargetResolution,
type CommandResolution,
type ExecutableResolution,
type ExecArgvToken,
@@ -667,8 +672,9 @@ export function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[]
return null;
}
const argv = [...baseArgv];
const execution = segment.resolution?.execution;
const resolvedExecutable =
segment.resolution?.resolvedRealPath?.trim() ?? segment.resolution?.resolvedPath?.trim() ?? "";
execution?.resolvedRealPath?.trim() ?? execution?.resolvedPath?.trim() ?? "";
if (resolvedExecutable) {
argv[0] = resolvedExecutable;
}

View File

@@ -31,7 +31,7 @@ describe("exec approvals wrapper resolution parity fixture", () => {
for (const fixture of fixtures) {
it(`matches wrapper fixture: ${fixture.id}`, () => {
const resolution = resolveCommandResolutionFromArgv(fixture.argv);
expect(resolution?.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
expect(resolution?.execution.rawExecutable ?? null).toBe(fixture.expectedRawExecutable);
});
}
});

View File

@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
makePathEnv,
makeTempDir,
} from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
evaluateShellAllowlist,
@@ -428,11 +433,13 @@ describe("exec approvals safe bins", () => {
{
raw: "jq .foo",
argv: ["jq", ".foo"],
resolution: {
rawExecutable: "jq",
resolvedPath: "/custom/bin/jq",
executableName: "jq",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq",
resolvedPath: "/custom/bin/jq",
executableName: "jq",
}),
}),
},
],
};
@@ -476,7 +483,7 @@ describe("exec approvals safe bins", () => {
expect(result.analysisOk).toBe(true);
expect(result.allowlistSatisfied).toBe(false);
expect(result.segmentSatisfiedBy).toEqual([null]);
expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead);
expect(result.segments[0]?.resolution?.execution.resolvedPath).toBe(fakeHead);
});
it("fails closed for semantic env wrappers in allowlist mode", () => {

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { CommandResolution, ExecutableResolution } from "./exec-command-resolution.js";
export function makePathEnv(binDir: string): NodeJS.ProcessEnv {
if (process.platform !== "win32") {
@@ -13,6 +14,56 @@ export function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-exec-approvals-"));
}
export function makeMockExecutableResolution(params: {
rawExecutable: string;
executableName: string;
resolvedPath?: string;
resolvedRealPath?: string;
}): ExecutableResolution {
return {
rawExecutable: params.rawExecutable,
resolvedPath: params.resolvedPath,
resolvedRealPath: params.resolvedRealPath,
executableName: params.executableName,
};
}
export function makeMockCommandResolution(params: {
execution: ExecutableResolution;
policy?: ExecutableResolution;
effectiveArgv?: string[];
wrapperChain?: string[];
policyBlocked?: boolean;
blockedWrapper?: string;
}): CommandResolution {
const policy = params.policy ?? params.execution;
const resolution: CommandResolution = {
execution: params.execution,
policy,
effectiveArgv: params.effectiveArgv,
wrapperChain: params.wrapperChain,
policyBlocked: params.policyBlocked,
blockedWrapper: params.blockedWrapper,
};
return Object.defineProperties(resolution, {
rawExecutable: {
get: () => params.execution.rawExecutable,
},
resolvedPath: {
get: () => params.execution.resolvedPath,
},
resolvedRealPath: {
get: () => params.execution.resolvedRealPath,
},
executableName: {
get: () => params.execution.executableName,
},
policyResolution: {
get: () => (policy === params.execution ? undefined : policy),
},
});
}
export type ShellParserParityFixtureCase = {
id: string;
command: string;

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { normalizeSafeBins } from "./exec-approvals-allowlist.js";
import {
makeMockCommandResolution,
makeMockExecutableResolution,
} from "./exec-approvals-test-helpers.js";
import { evaluateExecAllowlist, type ExecAllowlistEntry } from "./exec-approvals.js";
describe("exec approvals allowlist evaluation", () => {
@@ -9,11 +13,7 @@ describe("exec approvals allowlist evaluation", () => {
segments: Array<{
raw: string;
argv: string[];
resolution: {
rawExecutable: string;
executableName: string;
resolvedPath?: string;
};
resolution: ReturnType<typeof makeMockCommandResolution>;
}>;
};
resolvedPath: string;
@@ -40,11 +40,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "tool",
argv: ["tool"],
resolution: {
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
}),
}),
},
],
};
@@ -66,11 +68,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "jq .foo",
argv: ["jq", ".foo"],
resolution: {
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
}),
}),
},
],
};
@@ -96,11 +100,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "skill-bin",
argv: ["skill-bin", "--help"],
resolution: {
rawExecutable: "skill-bin",
resolvedPath: "/opt/skills/skill-bin",
executableName: "skill-bin",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "skill-bin",
resolvedPath: "/opt/skills/skill-bin",
executableName: "skill-bin",
}),
}),
},
],
};
@@ -118,11 +124,13 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "./skill-bin",
argv: ["./skill-bin", "--help"],
resolution: {
rawExecutable: "./skill-bin",
resolvedPath: "/tmp/skill-bin",
executableName: "skill-bin",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "./skill-bin",
resolvedPath: "/tmp/skill-bin",
executableName: "skill-bin",
}),
}),
},
],
};
@@ -140,10 +148,12 @@ describe("exec approvals allowlist evaluation", () => {
{
raw: "skill-bin --help",
argv: ["skill-bin", "--help"],
resolution: {
rawExecutable: "skill-bin",
executableName: "skill-bin",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "skill-bin",
executableName: "skill-bin",
}),
}),
},
],
};
@@ -158,11 +168,13 @@ describe("exec approvals allowlist evaluation", () => {
const segment = {
raw: "tool",
argv: ["tool"],
resolution: {
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
}),
}),
};
const analysis = {
ok: true,
@@ -184,20 +196,24 @@ describe("exec approvals allowlist evaluation", () => {
const allowlistSegment = {
raw: "tool",
argv: ["tool"],
resolution: {
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "tool",
resolvedPath: "/usr/bin/tool",
executableName: "tool",
}),
}),
};
const safeBinSegment = {
raw: "jq .foo",
argv: ["jq", ".foo"],
resolution: {
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
},
resolution: makeMockCommandResolution({
execution: makeMockExecutableResolution({
rawExecutable: "jq",
resolvedPath: "/usr/bin/jq",
executableName: "jq",
}),
}),
};
const analysis = {
ok: true,

View File

@@ -4,12 +4,13 @@ import { describe, expect, it } from "vitest";
import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
import {
evaluateExecAllowlist,
resolvePlannedSegmentArgv,
normalizeSafeBins,
parseExecArgvToken,
resolveAllowlistCandidatePath,
resolveCommandResolution,
resolveCommandResolutionFromArgv,
resolvePolicyAllowlistCandidatePath,
resolveExecutionTargetCandidatePath,
resolvePolicyTargetCandidatePath,
} from "./exec-approvals.js";
function buildNestedEnvShellCommand(params: {
@@ -119,9 +120,9 @@ describe("exec-command-resolution", () => {
for (const testCase of cases) {
const setup = testCase.setup();
const res = resolveCommandResolution(setup.command, setup.cwd, setup.envPath);
expect(res?.resolvedPath, testCase.name).toBe(setup.expectedPath);
expect(res?.execution.resolvedPath, testCase.name).toBe(setup.expectedPath);
if (setup.expectedExecutableName) {
expect(res?.executableName, testCase.name).toBe(setup.expectedExecutableName);
expect(res?.execution.executableName, testCase.name).toBe(setup.expectedExecutableName);
}
}
});
@@ -134,8 +135,8 @@ describe("exec-command-resolution", () => {
undefined,
makePathEnv(fixture.binDir),
);
expect(envResolution?.resolvedPath).toBe(fixture.exePath);
expect(envResolution?.executableName).toBe(fixture.exeName);
expect(envResolution?.execution.resolvedPath).toBe(fixture.exePath);
expect(envResolution?.execution.executableName).toBe(fixture.exeName);
const niceResolution = resolveCommandResolutionFromArgv([
"/usr/bin/nice",
@@ -143,16 +144,16 @@ describe("exec-command-resolution", () => {
"-lc",
"echo hi",
]);
expect(niceResolution?.rawExecutable).toBe("bash");
expect(niceResolution?.executableName.toLowerCase()).toContain("bash");
expect(niceResolution?.execution.rawExecutable).toBe("bash");
expect(niceResolution?.execution.executableName.toLowerCase()).toContain("bash");
const timeResolution = resolveCommandResolutionFromArgv(
["/usr/bin/time", "-p", "rg", "-n", "needle"],
undefined,
makePathEnv(fixture.binDir),
);
expect(timeResolution?.resolvedPath).toBe(fixture.exePath);
expect(timeResolution?.executableName).toBe(fixture.exeName);
expect(timeResolution?.execution.resolvedPath).toBe(fixture.exePath);
expect(timeResolution?.execution.executableName).toBe(fixture.exeName);
});
it("keeps shell multiplexer wrappers as a separate policy target", () => {
@@ -165,13 +166,13 @@ describe("exec-command-resolution", () => {
fs.chmodSync(busybox, 0o755);
const resolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
expect(resolution?.rawExecutable).toBe("sh");
expect(resolution?.execution.rawExecutable).toBe("sh");
expect(resolution?.effectiveArgv).toEqual(["sh", "-lc", "echo hi"]);
expect(resolution?.wrapperChain).toEqual(["busybox"]);
expect(resolution?.policyResolution?.rawExecutable).toBe(busybox);
expect(resolution?.policyResolution?.resolvedPath).toBe(busybox);
expect(resolvePolicyAllowlistCandidatePath(resolution ?? null, dir)).toBe(busybox);
expect(resolution?.executableName.toLowerCase()).toContain("sh");
expect(resolution?.policy.rawExecutable).toBe(busybox);
expect(resolution?.policy.resolvedPath).toBe(busybox);
expect(resolvePolicyTargetCandidatePath(resolution ?? null, dir)).toBe(busybox);
expect(resolution?.execution.executableName.toLowerCase()).toContain("sh");
});
it("does not satisfy inner-shell allowlists when invoked through busybox wrappers", () => {
@@ -184,7 +185,7 @@ describe("exec-command-resolution", () => {
fs.chmodSync(busybox, 0o755);
const shellResolution = resolveCommandResolutionFromArgv(["sh", "-lc", "echo hi"]);
expect(shellResolution?.resolvedPath).toBeTruthy();
expect(shellResolution?.execution.resolvedPath).toBeTruthy();
const wrappedResolution = resolveCommandResolutionFromArgv([busybox, "sh", "-lc", "echo hi"]);
const evalResult = evaluateExecAllowlist({
@@ -198,7 +199,7 @@ describe("exec-command-resolution", () => {
},
],
},
allowlist: [{ pattern: shellResolution?.resolvedPath ?? "" }],
allowlist: [{ pattern: shellResolution?.execution.resolvedPath ?? "" }],
safeBins: normalizeSafeBins([]),
cwd: dir,
});
@@ -215,7 +216,7 @@ describe("exec-command-resolution", () => {
"needle",
]);
expect(blockedEnv?.policyBlocked).toBe(true);
expect(blockedEnv?.rawExecutable).toBe("/usr/bin/env");
expect(blockedEnv?.execution.rawExecutable).toBe("/usr/bin/env");
if (process.platform === "win32") {
return;
@@ -252,7 +253,7 @@ describe("exec-command-resolution", () => {
it("resolves allowlist candidate paths from unresolved raw executables", () => {
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "~/bin/tool",
executableName: "tool",
@@ -262,7 +263,7 @@ describe("exec-command-resolution", () => {
).toContain("/bin/tool");
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "./scripts/run.sh",
executableName: "run.sh",
@@ -272,7 +273,7 @@ describe("exec-command-resolution", () => {
).toBe(path.resolve("/repo", "./scripts/run.sh"));
expect(
resolveAllowlistCandidatePath(
resolveExecutionTargetCandidatePath(
{
rawExecutable: "rg",
executableName: "rg",
@@ -282,6 +283,101 @@ describe("exec-command-resolution", () => {
).toBeUndefined();
});
it("keeps execution and policy targets coherent across wrapper classes", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const envPath = path.join(binDir, "env");
const rgPath = path.join(binDir, "rg");
const busybox = path.join(dir, "busybox");
for (const file of [envPath, rgPath, busybox]) {
fs.writeFileSync(file, "");
fs.chmodSync(file, 0o755);
}
const cases = [
{
name: "transparent env wrapper",
argv: [envPath, "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: rgPath,
expectedPolicyPath: rgPath,
expectedPlannedArgv: [fs.realpathSync(rgPath), "-n", "needle"],
allowlistPattern: rgPath,
allowlistSatisfied: true,
},
{
name: "busybox shell multiplexer",
argv: [busybox, "sh", "-lc", "echo hi"],
env: { PATH: `${binDir}${path.delimiter}/bin:/usr/bin` },
expectedExecutionPath: "/bin/sh",
expectedPolicyPath: busybox,
expectedPlannedArgv: ["/bin/sh", "-lc", "echo hi"],
allowlistPattern: busybox,
allowlistSatisfied: true,
},
{
name: "semantic env wrapper",
argv: [envPath, "FOO=bar", "rg", "-n", "needle"],
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
{
name: "wrapper depth overflow",
argv: buildNestedEnvShellCommand({
envExecutable: envPath,
depth: 5,
payload: "echo hi",
}),
env: makePathEnv(binDir),
expectedExecutionPath: envPath,
expectedPolicyPath: envPath,
expectedPlannedArgv: null,
allowlistPattern: envPath,
allowlistSatisfied: false,
},
] as const;
for (const testCase of cases) {
const argv = [...testCase.argv];
const resolution = resolveCommandResolutionFromArgv(argv, dir, testCase.env);
const segment = {
raw: argv.join(" "),
argv,
resolution,
};
expect(
resolveExecutionTargetCandidatePath(resolution ?? null, dir),
`${testCase.name} execution`,
).toBe(testCase.expectedExecutionPath);
expect(
resolvePolicyTargetCandidatePath(resolution ?? null, dir),
`${testCase.name} policy`,
).toBe(testCase.expectedPolicyPath);
expect(resolvePlannedSegmentArgv(segment), `${testCase.name} planned argv`).toEqual(
testCase.expectedPlannedArgv,
);
const evaluation = evaluateExecAllowlist({
analysis: { ok: true, segments: [segment] },
allowlist: [{ pattern: testCase.allowlistPattern }],
safeBins: normalizeSafeBins([]),
cwd: dir,
env: testCase.env,
});
expect(evaluation.allowlistSatisfied, `${testCase.name} allowlist`).toBe(
testCase.allowlistSatisfied,
);
}
});
it("normalizes argv tokens for short clusters, long options, and special sentinels", () => {
expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" });
expect(parseExecArgvToken("--")).toEqual({ kind: "terminator", raw: "--" });

View File

@@ -13,14 +13,21 @@ export type ExecutableResolution = {
executableName: string;
};
export type CommandResolution = ExecutableResolution & {
export type CommandResolution = {
execution: ExecutableResolution;
policy: ExecutableResolution;
effectiveArgv?: string[];
wrapperChain?: string[];
policyBlocked?: boolean;
blockedWrapper?: string;
policyResolution?: ExecutableResolution;
};
function isCommandResolution(
resolution: CommandResolution | ExecutableResolution | null,
): resolution is CommandResolution {
return Boolean(resolution && "execution" in resolution && "policy" in resolution);
}
function parseFirstToken(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) {
@@ -80,19 +87,36 @@ function buildCommandResolution(params: {
policyBlocked: boolean;
blockedWrapper?: string;
}): CommandResolution {
const resolution = buildExecutableResolution(params.rawExecutable, params);
const policyResolution =
params.policyRawExecutable && params.policyRawExecutable !== params.rawExecutable
? buildExecutableResolution(params.policyRawExecutable, params)
: undefined;
return {
...resolution,
const execution = buildExecutableResolution(params.rawExecutable, params);
const policy = params.policyRawExecutable
? buildExecutableResolution(params.policyRawExecutable, params)
: execution;
const resolution: CommandResolution = {
execution,
policy,
effectiveArgv: params.effectiveArgv,
wrapperChain: params.wrapperChain,
policyBlocked: params.policyBlocked,
blockedWrapper: params.blockedWrapper,
policyResolution,
};
// Compatibility getters for JS/tests while TS callers migrate to explicit targets.
return Object.defineProperties(resolution, {
rawExecutable: {
get: () => execution.rawExecutable,
},
resolvedPath: {
get: () => execution.resolvedPath,
},
resolvedRealPath: {
get: () => execution.resolvedRealPath,
},
executableName: {
get: () => execution.executableName,
},
policyResolution: {
get: () => (policy === execution ? undefined : policy),
},
});
}
export function resolveCommandResolution(
@@ -137,13 +161,6 @@ export function resolveCommandResolutionFromArgv(
});
}
export function resolveAllowlistCandidatePath(
resolution: CommandResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutableCandidatePathFromResolution(resolution, cwd);
}
function resolveExecutableCandidatePathFromResolution(
resolution: ExecutableResolution | null | undefined,
cwd?: string,
@@ -169,16 +186,66 @@ function resolveExecutableCandidatePathFromResolution(
return path.resolve(base, expanded);
}
export function resolvePolicyAllowlistCandidatePath(
resolution: CommandResolution | null,
export function resolveExecutionTargetResolution(
resolution: CommandResolution | ExecutableResolution | null,
): ExecutableResolution | null {
if (!resolution) {
return null;
}
return isCommandResolution(resolution) ? resolution.execution : resolution;
}
export function resolvePolicyTargetResolution(
resolution: CommandResolution | ExecutableResolution | null,
): ExecutableResolution | null {
if (!resolution) {
return null;
}
return isCommandResolution(resolution) ? resolution.policy : resolution;
}
export function resolveExecutionTargetCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutableCandidatePathFromResolution(
resolution?.policyResolution ?? resolution,
isCommandResolution(resolution) ? resolution.execution : resolution,
cwd,
);
}
export function resolvePolicyTargetCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutableCandidatePathFromResolution(
isCommandResolution(resolution) ? resolution.policy : resolution,
cwd,
);
}
export function resolveApprovalAuditCandidatePath(
resolution: CommandResolution | null,
cwd?: string,
): string | undefined {
return resolvePolicyTargetCandidatePath(resolution, cwd);
}
// Legacy alias kept while callers migrate to explicit target naming.
export function resolveAllowlistCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolveExecutionTargetCandidatePath(resolution, cwd);
}
export function resolvePolicyAllowlistCandidatePath(
resolution: CommandResolution | ExecutableResolution | null,
cwd?: string,
): string | undefined {
return resolvePolicyTargetCandidatePath(resolution, cwd);
}
export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: ExecutableResolution | null,

View File

@@ -1004,7 +1004,8 @@ export function hardenApprovedExecutionPaths(params: {
};
}
const pinnedExecutable = resolution?.resolvedRealPath ?? resolution?.resolvedPath;
const pinnedExecutable =
resolution?.execution.resolvedRealPath ?? resolution?.execution.resolvedPath;
if (!pinnedExecutable) {
return {
ok: false,

View File

@@ -5,9 +5,9 @@ import type { GatewayClient } from "../gateway/client.js";
import {
addAllowlistEntry,
recordAllowlistUse,
resolveApprovalAuditCandidatePath,
resolveAllowAlwaysPatterns,
resolveExecApprovals,
resolvePolicyAllowlistCandidatePath,
type ExecAllowlistEntry,
type ExecAsk,
type ExecCommandSegment,
@@ -576,7 +576,7 @@ async function executeSystemRunPhase(
phase.agentId,
match,
phase.commandText,
resolvePolicyAllowlistCandidatePath(phase.segments[0]?.resolution ?? null, phase.cwd),
resolveApprovalAuditCandidatePath(phase.segments[0]?.resolution ?? null, phase.cwd),
);
}
}