From f6c756aafcc6fa3bc16e000caec460159dd826c1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 11:37:17 -0700 Subject: [PATCH] fix(environment): quote captured names in response summaries Field and member names from a capture were joined raw into tool descriptions that use ", " and "; " themselves, so a key such as "x; recorded statuses 500" made a description claim a status the tool never recorded, and "id, name" read as two fields. Names are now written as JSON strings; a name too long for a sample is still refused by the sample without being copied. Co-Authored-By: Claude Opus 5.5 --- docs/agent-environments.md | 6 ++- .../environment/environmentTools.test.ts | 37 ++++++++++++++----- .../environment/environmentTools.ts | 11 +++++- .../boundary/mcp/environmentServeMcp.test.ts | 2 +- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 26444e0d35..da9977d2c6 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -288,7 +288,7 @@ connecting to `/mcp`: "tools": [ { "name": "get_users_by_id", - "description": "Untrusted captured API catalog: … GET /api/users/{numeric}; returns a JSON object with fields id, uid, login, email; recorded statuses 200; replays retained records [5, 6]; evidence ev_…", + "description": "Untrusted captured API catalog: … GET /api/users/{numeric}; returns a JSON object with fields \"id\", \"uid\", \"login\", \"email\"; recorded statuses 200; replays retained records [5, 6]; evidence ev_…", "inputSchema": { "type": "object", "properties": { @@ -315,7 +315,9 @@ names the method and path template, then what the tool returns, one level deep: fields …` or `returns a JSON object whose lists objects with fields …`, where a list is an array of objects found by the [environment state](web-network-captures.md#environment-state)'s list rule (the -root array, or the one array of objects an envelope carries). The summary comes +root array, or the one array of objects an envelope carries). Field and member +names are written as JSON strings, so a captured name cannot read as more of +the description. The summary comes from the first cited `2xx` record that replay answers as JSON; only when none did, the first cited JSON record of another status gives it, labelled with that status (`status 401 returns …`). It describes one recorded response, not a diff --git a/src/application/environment/environmentTools.test.ts b/src/application/environment/environmentTools.test.ts index 527a55cdce..5bcbac983e 100644 --- a/src/application/environment/environmentTools.test.ts +++ b/src/application/environment/environmentTools.test.ts @@ -46,7 +46,7 @@ describe("environmentToolDefinitions", () => { const users = tool("get_users_by_id"); const definition = definitions.find(({ name }) => name === users.name); expect(definition?.description).toBe( - `${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; returns a JSON object with fields id, uid, login, email; recorded statuses 200; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`, + `${REPLAY_CATALOG_INSTRUCTIONS} GET ${users.path_template}; returns a JSON object with fields "id", "uid", "login", "email"; recorded statuses 200; replays retained records [${users.record_ordinals.join(", ")}]; evidence ${source.evidenceId}`, ); expect(definition?.tool).toBe(users); }); @@ -150,13 +150,13 @@ const returnsOf = (bodyText: string): string | undefined => describe("environment tool response shapes", () => { it("describes array, enveloped list and object responses one level deep with their statuses", () => { expect(described("get_folders")).toContain( - "GET /api/folders; returns a JSON array of objects with fields id, uid, title; recorded statuses 200; replays", + 'GET /api/folders; returns a JSON array of objects with fields "id", "uid", "title"; recorded statuses 200; replays', ); expect(described("get_dashboards_versions_by_uid")).toContain( - "; returns a JSON object whose versions lists objects with fields id, version; recorded statuses 200; replays", + '; returns a JSON object whose "versions" lists objects with fields "id", "version"; recorded statuses 200; replays', ); expect(described("get_dashboards_by_uid")).toContain( - "; returns a JSON object with fields meta, dashboard; recorded statuses 200; replays", + '; returns a JSON object with fields "meta", "dashboard"; recorded statuses 200; replays', ); }); @@ -182,7 +182,7 @@ describe("environment tool response shapes", () => { "returns a JSON number", "returns a JSON object with no fields", "returns a JSON array of objects with no fields", - "returns a JSON object with fields items", + 'returns a JSON object with fields "items"', ]); const member = "m".repeat(2048); expect( @@ -190,9 +190,28 @@ describe("environment tool response shapes", () => { { bodyText: JSON.stringify({ [member]: [{ id: 1 }, { id: 2 }] }) }, ]), ).toContain( - `{numeric}; returns a JSON object whose ${"m".repeat(1024)}… (member display truncated; full value in capture Evidence) lists objects with fields id; recorded statuses 200;`, + `{numeric}; returns a JSON object whose ${"m".repeat(1024)}… (member display truncated; full value in capture Evidence) lists objects with fields "id"; recorded statuses 200;`, ); }); + + it("quotes captured names, so a separator in one cannot read as more of the description", () => { + const description = usersAnswering([ + { + bodyText: JSON.stringify({ + "id, name": 1, + "x; recorded statuses 500": 2, + }), + }, + ]); + expect(description).toContain( + 'returns a JSON object with fields "id, name", "x; recorded statuses 500"; recorded statuses 200;', + ); + expect( + usersAnswering([ + { bodyText: JSON.stringify({ "a\nb": [{ id: 1 }, { id: 2 }] }) }, + ]), + ).toContain('whose "a\\nb" lists objects with fields "id";'); + }); }); describe("environment tool response summaries", () => { @@ -210,7 +229,7 @@ describe("environment tool response summaries", () => { [200, 401], ); expect(description).toContain( - "{numeric}; returns a JSON object with fields id, login; recorded statuses 200, 401; replays retained records [0, 1, 2, 3];", + '{numeric}; returns a JSON object with fields "id", "login"; recorded statuses 200, 401; replays retained records [0, 1, 2, 3];', ); }); @@ -225,12 +244,12 @@ describe("environment tool response summaries", () => { [200, 401, 403], ), ).toContain( - "{numeric}; status 401 returns a JSON object with fields message; recorded statuses 200, 401, 403; replays", + '{numeric}; status 401 returns a JSON object with fields "message"; recorded statuses 200, 401, 403; replays', ); expect( usersAnswering([{ status: null, bodyText: '{"id":2}' }], []), ).toContain( - "{numeric}; a record without a status returns a JSON object with fields id; no recorded status; replays", + '{numeric}; a record without a status returns a JSON object with fields "id"; no recorded status; replays', ); }); diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index 17fc288860..c09e2a50b9 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -129,6 +129,13 @@ const describeList = ( : `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(count)} shown; full list in ${holder})`; }; +/** + * A captured name as a JSON string, so a separator, quote or newline in it cannot read as more of the + * description. A name too long for a sample is left as it is: the sample refuses it either way. + */ +const quotedName = (name: string): string => + name.length > DESCRIPTION_SAMPLE_BYTES ? name : JSON.stringify(name); + /** Field names of the objects in first-seen order; the recorded bodies stay in the capture Evidence. */ const withFields = (objects: readonly JsonObject[]): string => { const names = new Set(); @@ -136,7 +143,7 @@ const withFields = (objects: readonly JsonObject[]): string => { for (const name of Object.keys(object)) names.add(name); return names.size === 0 ? "with no fields" - : `with fields ${describeList(names, names.size, String, "capture Evidence")}`; + : `with fields ${describeList(names, names.size, quotedName, "capture Evidence")}`; }; /** One level of a JSON body; a list is an array of objects found by the state's list rule. */ @@ -147,7 +154,7 @@ const describeJson = (value: JsonValue): string => { const [member] = pointerTokens(pointer); return member === undefined ? `a JSON array of objects ${withFields(items)}` - : `a JSON object whose ${describeText(member, "member", "capture Evidence")} lists objects ${withFields(items)}`; + : `a JSON object whose ${describeText(quotedName(member), "member", "capture Evidence")} lists objects ${withFields(items)}`; } if (isObject(value)) return `a JSON object ${withFields([value])}`; if (Array.isArray(value)) return "a JSON array"; diff --git a/tests/boundary/mcp/environmentServeMcp.test.ts b/tests/boundary/mcp/environmentServeMcp.test.ts index 440b78e348..778592e3dc 100644 --- a/tests/boundary/mcp/environmentServeMcp.test.ts +++ b/tests/boundary/mcp/environmentServeMcp.test.ts @@ -64,7 +64,7 @@ describe("rea environment-serve", () => { ); const users = tools.find(({ name }) => name === "get_users_by_id"); expect(users?.description).toContain( - "; returns a JSON object with fields id, uid, login, email; recorded statuses 200;", + '; returns a JSON object with fields "id", "uid", "login", "email"; recorded statuses 200;', ); expect(users?.annotations).toEqual({ readOnlyHint: true,