From 983055aef0486a4bc230d16265e69fff788ef65d Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Fri, 7 Aug 2026 23:40:17 +0000 Subject: [PATCH] fix(dashboard-agent): keep a withheld object's key names from the eval judge The shape descriptor for a redacted object copied its key names verbatim, so a payload keyed by an email address sent that address to the judge model and into the chat_turn_evals row. Emit a count instead, in both the shape and the depth-cap descriptor, and stop allow-listing "keys" so a tool's own field of that name is redacted like any other. --- .../src/dashboard-agent.test.ts | 4 +-- .../dashboard-agent/src/eval-policy.ts | 13 ++++--- .../src/eval-redaction.test.ts | 34 +++++++++++++++++++ .../dashboard-agent/src/eval-turn.ts | 2 +- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/internal-packages/dashboard-agent/src/dashboard-agent.test.ts b/internal-packages/dashboard-agent/src/dashboard-agent.test.ts index 8736db765..8c8fd34a8 100644 --- a/internal-packages/dashboard-agent/src/dashboard-agent.test.ts +++ b/internal-packages/dashboard-agent/src/dashboard-agent.test.ts @@ -620,7 +620,7 @@ describe("the eval judge payload", () => { expect(redacted.runs[0].error.name).toBe("TimeoutError"); expect(redacted.runs[0].error.message).toEqual({ redacted: "message", chars: 16 }); // The customer's data does not. - expect(redacted.runs[0].payload).toEqual({ redacted: "payload", keys: ["email", "amount"] }); + expect(redacted.runs[0].payload).toEqual({ redacted: "payload", keyCount: 2 }); expect(redacted.runs[0].output).toEqual({ redacted: "output", chars: 16 }); expect(redacted.rows).toEqual({ redacted: "rows", items: 2 }); expect(JSON.stringify(redacted)).not.toContain("someone@example.com"); @@ -647,7 +647,7 @@ describe("the eval judge payload", () => { level: { level: { level: { - level: { level: { truncated: true, keys: ["rows", "status"] } }, + level: { level: { truncated: true, keyCount: 2 } }, }, }, }, diff --git a/internal-packages/dashboard-agent/src/eval-policy.ts b/internal-packages/dashboard-agent/src/eval-policy.ts index 2dfc4c470..a08ed6d24 100644 --- a/internal-packages/dashboard-agent/src/eval-policy.ts +++ b/internal-packages/dashboard-agent/src/eval-policy.ts @@ -152,9 +152,9 @@ const STRUCTURAL_KEYS = new Set([ "cliVersion", "sdkVersion", "version", - // Markers this module and the truncation step add themselves. + // Markers this module and the truncation step add themselves. `keyCount` and the rest are + // numbers or booleans we wrote; a tool's own field of the same name is redacted like any other. "chars", - "keys", "note", "omitted", "redacted", @@ -181,14 +181,13 @@ export function allowedEvalKeys(toolName?: string): ReadonlySet { return new Set([...STRUCTURAL_KEYS, ...extras]); } -/** Max keys listed in a shape descriptor, so a wide object can't grow the prompt. */ -const MAX_SHAPE_KEYS = 20; - function describeShape(key: string, value: unknown): Record { if (Array.isArray(value)) return { redacted: key, items: value.length }; if (typeof value === "string") return { redacted: key, chars: value.length }; if (value !== null && typeof value === "object") { - return { redacted: key, keys: Object.keys(value).slice(0, MAX_SHAPE_KEYS) }; + // The count, never the names: a withheld object's own keys can be customer data + // (an email address, an account id) just as much as its values. + return { redacted: key, keyCount: Object.keys(value).length }; } return { redacted: key }; } @@ -204,7 +203,7 @@ const MAX_REDACT_DEPTH = 8; function describeTruncated(value: object): Record { return Array.isArray(value) ? { truncated: true, items: value.length } - : { truncated: true, keys: Object.keys(value).slice(0, MAX_SHAPE_KEYS) }; + : { truncated: true, keyCount: Object.keys(value).length }; } /** diff --git a/internal-packages/dashboard-agent/src/eval-redaction.test.ts b/internal-packages/dashboard-agent/src/eval-redaction.test.ts index c40fcc3e9..481884f99 100644 --- a/internal-packages/dashboard-agent/src/eval-redaction.test.ts +++ b/internal-packages/dashboard-agent/src/eval-redaction.test.ts @@ -52,6 +52,40 @@ describe("the eval judge's structural allow-list", () => { }); }); + it("withholds a redacted object's key names, not only its values", () => { + const redacted = redactEvalToolValue({ + id: "run_1", + payload: { "billing@customer.com": { plan: "pro" }, "Acme Corp": 1 }, + }) as Record; + + expect(redacted.payload).toEqual({ redacted: "payload", keyCount: 2 }); + const serialized = JSON.stringify(redacted); + expect(serialized).not.toContain("billing@customer.com"); + expect(serialized).not.toContain("Acme Corp"); + }); + + it("withholds the key names of an object sitting at the depth cap", () => { + // Eight allowed containers puts the object exactly on MAX_REDACT_DEPTH, where the walk stops. + let value: unknown = { "billing@customer.com": 1, "Acme Corp": 2 }; + for (let depth = 0; depth < 8; depth++) value = { data: value }; + + const serialized = JSON.stringify(redactEvalToolValue(value)); + expect(serialized).toContain('"truncated":true'); + expect(serialized).not.toContain("billing@customer.com"); + expect(serialized).not.toContain("Acme Corp"); + }); + + it("redacts a tool's own `keys` field like any other unknown name", () => { + expect(allowedEvalKeys().has("keys")).toBe(false); + + const redacted = redactEvalToolValue({ keys: ["billing@customer.com"] }) as Record< + string, + unknown + >; + expect(redacted.keys).toEqual({ redacted: "keys", items: 1 }); + expect(JSON.stringify(redacted)).not.toContain("billing@customer.com"); + }); + it("allows a tool's own structural fields, and only for that tool", () => { expect(allowedEvalKeys("run_query").has("columns")).toBe(true); expect(allowedEvalKeys("get_run").has("columns")).toBe(false); diff --git a/internal-packages/dashboard-agent/src/eval-turn.ts b/internal-packages/dashboard-agent/src/eval-turn.ts index 19bf4c311..983cdbc62 100644 --- a/internal-packages/dashboard-agent/src/eval-turn.ts +++ b/internal-packages/dashboard-agent/src/eval-turn.ts @@ -141,7 +141,7 @@ const JUDGE_SYSTEM = [ "You are given the user's question, the data the agent retrieved through its tools (treat this as the only ground truth), and the agent's answer.", "Reason briefly first, then fill in the scores and classification.", "Score quality only on factual grounding and whether the question was answered; do not reward verbosity or confidence. Penalize any run id, error name, count, status, version, or metric not present in the tool data.", - 'Some tool fields arrive as a shape descriptor like {"redacted":"payload","keys":[...]} — that data was withheld on purpose. Only a fixed set of structural fields (ids, names, statuses, counts, timestamps, versions) is passed through, so most free text, including error messages, arrives this way. Treat it as retrieved but unreadable: judge grounding on the facts you can see, and never penalize an answer for a redacted field, for a value cut short with {"truncated":true}, or for a tool call listed as {"omitted":true}.', + 'Some tool fields arrive as a shape descriptor like {"redacted":"payload","keyCount":3} — that data was withheld on purpose. Only a fixed set of structural fields (ids, names, statuses, counts, timestamps, versions) is passed through, so most free text, including error messages, arrives this way. Treat it as retrieved but unreadable: judge grounding on the facts you can see, and never penalize an answer for a redacted field, for a value cut short with {"truncated":true}, or for a tool call listed as {"omitted":true}.', `A failed tool call carries {"isError":true,"errorCategory":"..."} — one of ${EVAL_ERROR_CATEGORIES.join(", ")}. The category is a label our own code derives from the failure and is the only part of it you get: the message was withheld on purpose, like every other free text, and "unknown" means our check could not tell the kind, not that anything was hidden from you. Read the category as the failure's kind, judge the answer against it, and never treat a withheld message as a sign the agent concealed something or as grounds to lower a score.`, "Then classify the turn for product insight. Flag capabilityGap when the agent could not fully help because it lacked a tool, data, or permission (it is read-only, so any request to change something is a capability gap). Flag docsGap for how-to questions a doc would answer better. Flag supportOpportunity when the user seems stuck or frustrated. Flag featureRequest when they want something the product does not do. Capture concrete, actionable signals.", ].join(" ");