diff --git a/src/infra/dispatch-wrapper-resolution.ts b/src/infra/dispatch-wrapper-resolution.ts index 1a7981d2dea3..bce17b828194 100644 --- a/src/infra/dispatch-wrapper-resolution.ts +++ b/src/infra/dispatch-wrapper-resolution.ts @@ -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" }; diff --git a/src/infra/exec-allow-always-persistence.test.ts b/src/infra/exec-allow-always-persistence.test.ts index 550d16356661..527793fa3e1b 100644 --- a/src/infra/exec-allow-always-persistence.test.ts +++ b/src/infra/exec-allow-always-persistence.test.ts @@ -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'"; diff --git a/src/infra/exec-approvals-allow-always.test.ts b/src/infra/exec-approvals-allow-always.test.ts index af1db45b8f9c..6fdfca444cc7 100644 --- a/src/infra/exec-approvals-allow-always.test.ts +++ b/src/infra/exec-approvals-allow-always.test.ts @@ -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; diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 726bab7ed13f..895f9a0c2ff0 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -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, diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 79a2f68e9c61..10be0ad15584 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -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: "--" }); diff --git a/src/infra/exec-wrapper-resolution.test.ts b/src/infra/exec-wrapper-resolution.test.ts index ebfa57d7cc56..57c27cf96773 100644 --- a/src/infra/exec-wrapper-resolution.test.ts +++ b/src/infra/exec-wrapper-resolution.test.ts @@ -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"], diff --git a/src/infra/exec-wrapper-trust-plan.test.ts b/src/infra/exec-wrapper-trust-plan.test.ts index 578dda64f1d3..782c862304ab 100644 --- a/src/infra/exec-wrapper-trust-plan.test.ts +++ b/src/infra/exec-wrapper-trust-plan.test.ts @@ -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", diff --git a/src/infra/package-manager-exec-wrapper.ts b/src/infra/package-manager-exec-wrapper.ts new file mode 100644 index 000000000000..1d24f88c135e --- /dev/null +++ b/src/infra/package-manager-exec-wrapper.ts @@ -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): 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; + caseSensitiveOptionsWithValue?: ReadonlySet; + flagOptions: ReadonlySet; + }, +): 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" }; + } +} diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index cf93b4405da2..c4ae27f22d04 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -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", () => { diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 7dc3b1e6120b..9d3b0c87d4bf 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -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;