mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix: bind package-manager exec approvals to inner commands [AI] (#102035)
* fix: bind package-manager exec approvals to inner commands * fix: fail closed package manager exec approval gaps * chore: refresh pr branch metadata * fix: cover npm exec alias approvals * fix: fail closed hidden package manager exec aliases * fix: unwrap chained package manager approvals * fix: cover yarn package manager approvals * fix: preserve yarn run approval compatibility * fix: bind package manager script argv checks * test: fix package manager script argv type check * fix: fail closed pnpm shorthand approvals * fix: preserve pnpm built-in approvals * fix: preserve pnpm script shortcuts * fix: preserve npm cwd exec unwrapping * fix: fail closed cwd and yarn package shorthands
This commit is contained in:
parent
c69724ef3b
commit
534ace4d8a
10 changed files with 1651 additions and 232 deletions
|
|
@ -60,7 +60,6 @@ const XCRUN_FLAG_OPTIONS = new Set([
|
|||
"-v",
|
||||
"--verbose",
|
||||
]);
|
||||
|
||||
function isArchSelectorToken(token: string): boolean {
|
||||
return /^-[A-Za-z0-9_]+$/.test(token);
|
||||
}
|
||||
|
|
@ -481,6 +480,9 @@ const DISPATCH_WRAPPER_SPECS: readonly DispatchWrapperSpec[] = [
|
|||
const DISPATCH_WRAPPER_SPEC_BY_NAME = new Map(
|
||||
DISPATCH_WRAPPER_SPECS.map((spec) => [spec.name, spec] as const),
|
||||
);
|
||||
function normalizeDispatchWrapperName(token: string): string {
|
||||
return normalizeExecutableToken(token);
|
||||
}
|
||||
|
||||
type DispatchWrapperUnwrapResult =
|
||||
| { kind: "not-wrapper" }
|
||||
|
|
@ -508,7 +510,7 @@ function unwrapDispatchWrapper(
|
|||
}
|
||||
|
||||
export function isDispatchWrapperExecutable(token: string): boolean {
|
||||
return DISPATCH_WRAPPER_SPEC_BY_NAME.has(normalizeExecutableToken(token));
|
||||
return DISPATCH_WRAPPER_SPEC_BY_NAME.has(normalizeDispatchWrapperName(token));
|
||||
}
|
||||
|
||||
export function unwrapKnownDispatchWrapperInvocation(
|
||||
|
|
@ -519,7 +521,7 @@ export function unwrapKnownDispatchWrapperInvocation(
|
|||
if (!token0) {
|
||||
return { kind: "not-wrapper" };
|
||||
}
|
||||
const wrapper = normalizeExecutableToken(token0);
|
||||
const wrapper = normalizeDispatchWrapperName(token0);
|
||||
const spec = DISPATCH_WRAPPER_SPEC_BY_NAME.get(wrapper);
|
||||
if (!spec) {
|
||||
return { kind: "not-wrapper" };
|
||||
|
|
|
|||
|
|
@ -37,6 +37,423 @@ describe("resolveAllowAlwaysPersistenceDecision", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("persists package-manager exec approvals against the inner executable", async () => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "pnpm");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "pnpm --reporter silent exec -- tsx ./run.ts";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists pnpm cwd exec approvals against the inner executable", async () => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "pnpm");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "pnpm -C ./package exec -- tsx ./run.ts";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["env --", "nice"])(
|
||||
"persists dispatch-wrapped package-manager exec approvals against the inner executable: %s",
|
||||
async (wrapper) => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["env", "nice", "pnpm"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = `${wrapper} pnpm exec -- tsx ./run.ts`;
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["--workspace=a", "--workspace a", "--workspaces"])(
|
||||
"persists npm workspace exec approvals against the inner executable: %s",
|
||||
async (workspaceOption) => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "npm");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = `npm ${workspaceOption} exec -- tsx ./run.ts`;
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("persists npm cwd exec approvals against the inner executable", async () => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "npm");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "npm -C ./package exec -- tsx ./run.ts";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists npm x approvals against the inner executable", async () => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "npm");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "npm x -- tsx ./run.ts";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists chained package-manager exec approvals against the final inner executable", async () => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["pnpm", "npm"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "pnpm exec -- npm x -- tsx ./run.ts";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["exec --", "dlx"])(
|
||||
"persists yarn %s approvals against the inner executable",
|
||||
async (subcommand) => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "yarn");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = `yarn ${subcommand} tsx ./run.ts`;
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "patterns",
|
||||
commandText: command,
|
||||
patterns: [expect.objectContaining({ pattern: tsxPath })],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps package-manager shell carriers one-shot", async () => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "echo");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "pnpm exec sh -c 'echo warmup-ok'";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
expect(resolveExecApprovalAllowedDecisions({ allowAlwaysPersistence: decision })).toEqual([
|
||||
"allow-once",
|
||||
"deny",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["--workspace=a", "--workspace a", "--workspaces"])(
|
||||
"keeps npm workspace shell carriers one-shot: %s",
|
||||
async (workspaceOption) => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["npm", "sh", "echo"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const env = makePathEnv(dir);
|
||||
const command = `npm ${workspaceOption} exec sh -c 'echo warmup-ok'`;
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps npm x shell carriers one-shot", async () => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["npm", "sh", "echo"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const env = makePathEnv(dir);
|
||||
const command = "npm x sh -c 'echo warmup-ok'";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps chained package-manager shell carriers one-shot", async () => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["pnpm", "npm", "sh", "echo"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const env = makePathEnv(dir);
|
||||
const command = "pnpm exec -- npm x sh -c 'echo warmup-ok'";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["yarn run sh -c 'echo warmup-ok'", "yarn sh -c 'echo warmup-ok'"])(
|
||||
"keeps yarn script or bin fallback carriers one-shot: %s",
|
||||
async (command) => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "yarn");
|
||||
for (const executable of ["sh", "echo"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const env = makePathEnv(dir);
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["env --", "nice"])(
|
||||
"keeps dispatch-wrapped package-manager shell carriers one-shot: %s",
|
||||
async (wrapper) => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["env", "nice", "pnpm", "sh"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
makeExecutable(dir, "echo");
|
||||
const env = makePathEnv(dir);
|
||||
const command = `${wrapper} pnpm exec sh -c 'echo warmup-ok'`;
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ flag: "-c", wrapper: "" },
|
||||
{ flag: "--shell-mode", wrapper: "" },
|
||||
{ flag: "-c", wrapper: "env --" },
|
||||
{ flag: "--shell-mode", wrapper: "env --" },
|
||||
])(
|
||||
"keeps pnpm shell-mode exec approvals one-shot: $wrapper pnpm exec $flag",
|
||||
async ({ flag, wrapper }) => {
|
||||
const dir = makeTempDir();
|
||||
for (const executable of ["env", "pnpm"]) {
|
||||
makeExecutable(dir, executable);
|
||||
}
|
||||
const env = makePathEnv(dir);
|
||||
const command = `${wrapper} pnpm exec ${flag} "sh -c 'echo warmup-ok'"`.trim();
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps package-manager shell-call modes one-shot", async () => {
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "npx");
|
||||
const env = makePathEnv(dir);
|
||||
const command = "npx --call \"sh -c 'echo warmup-ok'\"";
|
||||
const plan = await planShellAuthorization({ command, cwd: dir, env });
|
||||
|
||||
const decision = resolveAllowAlwaysPersistenceDecision({
|
||||
segments: plannedSegments(plan),
|
||||
commandText: command,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
authorizationPlan: plan,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: "one-shot",
|
||||
reasons: expect.arrayContaining(["no-reusable-pattern"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps shell wrappers without reusable patterns one-shot", async () => {
|
||||
const cwd = makeTempDir();
|
||||
const command = "sh -c './scripts/run.sh'";
|
||||
|
|
|
|||
|
|
@ -871,6 +871,528 @@ $0 \\"$1\\"" touch {marker}`,
|
|||
});
|
||||
});
|
||||
|
||||
it("prevents allow-always bypass for package-manager shell carriers", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "sh");
|
||||
const echo = makeExecutable(dir, "echo");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
|
||||
await expectAllowAlwaysBypassBlocked({
|
||||
dir,
|
||||
firstCommand: "pnpm exec sh -c 'echo warmup-ok'",
|
||||
secondCommand: "pnpm exec sh -c 'id > marker'",
|
||||
env,
|
||||
persistedPattern: null,
|
||||
allowlistPattern: echo,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale package-manager allow-always entries for shell carriers", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm exec sh -c 'id > marker'",
|
||||
allowlist: [{ pattern: pnpmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["exec", "x"])(
|
||||
"rejects stale npm allow-always entries when unknown options hide %s",
|
||||
async (subcommand) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const npmPath = makeExecutable(dir, "npm");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: `npm --unknown-global-option ${subcommand} sh -c 'id > marker'`,
|
||||
allowlist: [{ pattern: npmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects stale pnpm allow-always entries when unknown options hide exec", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm --unknown-global-option exec sh -c 'id > marker'",
|
||||
allowlist: [{ pattern: pnpmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stale npm allow-always entries for x shell carriers", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const npmPath = makeExecutable(dir, "npm");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "npm x sh -c 'id > marker'",
|
||||
allowlist: [{ pattern: npmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stale package-manager allow-always entries for chained shell carriers", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
const npmPath = makeExecutable(dir, "npm");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm exec -- npm x sh -c 'id > marker'",
|
||||
allowlist: [
|
||||
{ pattern: pnpmPath, source: "allow-always" },
|
||||
{ pattern: npmPath, source: "allow-always" },
|
||||
],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stale yarn allow-always entries for exec-like carriers", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const yarnPath = makeExecutable(dir, "yarn");
|
||||
makeExecutable(dir, "sh");
|
||||
makeExecutable(dir, "id");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "yarn exec -- sh -c 'id > marker'",
|
||||
allowlist: [{ pattern: yarnPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ command: "npm run test -- x", executable: "npm" },
|
||||
{ command: "pnpm run build -- node", executable: "pnpm" },
|
||||
{ command: "pnpm test -- node", executable: "pnpm" },
|
||||
{ command: "pnpm install", executable: "pnpm" },
|
||||
{ command: "yarn install", executable: "yarn" },
|
||||
])(
|
||||
"keeps exec-like arguments on known non-exec package-manager subcommands allowlisted: $command",
|
||||
async ({ command, executable }) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const executablePath = makeExecutable(dir, executable);
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command,
|
||||
allowlist: [{ pattern: executablePath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects stale pnpm allow-always entries for implicit exec shorthands", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "eslint");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm eslint .",
|
||||
allowlist: [{ pattern: pnpmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stale pnpm allow-always entries for cwd implicit exec shorthands", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "eslint");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm -C ./package eslint .",
|
||||
allowlist: [{ pattern: pnpmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["yarn run eslint .", "yarn eslint ."])(
|
||||
"rejects stale yarn allow-always entries for script or bin fallback: %s",
|
||||
async (command) => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const yarnPath = makeExecutable(dir, "yarn");
|
||||
makeExecutable(dir, "eslint");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command,
|
||||
allowlist: [{ pattern: yarnPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires bound args for package-manager shell script carriers", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const { dir, script, env, safeBins } = createShellScriptFixture();
|
||||
makeExecutable(dir, "pnpm");
|
||||
const shPath = makeExecutable(dir, "sh");
|
||||
|
||||
const result = await evaluateShellAllowlistWithAuthorization({
|
||||
command: `pnpm exec sh ${script}`,
|
||||
allowlist: [{ pattern: shPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(result.allowlistSatisfied).toBe(false);
|
||||
expect(result.segmentAllowlistEntries).toEqual([null]);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: result.analysisOk,
|
||||
allowlistSatisfied: result.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches package-manager shell-script arg patterns against inner argv", () => {
|
||||
const { dir, script, env, safeBins } = createShellScriptFixture();
|
||||
makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "bash");
|
||||
const platform = "win32";
|
||||
const analysis = analyzeArgvCommand({
|
||||
argv: ["pnpm", "exec", "bash", script, "allowed"],
|
||||
cwd: dir,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
expect(analysis.ok).toBe(true);
|
||||
|
||||
const entries = resolveAllowAlwaysPatternEntries({
|
||||
segments: analysis.segments,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
expect(entries).toEqual([{ pattern: script, argPattern: "^allowed\x00$" }]);
|
||||
|
||||
const allowed = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: entries,
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
expect(allowed.allowlistSatisfied).toBe(true);
|
||||
|
||||
const extraArgAnalysis = analyzeArgvCommand({
|
||||
argv: ["pnpm", "exec", "bash", script, "allowed", "extra"],
|
||||
cwd: dir,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
const denied = evaluateExecAllowlist({
|
||||
analysis: extraArgAnalysis,
|
||||
allowlist: entries,
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform,
|
||||
});
|
||||
expect(denied.allowlistSatisfied).toBe(false);
|
||||
expect(
|
||||
requiresExecApproval({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
analysisOk: extraArgAnalysis.ok,
|
||||
allowlistSatisfied: denied.allowlistSatisfied,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches package-manager exec allow-always entries by inner executable", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
const tsxPath = makeExecutable(dir, "tsx");
|
||||
const env = makePathEnv(dir);
|
||||
const safeBins = resolveSafeBins(undefined);
|
||||
|
||||
const staleOuter = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm exec -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: pnpmPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(staleOuter.allowlistSatisfied).toBe(false);
|
||||
|
||||
const inner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm exec -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(inner.allowlistSatisfied).toBe(true);
|
||||
|
||||
const pnpmCwdInner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm -C ./package exec -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(pnpmCwdInner.allowlistSatisfied).toBe(true);
|
||||
|
||||
const npmInner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "npm --loglevel=silent exec -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(npmInner.allowlistSatisfied).toBe(true);
|
||||
|
||||
const npmCwdInner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "npm -C ./package exec -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(npmCwdInner.allowlistSatisfied).toBe(true);
|
||||
|
||||
const npmAliasInner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "npm x -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(npmAliasInner.allowlistSatisfied).toBe(true);
|
||||
|
||||
const chainedInner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "pnpm exec -- npm x -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(chainedInner.allowlistSatisfied).toBe(true);
|
||||
|
||||
const yarnInner = await evaluateShellAllowlistWithAuthorization({
|
||||
command: "yarn exec -- tsx ./run.ts",
|
||||
allowlist: [{ pattern: tsxPath, source: "allow-always" }],
|
||||
safeBins,
|
||||
cwd: dir,
|
||||
env,
|
||||
platform: process.platform,
|
||||
});
|
||||
expect(yarnInner.allowlistSatisfied).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents allow-always bypass for sandbox-exec wrapper chains", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { explainShellCommand } from "./command-explainer/extract.js";
|
|||
import type { CommandStep } from "./command-explainer/types.js";
|
||||
import {
|
||||
isDispatchWrapperExecutable,
|
||||
resolveDispatchWrapperTrustPlan,
|
||||
unwrapDispatchWrappersForResolution,
|
||||
} from "./dispatch-wrapper-resolution.js";
|
||||
import {
|
||||
|
|
@ -53,6 +54,7 @@ import {
|
|||
} from "./exec-wrapper-resolution.js";
|
||||
import { resolveExecWrapperTrustPlan } from "./exec-wrapper-trust-plan.js";
|
||||
import { expandHomePrefix } from "./home-dir.js";
|
||||
import { resolveKnownPackageManagerExecInvocation } from "./package-manager-exec-wrapper.js";
|
||||
import {
|
||||
POSIX_INLINE_COMMAND_FLAGS,
|
||||
isDirectShellPositionalCarrierCommand,
|
||||
|
|
@ -292,6 +294,64 @@ type SegmentMatchEvaluation = {
|
|||
match: ExecAllowlistEntry | null;
|
||||
};
|
||||
|
||||
const MAX_PACKAGE_MANAGER_EXEC_UNWRAP_DEPTH = 6;
|
||||
|
||||
type PackageManagerTrustTarget =
|
||||
| { kind: "blocked" }
|
||||
| { kind: "not-package-manager"; argv: string[] }
|
||||
| { kind: "package-manager"; argv: string[] };
|
||||
|
||||
// Package-manager exec keeps the outer argv for process launch, but durable
|
||||
// approval matching must use the inner trust target so stale outer-wrapper
|
||||
// allow-always entries cannot authorize a different wrapped payload.
|
||||
function resolvePackageManagerTrustTargetArgv(
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): PackageManagerTrustTarget {
|
||||
let current = argv;
|
||||
let sawPackageManagerExec = false;
|
||||
for (let depth = 0; depth < MAX_PACKAGE_MANAGER_EXEC_UNWRAP_DEPTH; depth += 1) {
|
||||
const dispatchPlan = resolveDispatchWrapperTrustPlan(current, undefined, platform);
|
||||
if (dispatchPlan.policyBlocked) {
|
||||
return { kind: "blocked" };
|
||||
}
|
||||
current = dispatchPlan.argv;
|
||||
const packageManagerExec = resolveKnownPackageManagerExecInvocation(current);
|
||||
if (packageManagerExec.kind === "unsafe-exec") {
|
||||
return { kind: "blocked" };
|
||||
}
|
||||
if (packageManagerExec.kind !== "unwrapped") {
|
||||
return sawPackageManagerExec
|
||||
? { kind: "package-manager", argv: current }
|
||||
: { kind: "not-package-manager", argv: current };
|
||||
}
|
||||
sawPackageManagerExec = true;
|
||||
current = packageManagerExec.argv;
|
||||
}
|
||||
return { kind: "blocked" };
|
||||
}
|
||||
|
||||
function resolvePackageManagerAllowlistTargetArgv(
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string[] | null | undefined {
|
||||
const packageManagerTarget = resolvePackageManagerTrustTargetArgv(argv, platform);
|
||||
if (packageManagerTarget.kind === "blocked") {
|
||||
return null;
|
||||
}
|
||||
if (packageManagerTarget.kind !== "package-manager") {
|
||||
return undefined;
|
||||
}
|
||||
const trustPlan = resolveExecWrapperTrustPlan(packageManagerTarget.argv, undefined, platform);
|
||||
if (
|
||||
trustPlan.policyBlocked ||
|
||||
(trustPlan.shellWrapperExecutable && trustPlan.shellInlineCommand)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trustPlan.argv;
|
||||
}
|
||||
|
||||
function matchExecutableAllowlistForSegment(params: {
|
||||
allowlist: ExecAllowlistEntry[];
|
||||
candidateResolution: ExecutableResolution | null;
|
||||
|
|
@ -420,21 +480,36 @@ function resolveSegmentAllowlistMatch(params: {
|
|||
params.segment.resolution?.effectiveArgv && params.segment.resolution.effectiveArgv.length > 0
|
||||
? params.segment.resolution.effectiveArgv
|
||||
: params.segment.argv;
|
||||
const allowlistSegment =
|
||||
effectiveArgv === params.segment.argv
|
||||
? params.segment
|
||||
: { ...params.segment, argv: effectiveArgv };
|
||||
const executableResolution = resolvePolicyTargetResolution(params.segment.resolution);
|
||||
const executionResolution = resolveExecutionTargetResolution(params.segment.resolution);
|
||||
const candidatePath = resolvePolicyTargetCandidatePath(
|
||||
params.segment.resolution,
|
||||
params.context.cwd,
|
||||
const packageManagerTargetArgv = resolvePackageManagerAllowlistTargetArgv(
|
||||
effectiveArgv,
|
||||
(params.context.platform ?? undefined) as NodeJS.Platform | undefined,
|
||||
);
|
||||
const trustPath = resolvePolicyTargetTrustPath(params.segment.resolution, params.context.cwd);
|
||||
if (packageManagerTargetArgv === null) {
|
||||
return { effectiveArgv, inlineCommand: null, match: null };
|
||||
}
|
||||
const matchArgv = packageManagerTargetArgv ?? effectiveArgv;
|
||||
const matchResolution =
|
||||
matchArgv === effectiveArgv
|
||||
? params.segment.resolution
|
||||
: resolveCommandResolutionFromArgv(
|
||||
matchArgv,
|
||||
params.context.cwd,
|
||||
params.context.env,
|
||||
(params.context.platform ?? undefined) as NodeJS.Platform | undefined,
|
||||
);
|
||||
const allowlistSegment =
|
||||
matchArgv === params.segment.argv
|
||||
? params.segment
|
||||
: { ...params.segment, argv: matchArgv, resolution: matchResolution };
|
||||
const executableResolution = resolvePolicyTargetResolution(matchResolution);
|
||||
const executionResolution = resolveExecutionTargetResolution(params.segment.resolution);
|
||||
const candidatePath = resolvePolicyTargetCandidatePath(matchResolution, params.context.cwd);
|
||||
const trustPath = resolvePolicyTargetTrustPath(matchResolution, params.context.cwd);
|
||||
const candidateResolution =
|
||||
candidatePath && executableResolution
|
||||
? { ...executableResolution, resolvedPath: candidatePath, resolvedRealPath: trustPath }
|
||||
: executableResolution;
|
||||
const matchExecutionResolution = resolveExecutionTargetResolution(matchResolution);
|
||||
const inlineCommand = extractBindableShellWrapperInlineCommand(allowlistSegment.argv);
|
||||
const powerShellFileScriptArgv = resolvePowerShellFileScriptArgv({
|
||||
segment: allowlistSegment,
|
||||
|
|
@ -446,14 +521,14 @@ function resolveSegmentAllowlistMatch(params: {
|
|||
const executableMatch = matchExecutableAllowlistForSegment({
|
||||
allowlist: params.context.allowlist,
|
||||
candidateResolution,
|
||||
effectiveArgv,
|
||||
effectiveArgv: matchArgv,
|
||||
platform: params.context.platform,
|
||||
inlineCommand,
|
||||
isShellWrapperInvocation,
|
||||
isPositionalCarrierInvocation,
|
||||
allowlistTargetIsExecutionTarget: executableResolutionsReferToSameTarget(
|
||||
executableResolution,
|
||||
executionResolution,
|
||||
matchExecutionResolution ?? executionResolution,
|
||||
),
|
||||
});
|
||||
const shellPositionalArgvCandidatePath =
|
||||
|
|
@ -490,7 +565,7 @@ function resolveSegmentAllowlistMatch(params: {
|
|||
? (powerShellFileScriptArgv ??
|
||||
resolveShellWrapperScriptArgv({
|
||||
shellScriptCandidatePath,
|
||||
effectiveArgv,
|
||||
effectiveArgv: matchArgv,
|
||||
cwd: params.context.cwd,
|
||||
}))
|
||||
: null;
|
||||
|
|
@ -1113,6 +1188,17 @@ function resolveCandidateTrustPath(candidatePath: string | undefined): string |
|
|||
});
|
||||
}
|
||||
|
||||
function resolveAllowAlwaysPatternArgv(
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string[] | null {
|
||||
const packageManagerTarget = resolvePackageManagerTrustTargetArgv(argv, platform);
|
||||
if (packageManagerTarget.kind === "blocked") {
|
||||
return null;
|
||||
}
|
||||
return packageManagerTarget.argv;
|
||||
}
|
||||
|
||||
function collectAllowAlwaysPatterns(params: {
|
||||
segment: ExecCommandSegment;
|
||||
cwd?: string;
|
||||
|
|
@ -1126,8 +1212,15 @@ function collectAllowAlwaysPatterns(params: {
|
|||
return;
|
||||
}
|
||||
|
||||
const trustPlan = resolveExecWrapperTrustPlan(
|
||||
const patternArgv = resolveAllowAlwaysPatternArgv(
|
||||
params.segment.argv,
|
||||
(params.platform ?? undefined) as NodeJS.Platform | undefined,
|
||||
);
|
||||
if (!patternArgv) {
|
||||
return;
|
||||
}
|
||||
const trustPlan = resolveExecWrapperTrustPlan(
|
||||
patternArgv,
|
||||
undefined,
|
||||
(params.platform ?? undefined) as NodeJS.Platform | undefined,
|
||||
);
|
||||
|
|
@ -1195,7 +1288,7 @@ function collectAllowAlwaysPatterns(params: {
|
|||
if (scriptPath) {
|
||||
const scriptTrustPath = resolveCandidateTrustPath(scriptPath) ?? scriptPath;
|
||||
const argPattern = buildScriptArgPatternFromArgv(
|
||||
powerShellFileScriptArgv ?? params.segment.argv,
|
||||
powerShellFileScriptArgv ?? segment.argv,
|
||||
scriptPath,
|
||||
params.cwd,
|
||||
params.platform,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
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 { makeExecutable, makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
|
||||
import {
|
||||
evaluateExecAllowlist,
|
||||
resolvePlannedSegmentArgv,
|
||||
|
|
@ -469,6 +469,25 @@ describe("exec-command-resolution", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("keeps package-manager exec as the planned execution argv", () => {
|
||||
const dir = makeTempDir();
|
||||
const pnpmPath = makeExecutable(dir, "pnpm");
|
||||
makeExecutable(dir, "eslint");
|
||||
const env = makePathEnv(dir);
|
||||
const argv = ["pnpm", "exec", "eslint", "."];
|
||||
const resolution = resolveCommandResolutionFromArgv(argv, dir, env);
|
||||
const segment = {
|
||||
raw: argv.join(" "),
|
||||
argv,
|
||||
resolution,
|
||||
};
|
||||
|
||||
expect(resolution?.policyBlocked).toBe(false);
|
||||
expect(resolution?.execution.resolvedPath).toBe(pnpmPath);
|
||||
expect(resolution?.effectiveArgv).toEqual(argv);
|
||||
expect(resolvePlannedSegmentArgv(segment)).toEqual([pnpmPath, "exec", "eslint", "."]);
|
||||
});
|
||||
|
||||
it("normalizes argv tokens for short clusters, long options, and special sentinels", () => {
|
||||
expect(parseExecArgvToken("")).toEqual({ kind: "empty", raw: "" });
|
||||
expect(parseExecArgvToken("--")).toEqual({ kind: "terminator", raw: "--" });
|
||||
|
|
|
|||
|
|
@ -315,6 +315,23 @@ describe("resolveDispatchWrapperTrustPlan", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["npm", "install"],
|
||||
["pnpm", "install"],
|
||||
["pnpm", "run", "build"],
|
||||
])(
|
||||
"does not classify ordinary package-manager subcommands as dispatch wrappers: %s %s",
|
||||
(executable, ...args) => {
|
||||
const argv = [executable, ...args];
|
||||
expect(unwrapKnownDispatchWrapperInvocation(argv)).toEqual({ kind: "not-wrapper" });
|
||||
expect(resolveDispatchWrapperTrustPlan(argv)).toEqual({
|
||||
argv,
|
||||
wrappers: [],
|
||||
policyBlocked: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
argv: ["caffeinate", "-d", "-t", "60", "bash", "-lc", "echo hi"],
|
||||
|
|
|
|||
|
|
@ -57,6 +57,32 @@ describe("resolveExecWrapperTrustPlan", () => {
|
|||
shellInlineCommand: "echo hi",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps package-manager exec argv as the execution trust target",
|
||||
enabled: true,
|
||||
argv: ["pnpm", "--reporter", "silent", "exec", "--", "tsx", "./run.ts"],
|
||||
expected: {
|
||||
argv: ["pnpm", "--reporter", "silent", "exec", "--", "tsx", "./run.ts"],
|
||||
policyArgv: ["pnpm", "--reporter", "silent", "exec", "--", "tsx", "./run.ts"],
|
||||
wrapperChain: [],
|
||||
policyBlocked: false,
|
||||
shellWrapperExecutable: false,
|
||||
shellInlineCommand: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps package-manager shell-call mode outside generic wrapper policy",
|
||||
enabled: true,
|
||||
argv: ["npx", "--call", "sh -c 'echo hi'"],
|
||||
expected: {
|
||||
argv: ["npx", "--call", "sh -c 'echo hi'"],
|
||||
policyArgv: ["npx", "--call", "sh -c 'echo hi'"],
|
||||
wrapperChain: [],
|
||||
policyBlocked: false,
|
||||
shellWrapperExecutable: false,
|
||||
shellInlineCommand: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "omits startup shell inline payloads from trust plans",
|
||||
enabled: process.platform !== "win32",
|
||||
|
|
|
|||
488
src/infra/package-manager-exec-wrapper.ts
Normal file
488
src/infra/package-manager-exec-wrapper.ts
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
// Parses package-manager exec wrappers that delegate to a concrete command.
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeExecutableToken } from "./exec-wrapper-tokens.js";
|
||||
import { parseInlineOptionToken } from "./inline-option-token.js";
|
||||
|
||||
export const NPM_EXEC_OPTIONS_WITH_VALUE = new Set([
|
||||
"--cache",
|
||||
"--loglevel",
|
||||
"--package",
|
||||
"--prefix",
|
||||
"--script-shell",
|
||||
"--userconfig",
|
||||
"--workspace",
|
||||
"-p",
|
||||
"-w",
|
||||
]);
|
||||
|
||||
const NPM_EXEC_FLAG_OPTIONS = new Set([
|
||||
"--no",
|
||||
"--quiet",
|
||||
"--ws",
|
||||
"--workspaces",
|
||||
"--yes",
|
||||
"-q",
|
||||
"-y",
|
||||
]);
|
||||
|
||||
const NPM_EXEC_SUBCOMMANDS = new Set(["exec", "x"]);
|
||||
|
||||
export const PNPM_OPTIONS_WITH_VALUE = new Set([
|
||||
"--config",
|
||||
"--dir",
|
||||
"--filter",
|
||||
"--reporter",
|
||||
"--stream",
|
||||
"--test-pattern",
|
||||
"--workspace-concurrency",
|
||||
]);
|
||||
|
||||
export const PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE = new Set(["-C"]);
|
||||
|
||||
export const PNPM_FLAG_OPTIONS = new Set([
|
||||
"--aggregate-output",
|
||||
"--color",
|
||||
"--parallel",
|
||||
"--recursive",
|
||||
"--silent",
|
||||
"--workspace-root",
|
||||
"-r",
|
||||
"-s",
|
||||
"-w",
|
||||
]);
|
||||
|
||||
export const PNPM_DLX_OPTIONS_WITH_VALUE = new Set(["--allow-build", "--package", "-p"]);
|
||||
|
||||
const PNPM_EXEC_SUBCOMMANDS = new Set(["exec", "dlx", "node"]);
|
||||
const PNPM_SCRIPT_RUN_SUBCOMMANDS = new Set(["restart", "run", "start", "stop", "test"]);
|
||||
const PNPM_BUILTIN_NON_EXEC_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"audit",
|
||||
"bin",
|
||||
"config",
|
||||
"dedupe",
|
||||
"deploy",
|
||||
"help",
|
||||
"import",
|
||||
"init",
|
||||
"install",
|
||||
"licenses",
|
||||
"link",
|
||||
"list",
|
||||
"outdated",
|
||||
"patch",
|
||||
"prune",
|
||||
"publish",
|
||||
"rebuild",
|
||||
"remove",
|
||||
"root",
|
||||
"server",
|
||||
"store",
|
||||
"unlink",
|
||||
"update",
|
||||
"view",
|
||||
"why",
|
||||
]);
|
||||
|
||||
const YARN_OPTIONS_WITH_VALUE = new Set(["--cwd"]);
|
||||
const YARN_FLAG_OPTIONS = new Set(["--immutable", "--silent", "-s"]);
|
||||
const YARN_DLX_OPTIONS_WITH_VALUE = new Set(["--package", "-p"]);
|
||||
const YARN_DLX_FLAG_OPTIONS = new Set(["--quiet", "-q"]);
|
||||
const YARN_EXEC_SUBCOMMANDS = new Set(["exec", "dlx"]);
|
||||
const YARN_BUILTIN_NON_EXEC_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"audit",
|
||||
"autoclean",
|
||||
"bin",
|
||||
"cache",
|
||||
"check",
|
||||
"config",
|
||||
"create",
|
||||
"dedupe",
|
||||
"generate-lock-entry",
|
||||
"global",
|
||||
"help",
|
||||
"import",
|
||||
"info",
|
||||
"init",
|
||||
"install",
|
||||
"licenses",
|
||||
"link",
|
||||
"list",
|
||||
"login",
|
||||
"logout",
|
||||
"outdated",
|
||||
"owner",
|
||||
"pack",
|
||||
"policies",
|
||||
"prune",
|
||||
"publish",
|
||||
"remove",
|
||||
"self-update",
|
||||
"tag",
|
||||
"team",
|
||||
"unlink",
|
||||
"upgrade",
|
||||
"upgrade-interactive",
|
||||
"version",
|
||||
"versions",
|
||||
"why",
|
||||
"workspace",
|
||||
]);
|
||||
|
||||
function normalizeOptionFlag(token: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(parseInlineOptionToken(token).name);
|
||||
}
|
||||
|
||||
function containsSubcommandToken(argv: string[], subcommands: ReadonlySet<string>): boolean {
|
||||
return argv.some((token) => subcommands.has(normalizeLowercaseStringOrEmpty(token)));
|
||||
}
|
||||
|
||||
export function normalizePackageManagerExecToken(token: string): string {
|
||||
return normalizeExecutableToken(token).replace(/\.(?:c|m)?js$/i, "");
|
||||
}
|
||||
|
||||
export type PackageManagerExecInvocation =
|
||||
| { kind: "not-package-manager" }
|
||||
| { kind: "not-exec" }
|
||||
| { kind: "unsafe-exec" }
|
||||
| { kind: "unwrapped"; argv: string[] };
|
||||
|
||||
function firstSubcommandAfterOptions(
|
||||
argv: string[],
|
||||
params: {
|
||||
optionsWithValue: ReadonlySet<string>;
|
||||
caseSensitiveOptionsWithValue?: ReadonlySet<string>;
|
||||
flagOptions: ReadonlySet<string>;
|
||||
},
|
||||
): string | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
return normalizeLowercaseStringOrEmpty(token);
|
||||
}
|
||||
const parsedOption = parseInlineOptionToken(token);
|
||||
if (params.caseSensitiveOptionsWithValue?.has(parsedOption.name)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
const flag = normalizeLowercaseStringOrEmpty(parsedOption.name);
|
||||
if (params.optionsWithValue.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (params.flagOptions.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapPnpmExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
if (token === "exec") {
|
||||
if (idx + 1 >= argv.length) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx + 1);
|
||||
const normalizedTail = tail[0] === "--" ? tail.slice(1) : tail;
|
||||
const firstExecArg = normalizeOptionFlag(normalizedTail[0] ?? "");
|
||||
if (firstExecArg === "-c" || firstExecArg === "--shell-mode") {
|
||||
return null;
|
||||
}
|
||||
return normalizedTail.length > 0 ? normalizedTail : null;
|
||||
}
|
||||
if (token === "dlx") {
|
||||
return unwrapPnpmDlxInvocation(argv.slice(idx + 1));
|
||||
}
|
||||
if (token === "node") {
|
||||
const tail = argv.slice(idx + 1);
|
||||
const normalizedTail = tail[0] === "--" ? tail.slice(1) : tail;
|
||||
return ["node", ...normalizedTail];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const parsedOption = parseInlineOptionToken(token);
|
||||
const flag = normalizeLowercaseStringOrEmpty(parsedOption.name);
|
||||
if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE.has(parsedOption.name)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapPnpmDlxInvocation(argv: string[]): string[] | null {
|
||||
let idx = 0;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
const tail = argv.slice(idx + 1);
|
||||
return tail.length > 0 ? tail : null;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
return argv.slice(idx);
|
||||
}
|
||||
const parsedOption = parseInlineOptionToken(token);
|
||||
const flag = normalizeLowercaseStringOrEmpty(parsedOption.name);
|
||||
if (flag === "-c" || flag === "--shell-mode") {
|
||||
return null;
|
||||
}
|
||||
if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE.has(parsedOption.name)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapDirectPackageExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
return argv.slice(idx);
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
if (flag === "-c" || flag === "--call") {
|
||||
return null;
|
||||
}
|
||||
if (NPM_EXEC_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (NPM_EXEC_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapNpmExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
if (!NPM_EXEC_SUBCOMMANDS.has(token)) {
|
||||
return null;
|
||||
}
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
const parsedOption = parseInlineOptionToken(token);
|
||||
const flag = normalizeLowercaseStringOrEmpty(parsedOption.name);
|
||||
if (NPM_EXEC_OPTIONS_WITH_VALUE.has(flag) || parsedOption.name === "-C") {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (NPM_EXEC_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (idx >= argv.length) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx);
|
||||
if (tail[0] === "--") {
|
||||
return tail.length > 1 ? tail.slice(1) : null;
|
||||
}
|
||||
return unwrapDirectPackageExecInvocation(["npx", ...tail]);
|
||||
}
|
||||
|
||||
function unwrapYarnDlxInvocation(argv: string[]): string[] | null {
|
||||
let idx = 0;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
const tail = argv.slice(idx + 1);
|
||||
return tail.length > 0 ? tail : null;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
return argv.slice(idx);
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
if (YARN_DLX_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (YARN_DLX_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapYarnExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = argv[idx]?.trim() ?? "";
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
if (token === "exec") {
|
||||
const tail = argv.slice(idx + 1);
|
||||
const normalizedTail = tail[0] === "--" ? tail.slice(1) : tail;
|
||||
return normalizedTail.length > 0 ? normalizedTail : null;
|
||||
}
|
||||
if (token === "dlx") {
|
||||
return unwrapYarnDlxInvocation(argv.slice(idx + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
if (YARN_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (YARN_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function unwrapKnownPackageManagerExecInvocation(argv: string[]): string[] | null {
|
||||
const resolution = resolveKnownPackageManagerExecInvocation(argv);
|
||||
return resolution.kind === "unwrapped" ? resolution.argv : null;
|
||||
}
|
||||
|
||||
export function resolveKnownPackageManagerExecInvocation(
|
||||
argv: string[],
|
||||
): PackageManagerExecInvocation {
|
||||
const executable = normalizePackageManagerExecToken(argv[0] ?? "");
|
||||
switch (executable) {
|
||||
case "npm": {
|
||||
const unwrapped = unwrapNpmExecInvocation(argv);
|
||||
if (unwrapped) {
|
||||
return { kind: "unwrapped", argv: unwrapped };
|
||||
}
|
||||
const firstSubcommand = firstSubcommandAfterOptions(argv, {
|
||||
optionsWithValue: NPM_EXEC_OPTIONS_WITH_VALUE,
|
||||
caseSensitiveOptionsWithValue: new Set(["-C"]),
|
||||
flagOptions: NPM_EXEC_FLAG_OPTIONS,
|
||||
});
|
||||
return NPM_EXEC_SUBCOMMANDS.has(firstSubcommand ?? "")
|
||||
? { kind: "unsafe-exec" }
|
||||
: firstSubcommand === null && containsSubcommandToken(argv.slice(1), NPM_EXEC_SUBCOMMANDS)
|
||||
? { kind: "unsafe-exec" }
|
||||
: { kind: "not-exec" };
|
||||
}
|
||||
case "npx":
|
||||
case "bunx": {
|
||||
const unwrapped = unwrapDirectPackageExecInvocation(argv);
|
||||
return unwrapped ? { kind: "unwrapped", argv: unwrapped } : { kind: "unsafe-exec" };
|
||||
}
|
||||
case "pnpm": {
|
||||
const unwrapped = unwrapPnpmExecInvocation(argv);
|
||||
if (unwrapped) {
|
||||
return { kind: "unwrapped", argv: unwrapped };
|
||||
}
|
||||
const firstSubcommand = firstSubcommandAfterOptions(argv, {
|
||||
optionsWithValue: new Set([...PNPM_OPTIONS_WITH_VALUE, ...PNPM_DLX_OPTIONS_WITH_VALUE]),
|
||||
caseSensitiveOptionsWithValue: PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE,
|
||||
flagOptions: PNPM_FLAG_OPTIONS,
|
||||
});
|
||||
const detectedKnownExec = PNPM_EXEC_SUBCOMMANDS.has(firstSubcommand ?? "");
|
||||
const hiddenKnownExec =
|
||||
firstSubcommand === null && containsSubcommandToken(argv.slice(1), PNPM_EXEC_SUBCOMMANDS);
|
||||
const implicitExecShorthand =
|
||||
firstSubcommand !== null &&
|
||||
!PNPM_SCRIPT_RUN_SUBCOMMANDS.has(firstSubcommand) &&
|
||||
!PNPM_BUILTIN_NON_EXEC_SUBCOMMANDS.has(firstSubcommand);
|
||||
return detectedKnownExec || hiddenKnownExec || implicitExecShorthand
|
||||
? { kind: "unsafe-exec" }
|
||||
: { kind: "not-exec" };
|
||||
}
|
||||
case "yarn": {
|
||||
const unwrapped = unwrapYarnExecInvocation(argv);
|
||||
if (unwrapped) {
|
||||
return { kind: "unwrapped", argv: unwrapped };
|
||||
}
|
||||
const firstSubcommand = firstSubcommandAfterOptions(argv, {
|
||||
optionsWithValue: new Set([...YARN_OPTIONS_WITH_VALUE, ...YARN_DLX_OPTIONS_WITH_VALUE]),
|
||||
flagOptions: new Set([...YARN_FLAG_OPTIONS, ...YARN_DLX_FLAG_OPTIONS]),
|
||||
});
|
||||
const detectedKnownExec = YARN_EXEC_SUBCOMMANDS.has(firstSubcommand ?? "");
|
||||
const hiddenKnownExec =
|
||||
firstSubcommand === null && containsSubcommandToken(argv.slice(1), YARN_EXEC_SUBCOMMANDS);
|
||||
const implicitRunOrBin =
|
||||
firstSubcommand !== null &&
|
||||
(firstSubcommand === "run" || !YARN_BUILTIN_NON_EXEC_SUBCOMMANDS.has(firstSubcommand));
|
||||
return detectedKnownExec || hiddenKnownExec || implicitRunOrBin
|
||||
? { kind: "unsafe-exec" }
|
||||
: { kind: "not-exec" };
|
||||
}
|
||||
default:
|
||||
return { kind: "not-package-manager" };
|
||||
}
|
||||
}
|
||||
|
|
@ -590,6 +590,13 @@ describe("hardenApprovedExecutionPaths", () => {
|
|||
initialBody: 'console.log("SAFE");\n',
|
||||
expectedArgvIndex: 5,
|
||||
},
|
||||
{
|
||||
name: "pnpm cwd exec tsx file",
|
||||
argv: ["pnpm", "-C", "./package", "exec", "tsx", "./run.ts"],
|
||||
scriptName: "run.ts",
|
||||
initialBody: 'console.log("SAFE");\n',
|
||||
expectedArgvIndex: 5,
|
||||
},
|
||||
{
|
||||
name: "pnpm js shim exec tsx file",
|
||||
argv: ["./pnpm.js", "exec", "tsx", "./run.ts"],
|
||||
|
|
@ -627,6 +634,27 @@ describe("hardenApprovedExecutionPaths", () => {
|
|||
initialBody: 'console.log("SAFE");\n',
|
||||
expectedArgvIndex: 4,
|
||||
},
|
||||
{
|
||||
name: "npm x tsx file",
|
||||
argv: ["npm", "x", "--", "tsx", "./run.ts"],
|
||||
scriptName: "run.ts",
|
||||
initialBody: 'console.log("SAFE");\n',
|
||||
expectedArgvIndex: 4,
|
||||
},
|
||||
{
|
||||
name: "npm loglevel exec tsx file",
|
||||
argv: ["npm", "--loglevel=silent", "exec", "--", "tsx", "./run.ts"],
|
||||
scriptName: "run.ts",
|
||||
initialBody: 'console.log("SAFE");\n',
|
||||
expectedArgvIndex: 5,
|
||||
},
|
||||
{
|
||||
name: "npm cwd exec tsx file",
|
||||
argv: ["npm", "-C", "./package", "exec", "--", "tsx", "./run.ts"],
|
||||
scriptName: "run.ts",
|
||||
initialBody: 'console.log("SAFE");\n',
|
||||
expectedArgvIndex: 6,
|
||||
},
|
||||
];
|
||||
|
||||
it("captures mutable runtime operands in approval plans", () => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ import {
|
|||
} from "../infra/exec-wrapper-resolution.js";
|
||||
import { sameFileIdentity } from "../infra/fs-safe-advanced.js";
|
||||
import { parseInlineOptionToken } from "../infra/inline-option-token.js";
|
||||
import {
|
||||
normalizePackageManagerExecToken,
|
||||
PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE,
|
||||
PNPM_DLX_OPTIONS_WITH_VALUE,
|
||||
PNPM_FLAG_OPTIONS,
|
||||
PNPM_OPTIONS_WITH_VALUE,
|
||||
unwrapKnownPackageManagerExecInvocation,
|
||||
} from "../infra/package-manager-exec-wrapper.js";
|
||||
import {
|
||||
advancePosixInlineOptionScan,
|
||||
POSIX_INLINE_COMMAND_FLAGS,
|
||||
|
|
@ -172,52 +180,6 @@ function isPosixShellOptionToken(token: string, supportsPlusOptions: boolean): b
|
|||
return token.startsWith("-") || (supportsPlusOptions && token.startsWith("+"));
|
||||
}
|
||||
|
||||
const NPM_EXEC_OPTIONS_WITH_VALUE = new Set([
|
||||
"--cache",
|
||||
"--package",
|
||||
"--prefix",
|
||||
"--script-shell",
|
||||
"--userconfig",
|
||||
"--workspace",
|
||||
"-p",
|
||||
"-w",
|
||||
]);
|
||||
|
||||
const NPM_EXEC_FLAG_OPTIONS = new Set([
|
||||
"--no",
|
||||
"--quiet",
|
||||
"--ws",
|
||||
"--workspaces",
|
||||
"--yes",
|
||||
"-q",
|
||||
"-y",
|
||||
]);
|
||||
|
||||
const PNPM_OPTIONS_WITH_VALUE = new Set([
|
||||
"--config",
|
||||
"--dir",
|
||||
"--filter",
|
||||
"--reporter",
|
||||
"--stream",
|
||||
"--test-pattern",
|
||||
"--workspace-concurrency",
|
||||
"-C",
|
||||
]);
|
||||
|
||||
const PNPM_FLAG_OPTIONS = new Set([
|
||||
"--aggregate-output",
|
||||
"--color",
|
||||
"--parallel",
|
||||
"--recursive",
|
||||
"--silent",
|
||||
"--workspace-root",
|
||||
"-r",
|
||||
"-s",
|
||||
"-w",
|
||||
]);
|
||||
|
||||
const PNPM_DLX_OPTIONS_WITH_VALUE = new Set(["--allow-build", "--package", "-p"]);
|
||||
|
||||
type FileOperandCollection = {
|
||||
hits: number[];
|
||||
sawOptionValueFile: boolean;
|
||||
|
|
@ -428,171 +390,6 @@ function unwrapArgvForMutableOperand(argv: string[]): {
|
|||
}
|
||||
}
|
||||
|
||||
function unwrapKnownPackageManagerExecInvocation(argv: string[]): string[] | null {
|
||||
const executable = normalizePackageManagerExecToken(argv[0] ?? "");
|
||||
switch (executable) {
|
||||
case "npm":
|
||||
return unwrapNpmExecInvocation(argv);
|
||||
case "npx":
|
||||
case "bunx":
|
||||
return unwrapDirectPackageExecInvocation(argv);
|
||||
case "pnpm":
|
||||
return unwrapPnpmExecInvocation(argv);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePackageManagerExecToken(token: string): string {
|
||||
const normalized = normalizeExecutableToken(token);
|
||||
if (!normalized) {
|
||||
return normalized;
|
||||
}
|
||||
// Approval binding only promises best-effort recovery of the effective runtime
|
||||
// command for common package-manager shims; it is not full package-manager semantics.
|
||||
return normalized.replace(/\.(?:c|m)?js$/i, "");
|
||||
}
|
||||
|
||||
function unwrapPnpmExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = readTrimmedArgToken(argv, idx);
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
if (token === "exec") {
|
||||
if (idx + 1 >= argv.length) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx + 1);
|
||||
return tail[0] === "--" ? (tail.length > 1 ? tail.slice(1) : null) : tail;
|
||||
}
|
||||
if (token === "dlx") {
|
||||
return unwrapPnpmDlxInvocation(argv.slice(idx + 1));
|
||||
}
|
||||
if (token === "node") {
|
||||
const tail = argv.slice(idx + 1);
|
||||
const normalizedTail = tail[0] === "--" ? tail.slice(1) : tail;
|
||||
return ["node", ...normalizedTail];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapPnpmDlxInvocation(argv: string[]): string[] | null {
|
||||
let idx = 0;
|
||||
while (idx < argv.length) {
|
||||
const token = readTrimmedArgToken(argv, idx);
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--") {
|
||||
const tail = argv.slice(idx + 1);
|
||||
return tail.length > 0 ? tail : null;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
// Once dlx-specific flags are stripped, the first positional token is the
|
||||
// package binary pnpm will execute inside the temporary environment.
|
||||
return argv.slice(idx);
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
if (flag === "-c" || flag === "--shell-mode") {
|
||||
return null;
|
||||
}
|
||||
if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapDirectPackageExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = readTrimmedArgToken(argv, idx);
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
return argv.slice(idx);
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
if (flag === "-c" || flag === "--call") {
|
||||
return null;
|
||||
}
|
||||
if (NPM_EXEC_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (NPM_EXEC_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapNpmExecInvocation(argv: string[]): string[] | null {
|
||||
let idx = 1;
|
||||
while (idx < argv.length) {
|
||||
const token = readTrimmedArgToken(argv, idx);
|
||||
if (!token) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
if (!token.startsWith("-")) {
|
||||
if (token !== "exec") {
|
||||
return null;
|
||||
}
|
||||
idx += 1;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(token === "-C" || token === "--prefix" || token === "--userconfig") &&
|
||||
!token.includes("=")
|
||||
) {
|
||||
idx += 2;
|
||||
continue;
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
if (idx >= argv.length) {
|
||||
return null;
|
||||
}
|
||||
const tail = argv.slice(idx);
|
||||
if (tail[0] === "--") {
|
||||
return tail.length > 1 ? tail.slice(1) : null;
|
||||
}
|
||||
return unwrapDirectPackageExecInvocation(["npx", ...tail]);
|
||||
}
|
||||
|
||||
function resolvePosixShellScriptOperandIndex(argv: string[], executable: string): number | null {
|
||||
const supportsPlusOptions = POSIX_SHELLS_WITH_PLUS_OPTIONS.has(executable);
|
||||
if (
|
||||
|
|
@ -1007,11 +804,16 @@ function pnpmDlxInvocationNeedsFailClosedBinding(argv: string[], cwd: string | u
|
|||
}
|
||||
return pnpmDlxTailNeedsFailClosedBinding(argv.slice(idx + 1), cwd);
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
const parsedOption = parseInlineOptionToken(token);
|
||||
const flag = normalizeLowercaseStringOrEmpty(parsedOption.name);
|
||||
if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE.has(parsedOption.name)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
|
|
@ -1036,7 +838,8 @@ function pnpmDlxTailNeedsFailClosedBinding(argv: string[], cwd: string | undefin
|
|||
if (!token.startsWith("-")) {
|
||||
return pnpmDlxTailMayNeedStableBinding(argv.slice(idx), cwd);
|
||||
}
|
||||
const flag = normalizeOptionFlag(token);
|
||||
const parsedOption = parseInlineOptionToken(token);
|
||||
const flag = normalizeLowercaseStringOrEmpty(parsedOption.name);
|
||||
if (flag === "-c" || flag === "--shell-mode") {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1044,6 +847,10 @@ function pnpmDlxTailNeedsFailClosedBinding(argv: string[], cwd: string | undefin
|
|||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_CASE_SENSITIVE_OPTIONS_WITH_VALUE.has(parsedOption.name)) {
|
||||
idx += token.includes("=") ? 1 : 2;
|
||||
continue;
|
||||
}
|
||||
if (PNPM_FLAG_OPTIONS.has(flag)) {
|
||||
idx += 1;
|
||||
continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue