Skip to content

Commit 07357d6

Browse files
committed
test: cover versioned inline eval approval
1 parent be94853 commit 07357d6

4 files changed

Lines changed: 149 additions & 16 deletions

File tree

src/agents/bash-tools.exec-host-gateway.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,8 @@ describe("processGatewayAllowlist", () => {
369369
security: "full" | "allowlist";
370370
askFallback: "full" | "allowlist";
371371
approvedByAsk: boolean;
372+
command?: string;
373+
inlineEvalHit?: typeof INLINE_EVAL_HIT;
372374
}) {
373375
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
374376
resolveExecHostApprovalContextMock.mockReturnValue({
@@ -377,7 +379,7 @@ describe("processGatewayAllowlist", () => {
377379
hostAsk: "always",
378380
askFallback: params.askFallback,
379381
});
380-
detectInterpreterInlineEvalArgvMock.mockReturnValue(INLINE_EVAL_HIT);
382+
detectInterpreterInlineEvalArgvMock.mockReturnValue(params.inlineEvalHit ?? INLINE_EVAL_HIT);
381383
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue(null);
382384
createExecApprovalDecisionStateMock.mockReturnValue({
383385
baseDecision: { timedOut: true },
@@ -390,7 +392,7 @@ describe("processGatewayAllowlist", () => {
390392
});
391393

392394
return runGatewayAllowlist({
393-
command: "python3 -c 'print(1)'",
395+
command: params.command ?? "python3 -c 'print(1)'",
394396
security: params.security,
395397
ask: "always",
396398
strictInlineEval: true,
@@ -1674,6 +1676,74 @@ EOF`,
16741676
expect(runExecProcessMock).not.toHaveBeenCalled();
16751677
});
16761678

1679+
it.each([
1680+
{
1681+
command: "python3.13 -c 'print(1)'",
1682+
executable: "python3.13",
1683+
normalizedExecutable: "python3.13",
1684+
flag: "-c",
1685+
argv: ["python3.13", "-c", "print(1)"],
1686+
},
1687+
{
1688+
command: "/usr/bin/pypy3.10 -c 'print(1)'",
1689+
executable: "/usr/bin/pypy3.10",
1690+
normalizedExecutable: "pypy3.10",
1691+
flag: "-c",
1692+
argv: ["/usr/bin/pypy3.10", "-c", "print(1)"],
1693+
},
1694+
{
1695+
command: "php -B 'system(\"id\")'",
1696+
executable: "php",
1697+
normalizedExecutable: "php",
1698+
flag: "-B",
1699+
argv: ["php", "-B", 'system("id")'],
1700+
},
1701+
{
1702+
command: "php -E 'system(\"id\")'",
1703+
executable: "php",
1704+
normalizedExecutable: "php",
1705+
flag: "-E",
1706+
argv: ["php", "-E", 'system("id")'],
1707+
},
1708+
{
1709+
command: "Rscript -e 'system(\"id\")'",
1710+
executable: "Rscript",
1711+
normalizedExecutable: "rscript",
1712+
flag: "-e",
1713+
argv: ["Rscript", "-e", 'system("id")'],
1714+
},
1715+
])("denies timed-out inline-eval requests before execution for $command", async (carrier) => {
1716+
const result = await runTimedOutStrictInlineEval({
1717+
security: "allowlist",
1718+
askFallback: "allowlist",
1719+
approvedByAsk: false,
1720+
command: carrier.command,
1721+
inlineEvalHit: {
1722+
executable: carrier.executable,
1723+
normalizedExecutable: carrier.normalizedExecutable,
1724+
flag: carrier.flag,
1725+
argv: carrier.argv,
1726+
},
1727+
});
1728+
1729+
expect(result.pendingResult?.details.status).toBe("approval-pending");
1730+
expect(enforceStrictInlineEvalApprovalBoundaryMock).toHaveBeenCalledWith(
1731+
expect.objectContaining({ requiresInlineEvalApproval: true }),
1732+
);
1733+
await vi.waitFor(() => {
1734+
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledWith(
1735+
expect.objectContaining({
1736+
approvalId: "req-1",
1737+
sessionKey: "agent:main:main",
1738+
turnSourceChannel: undefined,
1739+
direct: false,
1740+
}),
1741+
`Exec denied (gateway id=req-1, approval-timeout): ${carrier.command}`,
1742+
);
1743+
});
1744+
expect(runExecProcessMock).not.toHaveBeenCalled();
1745+
});
1746+
16771747
it("denies allowlist timeout fallback for strict inline-eval commands", async () => {
16781748
const result = await runTimedOutStrictInlineEval({
16791749
security: "allowlist",

src/infra/command-analysis/inline-eval.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe("exec inline eval detection", () => {
2525
{ argv: ["php", "-B", "system('id');"], expected: "php -B" },
2626
{ argv: ["php", "-E", "system('id');"], expected: "php -E" },
2727
{ argv: ["php", "-R", "system('id');"], expected: "php -R" },
28+
{ argv: ["php8.3", "-B", "system('id');"], expected: "php8.3 -B" },
2829
{ argv: ["Rscript", "-e", "system('id')"], expected: "rscript -e" },
2930
{ argv: ["osascript", "-e", "beep"], expected: "osascript -e" },
3031
{ argv: ["awk", "BEGIN { print 1 }"], expected: "awk inline program" },
@@ -89,6 +90,7 @@ describe("exec inline eval detection", () => {
8990
expect(isInterpreterLikeAllowlistPattern("/usr/bin/python3.13")).toBe(true);
9091
expect(isInterpreterLikeAllowlistPattern("python3.*")).toBe(true);
9192
expect(isInterpreterLikeAllowlistPattern("pypy3.10")).toBe(true);
93+
expect(isInterpreterLikeAllowlistPattern("php8.3")).toBe(true);
9294
expect(isInterpreterLikeAllowlistPattern("**/node")).toBe(true);
9395
expect(isInterpreterLikeAllowlistPattern("Rscript")).toBe(true);
9496
expect(isInterpreterLikeAllowlistPattern("r2")).toBe(false);

src/infra/command-analysis/inline-eval.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,20 @@ type PrefixFlagSpec = {
1515
prefix: string;
1616
};
1717

18-
type InterpreterFlagSpec = {
18+
type InterpreterNameSpec = {
1919
names: readonly string[];
20+
versionedNames?: readonly string[];
21+
};
22+
23+
type InterpreterFlagSpec = InterpreterNameSpec & {
2024
exactFlags: ReadonlySet<string>;
2125
rawExactFlags?: ReadonlyMap<string, string>;
2226
rawPrefixFlags?: readonly PrefixFlagSpec[];
2327
prefixFlags?: readonly PrefixFlagSpec[];
2428
scanPastDoubleDash?: boolean;
2529
};
2630

27-
type PositionalInterpreterSpec = {
28-
names: readonly string[];
31+
type PositionalInterpreterSpec = InterpreterNameSpec & {
2932
fileFlags?: ReadonlySet<string>;
3033
fileFlagPrefixes?: readonly string[];
3134
exactValueFlags?: ReadonlySet<string>;
@@ -37,7 +40,11 @@ type PositionalInterpreterSpec = {
3740
const VERSION_SUFFIX_PATTERN = /-?\d+(?:\.\d+)*$/;
3841

3942
const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
40-
{ names: ["python", "python2", "python3", "pypy", "pypy3"], exactFlags: new Set(["-c"]) },
43+
{
44+
names: ["python", "python2", "python3", "pypy", "pypy3"],
45+
versionedNames: ["python", "pypy"],
46+
exactFlags: new Set(["-c"]),
47+
},
4148
{
4249
names: ["node", "nodejs", "bun", "deno"],
4350
exactFlags: new Set(["-e", "--eval", "-p", "--print"]),
@@ -51,14 +58,15 @@ const FLAG_INTERPRETER_INLINE_EVAL_SPECS: readonly InterpreterFlagSpec[] = [
5158
{ names: ["perl"], exactFlags: new Set(["-e", "-E"]) },
5259
{
5360
names: ["php"],
61+
versionedNames: ["php"],
5462
exactFlags: new Set(["-r"]),
5563
rawExactFlags: new Map([
5664
["-B", "-B"],
5765
["-E", "-E"],
5866
["-R", "-R"],
5967
]),
6068
},
61-
{ names: ["r", "rscript"], exactFlags: new Set(["-e"]) },
69+
{ names: ["r", "rscript"], versionedNames: ["rscript"], exactFlags: new Set(["-e"]) },
6270
{ names: ["lua"], exactFlags: new Set(["-e"]) },
6371
{ names: ["osascript"], exactFlags: new Set(["-e"]) },
6472
{
@@ -160,9 +168,9 @@ const POSITIONAL_INTERPRETER_INLINE_EVAL_SPECS: readonly PositionalInterpreterSp
160168
];
161169

162170
const INTERPRETER_ALLOWLIST_NAMES = new Set(
163-
FLAG_INTERPRETER_INLINE_EVAL_SPECS.flatMap((entry) => entry.names).concat(
164-
POSITIONAL_INTERPRETER_INLINE_EVAL_SPECS.flatMap((entry) => entry.names),
165-
),
171+
FLAG_INTERPRETER_INLINE_EVAL_SPECS.flatMap((entry) =>
172+
entry.names.concat(entry.versionedNames ?? []),
173+
).concat(POSITIONAL_INTERPRETER_INLINE_EVAL_SPECS.flatMap((entry) => entry.names)),
166174
);
167175

168176
function stripInterpreterVersionSuffix(value: string): string {
@@ -177,16 +185,21 @@ function interpreterNameVariants(value: string): readonly string[] {
177185
return stripped === value || stripped.length < 2 ? [value] : [value, stripped];
178186
}
179187

180-
function specNamesInclude(names: readonly string[], normalizedExecutable: string): boolean {
181-
return interpreterNameVariants(normalizedExecutable).some((candidate) =>
182-
names.includes(candidate),
183-
);
188+
function specNamesInclude(spec: InterpreterNameSpec, normalizedExecutable: string): boolean {
189+
if (spec.names.includes(normalizedExecutable)) {
190+
return true;
191+
}
192+
const stripped = stripInterpreterVersionSuffix(normalizedExecutable);
193+
if (stripped === normalizedExecutable || stripped.length < 2) {
194+
return false;
195+
}
196+
return (spec.versionedNames ?? spec.names).includes(stripped);
184197
}
185198

186199
function findInterpreterSpec(executable: string): InterpreterFlagSpec | null {
187200
const normalized = normalizeExecutableToken(executable);
188201
for (const spec of FLAG_INTERPRETER_INLINE_EVAL_SPECS) {
189-
if (specNamesInclude(spec.names, normalized)) {
202+
if (specNamesInclude(spec, normalized)) {
190203
return spec;
191204
}
192205
}
@@ -196,7 +209,7 @@ function findInterpreterSpec(executable: string): InterpreterFlagSpec | null {
196209
function findPositionalInterpreterSpec(executable: string): PositionalInterpreterSpec | null {
197210
const normalized = normalizeExecutableToken(executable);
198211
for (const spec of POSITIONAL_INTERPRETER_INLINE_EVAL_SPECS) {
199-
if (specNamesInclude(spec.names, normalized)) {
212+
if (specNamesInclude(spec, normalized)) {
200213
return spec;
201214
}
202215
}

src/infra/exec-approvals-allow-always.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,54 @@ describe("resolveAllowAlwaysPatterns", () => {
434434
expect(persisted).toStrictEqual([]);
435435
});
436436

437+
it.each([
438+
{
439+
name: "versioned Python",
440+
executable: "python3.13",
441+
command: "python3.13 -c 'print(1)'",
442+
},
443+
{
444+
name: "versioned PyPy",
445+
executable: "pypy3.10",
446+
command: "pypy3.10 -c 'print(1)'",
447+
},
448+
{
449+
name: "PHP begin flag",
450+
executable: "php",
451+
command: "php -B 'system(\"id\")'",
452+
},
453+
{
454+
name: "PHP end flag",
455+
executable: "php",
456+
command: "php -E 'system(\"id\")'",
457+
},
458+
{
459+
name: "Rscript eval",
460+
executable: "Rscript",
461+
command: "Rscript -e 'system(\"id\")'",
462+
},
463+
])(
464+
"keeps $name inline-eval commands out of allow-always persistence in strict mode",
465+
async ({ command, executable }) => {
466+
if (process.platform === "win32") {
467+
return;
468+
}
469+
const dir = makeTempDir();
470+
makeExecutable(dir, executable);
471+
const env = makePathEnv(dir);
472+
const safeBins = resolveSafeBins(undefined);
473+
474+
const { persisted } = await resolvePersistedPatterns({
475+
command,
476+
dir,
477+
env,
478+
safeBins,
479+
strictInlineEval: true,
480+
});
481+
expect(persisted).toStrictEqual([]);
482+
},
483+
);
484+
437485
it("unwraps reusable shell wrappers and persists the inner executable instead", async () => {
438486
if (process.platform === "win32") {
439487
return;

0 commit comments

Comments
 (0)