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:
Pavan Kumar Gondhi 2026-07-08 21:29:02 +05:30 committed by GitHub
parent c69724ef3b
commit 534ace4d8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1651 additions and 232 deletions

View file

@ -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" };

View file

@ -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'";

View file

@ -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;

View file

@ -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,

View file

@ -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: "--" });

View file

@ -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"],

View file

@ -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",

View 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" };
}
}

View file

@ -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", () => {

View file

@ -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;