fix(web): reject drafts whose digits or types would not survive being sent

Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.

- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
  draft reading `{"id":9007199254740993}` submits `…992`. New
  `hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
  arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
  `parseJsonObjectDraft`, which Copilot did not flag but shares the defect.

  It is narrower than "unsafe integer": it also requires the number's shortest
  form to be written out in full, because JS stringifies from 1e21 upward in
  exponent form and such a value was typed that way — `{"n":1e308}` is a
  legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
  accepted. It still refuses the rare full-form integer that happens to be
  exactly representable, erring toward refusing a sendable value rather than
  misreporting an unsendable one — the trade `toNumericValue` already makes.

- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
  non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
  editor still shows a number. `reshapedReplayParam` now rejects a non-string
  prompt argument. Not a workaround: the spec types
  `GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
  is the only thing a prompt argument can be. `tools/call` is untouched —
  `callTool` sends its arguments as given.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 04:34:45 -04:00
commit 8f4aa7f8af
2 changed files with 216 additions and 13 deletions

View file

@ -1,5 +1,51 @@
import { describe, it, expect } from "vitest";
import { parseJsonObjectDraft } from "./jsonObjectDraft";
import { hasRoundedInteger, parseJsonObjectDraft } from "./jsonObjectDraft";
// `isSerializableJson` catches a literal too large to represent (`1e400` →
// `Infinity` → `null` on the wire). This is the quieter one: a whole number
// past 2^53−1 parses to the nearest double, so the draft shows digits the wire
// will not carry.
describe("hasRoundedInteger", () => {
it("accepts whole numbers inside the safe range", () => {
expect(hasRoundedInteger({ id: Number.MAX_SAFE_INTEGER })).toBe(false);
expect(hasRoundedInteger({ id: -Number.MAX_SAFE_INTEGER })).toBe(false);
expect(hasRoundedInteger({ id: 0 })).toBe(false);
});
it("rejects a whole number past the safe range", () => {
// Typed as …93, parsed as …92.
expect(hasRoundedInteger(JSON.parse('{"id":9007199254740993}'))).toBe(true);
});
// A fractional or exponent-form value is a double by nature and is sent as
// the double it parsed to, so nothing is lost between typing and sending.
it("accepts a fractional value however large", () => {
expect(hasRoundedInteger({ ratio: 1.5 })).toBe(false);
expect(hasRoundedInteger({ ratio: 0.1 + 0.2 })).toBe(false);
});
// JS stringifies from 1e21 upward in exponent form, so such a value was typed
// that way and the parsed double is what was asked for. `parseJsonObjectDraft`
// has always accepted these.
it("accepts a value whose shortest form carries an exponent", () => {
expect(hasRoundedInteger({ n: 1e308 })).toBe(false);
expect(hasRoundedInteger({ n: 1e21 })).toBe(false);
});
it("looks inside arrays and nested objects", () => {
expect(hasRoundedInteger(JSON.parse('[{"a":[9007199254740993]}]'))).toBe(
true,
);
expect(hasRoundedInteger({ a: { b: [1, 2, "x", null, true] } })).toBe(
false,
);
});
it("has nothing to say about non-numeric values", () => {
expect(hasRoundedInteger("9007199254740993")).toBe(false);
expect(hasRoundedInteger(null)).toBe(false);
});
});
describe("parseJsonObjectDraft", () => {
it("reads empty text as the empty object rather than an error", () => {

View file

@ -20,6 +20,55 @@ export function isJsonObject(value: unknown): value is StrictJsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Whether `JSON.parse` dropped digits from a whole number written out in full.
*
* `isSerializableJson` catches a literal too large to *represent* — `1e400`
* parses to `Infinity` and writes back as `null`. This catches the quieter one:
* a whole number past 2^53−1 parses to the nearest double, so
* `{"id":9007199254740993}` becomes `…992` with no error anywhere. The draft
* still shows what was typed, which is what makes it a misreport rather than a
* formatting difference — the editor displays one value and the wire carries
* another. Snowflake-style ids are the realistic case.
*
* Two conditions, and the second is what keeps this from over-reaching:
*
* - **An unsafe whole number.** A fractional value is a double by nature and is
* sent as the double it parsed to, so nothing is lost between typing and
* sending.
* - **Whose shortest representation is written out in full.** JS switches to
* exponent form at 1e21, so a value that stringifies as `1e+308` was typed in
* exponent form, where the parsed double *is* the value the user asked for.
* Without this, `{"n":1e308}` — a legitimate `_meta` value — would be
* refused, which `parseJsonObjectDraft`'s own tests pin against.
*
* It still refuses the rare full-form integer past 2^53−1 that happens to be
* exactly representable (2^54, say), because nothing here can tell it apart
* from one that lost digits without the original literal. That errs toward
* refusing a sendable value rather than misreporting an unsendable one, which
* is the same trade `toNumericValue` makes for the schema form's number input.
*/
export function hasRoundedInteger(value: unknown): boolean {
if (typeof value === "number") {
return (
Number.isInteger(value) &&
!Number.isSafeInteger(value) &&
!String(value).includes("e")
);
}
if (Array.isArray(value)) return value.some(hasRoundedInteger);
if (value !== null && typeof value === "object") {
return Object.values(value as Record<string, unknown>).some(
hasRoundedInteger,
);
}
return false;
}
/** The message every draft parser uses for {@link hasRoundedInteger}. */
export const ROUNDED_INTEGER_ERROR =
"Whole numbers must be within ±(2^53 − 1) — a longer one is rounded when parsed, so a different value would be sent";
/**
* Read a JSON-object editor's draft text.
*
@ -60,5 +109,8 @@ export function parseJsonObjectDraft(text: string): JsonObjectDraft {
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
};
}
if (hasRoundedInteger(parsed)) {
return { ok: false, error: ROUNDED_INTEGER_ERROR };
}
return { ok: true, value: parsed };
}