History

Make the security regressions behavioural

Rhys Sullivan committed 2 months ago

Commit 2b40ae61 parent 2c4c99c5 files · +156 −21Browse files

Unified · Split

5 files
  1. packages/plugins/mcp/src/sdk/appserver-connector.test.ts +10 −11
  2. packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts +39 −9
  3. packages/plugins/mcp/src/sdk/codex-repl.test.ts +64 −0
  4. packages/plugins/mcp/src/sdk/connection-pool-key.test.ts +42 −0
  5. packages/plugins/mcp/src/sdk/invoke.ts +1 −1
packages/plugins/mcp/src/sdk/appserver-connector.test.ts+10−11
@@ -298,24 +298,23 @@ describe("codex app-server bridge", () => { · 297 unmodified lines
298 ),298 ),
299 );299 );
300 300
301 it.effect("keeps a prototype-shaped argument as data, not as a prototype", () =>301 it.effect("carries a prototype-named argument through to the program as data", () =>
302 Effect.scoped(302 Effect.scoped(
303 Effect.gen(function* () {303 Effect.gen(function* () {
304 // Embedded as an object literal, `__proto__` sets the prototype rather304 // Parsed, not written as a literal: `{ __proto__: … }` in source sets
305 // than an own key: the argument would disappear from `args.__proto__`305 // the prototype, so a literal fixture would assert nothing. This is
306 // and reappear as inherited properties on every lookup the generated306 // the shape that arrives over the wire.
307 // program makes.307 // oxlint-disable-next-line executor/no-json-parse -- an object literal would assign the PROTOTYPE; parsing produces the own key that actually arrives over the wire
308 const args = JSON.parse('{"url":"https://example.com/","__proto__":{"polluted":"yes"}}');
308 const connection = yield* withConnection(browserInput());309 const connection = yield* withConnection(browserInput());
309 310
310 const result = yield* Effect.promise(() =>311 const result = yield* Effect.promise(() =>
311 connection.client.callTool({312 connection.client.callTool({ name: "navigate", arguments: args }),
312 name: "navigate",
313 arguments: { url: "https://example.com/", __proto__: { polluted: "yes" } },
314 }),
315 );313 );
316 const program = (result.content as readonly { readonly text: string }[])[0]!.text;314 const program = (result.content as readonly { readonly text: string }[])[0]!.text;
317 expect(program, "arguments are parsed as data").toContain("JSON.parse(");315
318 expect(program, "never emitted as an object literal").not.toContain("= {");316 expect(program, "embedded as parsed data").toContain("JSON.parse(");
317 expect(program, "with the key preserved rather than dropped").toContain("__proto__");
319 }),318 }),
320 ),319 ),
321 );320 );
packages/plugins/mcp/src/sdk/codex-plugin-presets.test.ts+39−9
@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";3import { CURATED_CODEX_PLUGINS } from "./codex-plugin-presets";
4import { APPROVAL_TERM_KEYS } from "./invoke";4import { approvalTerms } from "./invoke";
5import { mcpPresets } from "./presets";5import { mcpPresets } from "./presets";
6 6
7// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
@@ -41,13 +41,43 @@ describe("codex catalog presets", () => { · 33 unmodified lines
41});41});
42 42
43describe("approval terms", () => {43describe("approval terms", () => {
44 // The projection lives at the MCP boundary (`invoke.ts`), but the vocabulary44 // `_meta` is an open map: servers put progress tokens, internal ids, and
45 // it recognises is the contract these plugins speak, so pin it here.45 // opaque state in it. Rendering all of it as "approval terms" would both
46 it("names only the keys that describe what accepting means", () => {46 // mislead and risk surfacing something private, so the projection is a
47 // Codex's browser approval says the grant persists, and for which origin.47 // closed vocabulary.
48 // Everything else a server puts in `_meta` — progress tokens, internal48 it("keeps the keys that say what accepting means", () => {
49 // ids, opaque state — is not a term the user is agreeing to, and must not49 expect(
50 // be rendered as one.50 approvalTerms({
51 expect(APPROVAL_TERM_KEYS).toEqual(["persist", "origin", "connector_name", "connector_id"]);51 persist: "always",
52 origin: "https://example.com",
53 connector_name: "Browser use",
54 connector_id: "browser-use",
55 }),
56 ).toEqual({
57 meta: {
58 persist: "always",
59 origin: "https://example.com",
60 connector_name: "Browser use",
61 connector_id: "browser-use",
62 },
63 });
64 });
65
66 it("drops everything else a server attached", () => {
67 expect(
68 approvalTerms({
69 persist: "always",
70 progressToken: "tok_123",
71 codex_approval_kind: "mcp_tool_call",
72 internalSessionId: "sess_abc",
73 nested: { secret: "do not render" },
74 }),
75 ).toEqual({ meta: { persist: "always" } });
76 });
77
78 it("ignores non-string values and contributes nothing when no term applies", () => {
79 expect(approvalTerms({ persist: { always: true }, origin: 42 })).toEqual({});
80 expect(approvalTerms({ progressToken: "tok" })).toEqual({});
81 expect(approvalTerms(undefined)).toEqual({});
52 });82 });
53});83});
packages/plugins/mcp/src/sdk/codex-repl.test.ts+64−0
@@ -0,0 +1,64 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { jsLiteral, jsString, writeJsonResult } from "./codex-repl";
4
5// ---------------------------------------------------------------------------
6// Caller arguments are embedded into a JavaScript source text that runs in
7// Codex's REPL. The encoding is the whole security boundary, so it is tested
8// on the values that break naive embeddings rather than on happy text.
9// ---------------------------------------------------------------------------
10
11/** The value the emitted expression produces, without evaluating JS: the
12 * expression is `JSON.parse(<js string literal>)`, so unwrap the literal and
13 * parse it the same way the REPL would. */
14const evaluate = (expression: string): unknown => {
15 expect(expression.startsWith("JSON.parse(")).toBe(true);
16 const literal = expression.slice("JSON.parse(".length, -1);
17 // oxlint-disable-next-line executor/no-json-parse -- the subject under test IS the JSON embedding; decoding it exactly as the REPL would is the assertion, and a schema decode would hide the prototype behaviour being checked
18 return JSON.parse(JSON.parse(literal) as string);
19};
20
21describe("jsLiteral", () => {
22 it("keeps a prototype-named key as an own key", () => {
23 // Built by parsing, not as an object literal: writing `{ __proto__: … }`
24 // in source sets the prototype, so a literal-based fixture would test
25 // nothing. This is the shape that actually arrives over the wire.
26 // oxlint-disable-next-line executor/no-json-parse -- an object literal would assign the PROTOTYPE, so parsing is the only way to build the own key this test is about
27 const args: unknown = JSON.parse('{"__proto__":{"polluted":"yes"},"url":"https://x/"}');
28
29 const value = evaluate(jsLiteral(args)) as Record<string, unknown>;
30
31 expect(Object.hasOwn(value, "__proto__"), "carried as data").toBe(true);
32 expect(Object.getPrototypeOf(value), "and not onto the prototype chain").toBe(Object.prototype);
33 expect((value as { polluted?: unknown }).polluted).toBeUndefined();
34 });
35
36 it("survives quotes, newlines, and JS line terminators", () => {
37 // U+2028/U+2029 are legal raw inside a JSON string but END A STATEMENT in
38 // a JS source text — the case that silently truncates a program.
39 const args = { text: 'he said "hi"\n\u2028\u2029 and left', path: "C:\\tmp" };
40
41 expect(evaluate(jsLiteral(args))).toEqual(args);
42 });
43
44 it("renders an absent value as null rather than a hole", () => {
45 expect(evaluate(jsLiteral(undefined))).toBeNull();
46 });
47});
48
49describe("jsString", () => {
50 it("escapes line terminators that would end the statement", () => {
51 expect(jsString("a\u2028b")).toBe('"a\\u2028b"');
52 // oxlint-disable-next-line executor/no-json-parse -- decoding the emitted JS string literal is the assertion
53 expect(JSON.parse(jsString('quote " here')) as string).toBe('quote " here');
54 });
55});
56
57describe("writeJsonResult", () => {
58 it("scopes the per-call body so a reused REPL session does not redeclare", () => {
59 const program = writeJsonResult(["const a = 1;"], "await f(a)");
60
61 expect(program.startsWith("await (async () => {")).toBe(true);
62 expect(program).toContain("nodeRepl.write(JSON.stringify(result ?? null));");
63 });
64});
packages/plugins/mcp/src/sdk/connection-pool-key.test.ts+42−0
@@ -208,6 +208,48 @@ describe("MCP connection-pool key", () => { · 207 unmodified lines
208 },208 },
209 );209 );
210 210
211 // Two connections under the SAME owner are still distinct sessions: a
212 // grant made through one must not answer a prompt raised by the other.
213 const sameOwnerOtherConnection = yield* connectionPoolKey(
214 recipe,
215 "none",
216 {},
217 {
218 owner: "org",
219 connection: "second",
220 },
221 );
222
223 expect(theirs).not.toBe(mine);
224 expect(sameOwnerOtherConnection).not.toBe(mine);
225 }),
226 );
227
228 it.effect("separates remote connections that differ only by identity", () =>
229 Effect.gen(function* () {
230 // Hashed on the remote arm too: an open server with no credential would
231 // otherwise pool one session across every connection.
232 const input = remoteInput({ headers: {} });
233
234 const mine = yield* connectionPoolKey(
235 input,
236 "none",
237 {},
238 {
239 owner: "org",
240 connection: "default",
241 },
242 );
243 const theirs = yield* connectionPoolKey(
244 input,
245 "none",
246 {},
247 {
248 owner: "user:someone-else",
249 connection: "default",
250 },
251 );
252
211 expect(theirs).not.toBe(mine);253 expect(theirs).not.toBe(mine);
212 }),254 }),
213 );255 );
packages/plugins/mcp/src/sdk/invoke.ts+1−1
@@ -129,7 +129,7 @@ const decodeElicitContent = Schema.decodeUnknownSync( · 128 unmodified lines
129 * server contributes nothing rather than noise. */129 * server contributes nothing rather than noise. */
130export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const;130export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const;
131 131
132const approvalTerms = (meta: Record<string, unknown> | undefined) => {132export const approvalTerms = (meta: Record<string, unknown> | undefined) => {
133 if (meta === undefined) return {};133 if (meta === undefined) return {};
134 const terms = Object.fromEntries(134 const terms = Object.fromEntries(
135 APPROVAL_TERM_KEYS.flatMap((key) => {135 APPROVAL_TERM_KEYS.flatMap((key) => {