From d9c0357b2a5a0c50fa9a7955edfc54895fa847ca Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 00:15:39 -0700 Subject: [PATCH] fix(environment): keep bodies too deep for the state's nesting out of the state A recorded body at the JSON depth limit was accepted by extraction, then nested a few levels deeper as a document or entity value, so sealing the state Evidence threw and environment-serve and environment-export stopped instead of falling back. stateBodyJson, the one rule extraction and serving share, now treats a body nesting deeper than the limit minus 16 levels as unusable, so its tool stays a recorded view. Co-Authored-By: Claude Opus 5.5 --- .../state-serve/verification.md | 9 ++++++++ .../environment/environmentState.test.ts | 23 +++++++++++++++++++ .../EnvironmentStateAccumulator.ts | 16 +++++++++++-- .../analysisView/environmentStateRules.ts | 18 +++++++++++++++ .../analysisView/environmentStateView.test.ts | 17 ++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/specs/agent-environments/state-serve/verification.md b/docs/specs/agent-environments/state-serve/verification.md index 40a7d870e5..3087162acb 100644 --- a/docs/specs/agent-environments/state-serve/verification.md +++ b/docs/specs/agent-environments/state-serve/verification.md @@ -61,3 +61,12 @@ any path conversion on every platform), a real `https://` download (tests inject the fetcher), the memory and time of a budgeted batch (not measured), agent token usage, a real AgentEnv framework run, a real-world capture, and write effects, which are a later increment. + +After refusing bodies too deep for the state's own nesting (a recorded body +must nest at most the JSON depth limit minus 16 levels to enter the state): +`npm run check:fast`, `npm run format:check`, `npx knip`, `npm run scan:todos` +passed; `npm run jscpd`: 3.37% over 3373 files; `npx vitest run --project +domain --project services`: 347 files, 5950 tests passed, 5 pre-existing +skips; `npm run build:cached`, then `npm run test:focused --` the serve CLI, +MCP and process boundaries, the export CLI boundary and the two serve adapter +tests: 6 files, 48 tests passed. diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts index cae2b9065d..2d11d0292a 100644 --- a/src/application/environment/environmentState.test.ts +++ b/src/application/environment/environmentState.test.ts @@ -17,6 +17,7 @@ import { } from "../../../tests/fixtures/environmentServe.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import type { JsonValue } from "../../domain/jsonValue.js"; import { inspectAnalysisView, inspectAnalysisViewValidated, @@ -226,6 +227,28 @@ describe("prepareServedState", () => { }); }); + it("derives a state when a body leaves no room for the state's own nesting", () => { + let deep: JsonValue = { theme: "dark" }; + for (let level = 0; level < 995; level += 1) deep = { nested: deep }; + const deepFixture = environmentStateServeFixture( + timedCaptureEvidence([ + ["GET", "/api/settings", deep], + ["PUT", "/api/settings", { theme: "light" }], + ]), + ); + const deepSource = loadEnvironmentSource( + deepFixture.manifest, + deepFixture.capture, + ); + if (!deepSource.ok) throw deepSource.error; + expect( + prepareServedState(deepSource.value, deepFixture.capture).report, + ).toEqual({ + served: true, + evidence_id: deepFixture.state.evidence_id, + }); + }); + it("serves replay and reports the code and message when the capture yields a manifest but no state", () => { const headerOnly = scenarioServeFixture( scenarioHeaderOnlyCaptureEvidence(), diff --git a/src/domain/analysisView/EnvironmentStateAccumulator.ts b/src/domain/analysisView/EnvironmentStateAccumulator.ts index 5423a0733e..d1c3711fd0 100644 --- a/src/domain/analysisView/EnvironmentStateAccumulator.ts +++ b/src/domain/analysisView/EnvironmentStateAccumulator.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { parseExactJson } from "../exactJsonNumber.js"; -import type { JsonValue } from "../jsonValue.js"; +import { MAX_JSON_DEPTH, type JsonValue } from "../jsonValue.js"; import { ok, type Result } from "../result.js"; import { WEB_NETWORK_CAPTURE_LIMITS, @@ -15,6 +15,7 @@ import { import { EnvironmentStateExtraction } from "./EnvironmentStateExtraction.js"; import type { TemplateRule } from "./environmentManifestRules.js"; import { + deeperThan, hasPrototypeKey, isStateWrite, type ParsedBody, @@ -52,6 +53,13 @@ type StateFields = Pick< /** Bodies beyond this many UTF-8 bytes are not parsed into state; their views stay recorded. */ export const STATE_BODY_LIMIT_BYTES = 256 * 1024; +/** + * Levels the state's own nesting adds above a recorded value: Evidence, a `data/get` reply and a + * JSON-RPC response hold a body's members about ten levels down, so a body must leave this much + * room under the JSON depth limit to be sealed and served. + */ +const STATE_NESTING_DEPTH = 16; + /** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers and no `__proto__` member; otherwise undefined. */ export const stateBodyJson = ( contentType: string | null, @@ -67,7 +75,11 @@ export const stateBodyJson = ( ) return undefined; const value = parseExactJson(body.bodyText); - return value === undefined || hasPrototypeKey(value) ? undefined : value; + return value === undefined || + hasPrototypeKey(value) || + deeperThan(value, MAX_JSON_DEPTH - STATE_NESTING_DEPTH) + ? undefined + : value; }; const timingSchema = z.object({ diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 39543dd1f7..aa6fe5ee86 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -297,6 +297,24 @@ export const jsonBytes = (value: JsonValue): number => { return ENTRY_BYTES; }; +/** Whether a JSON value nests more than `limit` levels below its root. */ +export const deeperThan = (value: JsonValue, limit: number): boolean => { + const pending: { readonly value: JsonValue; readonly depth: number }[] = [ + { value, depth: 0 }, + ]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (next.depth > limit) return true; + const children = Array.isArray(next.value) + ? next.value + : isObject(next.value) + ? Object.values(next.value) + : []; + for (const child of children) + pending.push({ value: child, depth: next.depth + 1 }); + } + return false; +}; + /** JSON parsers keep a `__proto__` member that plain object construction would turn into a prototype. */ export const hasPrototypeKey = (value: JsonValue): boolean => Array.isArray(value) diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index b31daaf36e..cf26be38c7 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -327,6 +327,23 @@ it("leaves recorded the views it cannot reproduce, with the reason", () => { expect(state.entities).toEqual([]); }); +it("leaves a body too deep for the state's own nesting recorded instead of failing to seal", () => { + let deep: JsonValue = { theme: "dark" }; + for (let level = 0; level < 995; level += 1) deep = { nested: deep }; + const state = stateOf( + captureOf([ + ["GET", "/api/settings", deep], + ["PUT", "/api/settings", { theme: "light" }], + ]), + ); + expect(state.documents).toEqual([]); + expect(viewOf(state, "get_settings")).toEqual({ + tool: "get_settings", + shape: "recorded", + reason: "body-unusable", + }); +}); + it("holds a written path's non-entity read as a document", () => { const state = stateOf( captureOf([