fix(environment): state scenario redaction and body budgets as they behave

A response body that redaction emptied, because a declared secret could
not be cut out, was replayed as an empty reply; it is now treated as not
retained. Bodies that kept redaction markers are counted in a manifest
limitation, since their tools replay the redacted bytes.

The no-body limitation counted transactions no tool can cite and said
their tools replay status and content type, which a failed request does
not have; it now counts records with a usable request line and says
either value may be absent.

The tool contract and capture docs now say a scenario parent relies on
capture-time redaction instead of the sensitive-values refusal, the MCP
contract page lists scenario parents for environment-manifest, and a
scenario parent asked for another view is told the one it supports.

The browser scenario example raised max_network_body_bytes alone, but
the allocation budgets drop a body far below 256 KiB; it now raises
them and says how body and event budgets differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar Arakelyan
2026-10-19 14:50:18 -07:00
parent d2416ca542
commit 655e623341
9 changed files with 144 additions and 16 deletions
+21 -4
View File
@@ -36,8 +36,18 @@ member of `view.json` is the whole of `capture.json`.
The capture can also come from REA itself: a
[browser scenario](browser-scenario-contract.md) records a scripted session
in a browser REA launches, and its Evidence is a manifest parent like a HAR
inspection. Select the `network` event family and response bodies, and raise
the body budget to the 256 KiB a replayed reply can carry:
inspection. Select the `network` event family and response bodies. A replayed
reply carries up to 256 KiB of a recorded body, but the scenario keeps at most
`max_network_body_bytes` of each body (64 KiB by default), and only within its
allocation budgets: base64 is charged about 11 bytes per body byte, and
redaction more the shorter a declared secret is (about 35 bytes per body byte
for a 12-character `admin_password`). A body over
`max_network_body_allocation_bytes` (1 MiB by default) is kept as `unavailable`
with that reason; one over `max_event_allocation_bytes` (4 MiB) or
`max_total_event_allocation_bytes` (16 MiB) loses its whole content event,
headers included, which the capture lists among its omissions. Either way its
tool replays no body. The example raises all four so that 256 KiB bodies are
kept when the declared secret has at least 12 characters:
```json
{
@@ -79,7 +89,12 @@ the body budget to the 256 KiB a replayed reply can carry:
"response_body": true,
"header_values": false
},
"limits": { "max_network_body_bytes": 262144 }
"limits": {
"max_network_body_bytes": 262144,
"max_network_body_allocation_bytes": 12582912,
"max_event_allocation_bytes": 12582912,
"max_total_event_allocation_bytes": 67108864
}
}
}
```
@@ -98,7 +113,9 @@ unrepresented too. Tools are chosen by response media type, so a scenario that
selected neither response bodies nor header values is refused. The browser
scenario redacts credential headers and the secrets it declares; values the
application returns in response bodies, such as session tokens, are kept, and
the replayed body is the bytes the browser exposes for each response.
the replayed body is the bytes the browser exposes for each response, with any
declared secret replaced by its redaction marker (a body that redaction had to
empty replays no body).
## What a manifest is
+1 -1
View File
@@ -218,7 +218,7 @@ no group was templated.
See [derived API surfaces](web-network-captures.md#derived-api-surface).
`inspect_analysis_view` also accepts `environment-manifest` for retained HAR capture
Evidence, projecting the primary origin's request lines into named tools with path
Evidence and `capture_browser_scenario` Evidence, projecting the primary origin's request lines into named tools with path
templates, parameters, observed query names, value-free body shapes and record
pointers, plus excluded groups and three fixed unknowns. Templates, parameters and
names are inferences; the Evidence is inferred whenever any tool or excluded group
@@ -39,8 +39,13 @@ change.
bytes and their integrity checks are unchanged.
- The manifest result names `parent_operation: "capture_browser_scenario"`,
carries an empty `artifact` (`path: ""`, `sha256: null`) and states, as
limitations, the ordinal rule, any dropped capture events and how many
transactions retained no response body.
limitations, the ordinal rule, any dropped capture events, how many
transactions with a usable request line retained no response body (tools
citing them replay only the recorded status and content type, either of
which may be absent) and how many retained response bodies carry redaction
markers. A body that redaction emptied, because a declared secret could not
be cut out, is treated as not retained rather than replayed as an empty
reply.
- `environment-serve` and `environment-export` authenticate either parent
kind through the same loader, project scenario transactions through the same
adapter, and keep the cross-check that the manifest's parent is the supplied
+4 -1
View File
@@ -407,7 +407,10 @@ whenever it is nonzero the Evidence is inferred, otherwise derived. The view
performs no network activity and replays nothing. Native mitmproxy, SAZ and
qlog captures, and parents inspected with `sensitive_values`, return the same
typed unsupported errors as the api-surface view, including the canonical
token `sensitive_values_unretained`.
token `sensitive_values_unretained`. A `capture_browser_scenario` parent is not
refused for declaring secrets: the capture redacted them as it recorded, a
transaction whose URL was redacted is left unrepresented, and a response body
keeps the capture's redaction markers, which a manifest limitation counts.
The manifest accumulator shares the api-surface admission model: the capture
clone, the inline envelope, record pointers, groups, every route part, observed
@@ -69,6 +69,23 @@ describe("environment manifests from capture_browser_scenario parents", () => {
});
});
it("names the one view a scenario capture supports when another is requested", () => {
const refused = inspectAnalysisView({
source: {
kind: "inline",
evidence: scenarioCaptureEvidence(scenarioCaptureFixture()),
},
view: { kind: "summary" },
});
if (refused.ok) throw new Error("Expected the unsupported view refusal");
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "unsupported_target",
message: expect.stringContaining(
"supports only the environment-manifest view",
),
});
});
it("serves and exports a scenario-built manifest from the same capture", () => {
const source = loadEnvironmentSource(fixture.manifest, fixture.capture);
if (!source.ok) throw source.error;
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -815,7 +815,9 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
parent.operation === "capture_browser_scenario"
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest view.`
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import {
@@ -56,7 +58,7 @@ describe("scenarioCaptureRecords", () => {
request: { method: "GET", url: "http://127.0.0.1:3101/api/slow" },
});
expect(projected.limitations).toContain(
"1 of 8 scenario transactions retained no response body; their tools replay status and content type only.",
"1 of 8 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
);
});
@@ -73,9 +75,65 @@ describe("scenarioCaptureRecords", () => {
items: [...capture.events.items, { ...response, sequence: 99 }],
},
};
const projected = scenarioCaptureRecords(duplicated, "manifest");
expect(projected.records[0]?.reported).toEqual({});
// No manifest tool can cite it, so it is not counted among bodiless tools.
expect(
scenarioCaptureRecords(duplicated, "manifest").records[0]?.reported,
).toEqual({});
projected.limitations.some((limitation) =>
limitation.includes("retained no response body"),
),
).toBe(false);
});
it("replays a redacted body's markers, counts it, and treats a body redaction emptied as not retained", () => {
const capture = scenarioCaptureFixture(SCENARIO_EXCHANGES.slice(1, 3));
const redacted =
(transaction: string, bytes: Buffer) =>
(event: (typeof capture.events.items)[number]) =>
event.kind === "network-content" &&
event.phase === "response" &&
event.transaction_id === transaction &&
event.body.state === "captured"
? {
...event,
body: {
...event.body,
content: bytes.toString("base64"),
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
redacted: true,
},
}
: event;
const marker = Buffer.from('{"token":"[REDACTED:owner_token]"}');
const projected = scenarioCaptureRecords(
{
...capture,
events: {
...capture.events,
items: capture.events.items
.map(redacted("request-1", marker))
.map(redacted("request-2", Buffer.alloc(0))),
},
},
"manifest",
);
expect(projected.records[0]?.reported).toMatchObject({
response: { content: { text: marker.toString("base64") } },
});
expect(projected.records[1]?.reported).toMatchObject({
response: { status: 200, content: { mimeType: "application/json" } },
});
expect(projected.records[1]?.reported).not.toHaveProperty(
"response.content.text",
);
expect(projected.records[1]?.binary_fields).toEqual([]);
expect(projected.limitations).toEqual(
expect.arrayContaining([
"1 of 2 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.",
"1 of 2 scenario transactions retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.",
]),
);
});
});
@@ -96,6 +96,14 @@ const captured = (
): CapturedBody | undefined =>
content?.body.state === "captured" ? content.body : undefined;
/** A captured response body, unless redaction emptied it because a secret could not be cut out. */
const retainedBody = (
content: ContentEvent | undefined,
): CapturedBody | undefined => {
const body = captured(content);
return body?.redacted === true && body.bytes === 0 ? undefined : body;
};
/** Strictly decoded text, keeping a byte-order mark; undefined for bytes that are not UTF-8. */
const utf8Text = (body: CapturedBody): string | undefined => {
try {
@@ -134,7 +142,13 @@ const record = (
observed: TransactionIndex["requests"][number],
index: TransactionIndex,
purpose: "manifest" | "replay",
): { readonly record: WebNetworkCaptureRecord; readonly body: boolean } => {
): {
readonly record: WebNetworkCaptureRecord;
/** The record keeps a usable request line, so the manifest may cite it. */
readonly represented: boolean;
readonly body: boolean;
readonly redacted: boolean;
} => {
const { item, event: request } = observed;
const location = {
kind: "json-pointer" as const,
@@ -159,11 +173,13 @@ const record = (
)
return {
record: { ordinal, location, reported: {}, ...empty },
represented: false,
body: false,
redacted: false,
};
const requestContent = onlyContent(index, request.sequence);
const responseContent = onlyContent(index, response?.sequence);
const body = captured(responseContent);
const body = retainedBody(responseContent);
const mimeType = mediaType(responseContent) ?? "";
return {
record: {
@@ -198,7 +214,9 @@ const record = (
},
],
},
represented: !request.url.redacted,
body: body !== undefined,
redacted: body?.redacted === true,
};
};
@@ -210,10 +228,13 @@ export const scenarioCaptureRecords = (
const index = indexTransactions(capture.events.items);
const records: WebNetworkCaptureRecord[] = [];
let withoutBody = 0;
let redactedBodies = 0;
for (const [ordinal, observed] of index.requests.entries()) {
const projected = record(ordinal, observed, index, purpose);
records.push(projected.record);
if (!projected.represented) continue;
if (!projected.body) withoutBody += 1;
if (projected.redacted) redactedBodies += 1;
}
return {
records,
@@ -227,7 +248,12 @@ export const scenarioCaptureRecords = (
: []),
...(withoutBody > 0
? [
`${String(withoutBody)} of ${String(records.length)} scenario transactions retained no response body; their tools replay status and content type only.`,
`${String(withoutBody)} of ${String(records.length)} scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.`,
]
: []),
...(redactedBodies > 0
? [
`${String(redactedBodies)} of ${String(records.length)} scenario transactions retained a response body with declared secret values replaced by redaction markers; tools citing them replay the redacted bytes.`,
]
: []),
],