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 ArakelyanandClaude Opus 5.5 committed 2026-10-19 14:50:18 -07:00
1 parent d2416ca542
commit 655e623341
9 files changed
+144 -16

No files matched your search

+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;
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; a transaction whose URL was redacted is left unrepresented. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishesLine truncated
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recordeLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+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.`,
]
: []),
],