From f254f5ac2b804b353a711264bb178f43fdf07867 Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 10:21:53 +0800
Subject: [PATCH 001/159] feat(android): add opt-in package summaries and
retained manifest views
Add explicit package summary delivery and exact retained manifest pages while preserving complete MCP defaults, one-shot CLI output, provider requests, source identity and session lifetime. Return distinct linked Evidence and preserve partial/null facts; reject unavailable retention before engine work.
Validation: 27 relevant SDK/domain/Android boundary checks, focused test-only correction rerun, full supported compiler/lint and docs checks, real NewPipe SDK pairs, authentic old Evidence continuation, four actual Codex trials with failures/regressions retained, and all 39 exact-head CI checks. Package replies shrink 119,527 to 8,659 bytes; no model-token or time gain is established.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---
docs/android-analysis.md | 21 ++
experiments/analysis-efficiency/README.md | 5 +
.../android-package-summary.md | 166 ++++++++++
experiments/analysis-efficiency/measure.mjs | 44 ++-
.../android/AndroidPackageInspection.ts | 43 +++
src/contracts/analysisViewToolContracts.ts | 2 +-
src/contracts/android/androidToolContracts.ts | 16 +-
src/domain/analysisView/analysisView.ts | 30 +-
src/domain/analysisView/androidPackageView.ts | 136 ++++++++
.../analysisView/javascriptApplicationView.ts | 2 +-
src/domain/android/androidAnalysis.ts | 6 +
src/server/createServer.ts | 1 +
src/server/registerAndroidTools.ts | 21 +-
.../mcp/androidPackageSummaryMcp.test.ts | 295 ++++++++++++++++++
14 files changed, 777 insertions(+), 11 deletions(-)
create mode 100644 experiments/analysis-efficiency/android-package-summary.md
create mode 100644 src/application/android/AndroidPackageInspection.ts
create mode 100644 src/domain/analysisView/androidPackageView.ts
create mode 100644 tests/boundary/mcp/androidPackageSummaryMcp.test.ts
diff --git a/docs/android-analysis.md b/docs/android-analysis.md
index 5a4a2f879..d98d9b5da 100644
--- a/docs/android-analysis.md
+++ b/docs/android-analysis.md
@@ -9,6 +9,27 @@ This family is present on repository main and in npm 4.1.0. Check the
[released package boundary](installation.md#released-package-and-main) and the
connected server's tool list before selecting it.
+## Optional package delivery
+
+The MCP `inspect_android_package` request keeps complete output when `detail` is
+omitted or `"complete"`. Select `detail: "summary"` explicitly to receive all
+package, SDK and permission declarations, engine identity and class/resource
+counts without decoded manifest text or duplicate raw provider replies. A
+retaining session keeps the complete original Evidence and returns a distinct
+linked `inspect_analysis_view` summary. This changes delivery, not JADX analysis.
+
+Use the returned `normalized_result.parent_evidence_id` with
+`inspect_analysis_view`, source kind `retained-evidence`, and
+`view: {kind: "android", facet: "manifest", offset: 0, limit: 4096}` to read the
+exact decoded manifest without reopening the APK. Offsets and limits count UTF-16
+code units; follow `coverage.next_offset`. The page preserves `source_status` and
+`reported_total_bytes`: exhausting retained text does not make a partial decoded
+manifest complete. Complete source Evidence remains exportable under the existing
+session lifetime. Servers without retention reject summary before starting JADX.
+One-shot CLI output remains complete. Older servers require the existing complete
+request after schema discovery. APK authenticity and runtime behavior remain
+unverified.
+
## Supply tools
Use a **full JDK 17 or newer** (including its compiler module) and
diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md
index 79bac97e0..8afef3a66 100644
--- a/experiments/analysis-efficiency/README.md
+++ b/experiments/analysis-efficiency/README.md
@@ -37,6 +37,11 @@ raw MCP replies from observed client execution/discovery and usage. Optional
trial capture retains tool/usage items while excluding account and ambient state;
existing ephemeral trials keep their default behavior.
+Follow-up: [Android package delivery and retained manifest](android-package-summary.md)
+records real NewPipe SDK pairs, unchanged old requests/Evidence, exact retained
+text, and actual Codex outcomes including client errors and a failed discovery.
+Reply bytes fall; model-token/time gains are not established.
+
Follow-up: [resource counters at payload completion](resource-counters.md) adds
optional owned counter observations while preserving unknown whole-scope final
counters, default receipt shape and the unchanged CPU/memory safeguards.
diff --git a/experiments/analysis-efficiency/android-package-summary.md b/experiments/analysis-efficiency/android-package-summary.md
new file mode 100644
index 000000000..448202d87
--- /dev/null
+++ b/experiments/analysis-efficiency/android-package-summary.md
@@ -0,0 +1,166 @@
+# Android package summary and exact retained manifest
+
+Continues [#2016](https://github.com/morluto/rea/issues/2016) and
+[#2438](https://github.com/morluto/rea/pull/2438). The recurring NewPipe question
+asks for APK/package/version identity, all case-sensitive MainActivity matches
+and all reported onCreate entries, including explicit null DEX descriptors.
+Complete package replies delivered decoded manifest text and duplicate upstream
+calls unrelated to those selected facts.
+
+## Compatibility and ownership
+
+MCP `inspect_android_package` accepts explicit `detail: "summary"`; omitted and
+`"complete"` requests preserve the complete output and provider request. The
+one-shot CLI stays complete. Summary selection happens in the application
+workflow, independently of the engine protocol, using the existing retained
+analysis-view machinery. No third-party plugin API or persistent analysis cache
+is introduced. Servers lacking retention reject summary before provider work.
+
+The complete original Evidence stays under its original ID and session lifetime.
+A distinct linked derived view returns all package/SDK/permission declarations,
+engine identity, class/resource counts and signature-verification status.
+Manifest metadata retains the producer's partial/complete status, nullable
+reported byte total, and observed UTF-16 length. Text and duplicate raw calls
+remain in the original record, rather than being rewritten under its ID.
+
+Use `inspect_analysis_view` with the parent's ID and
+`view: {kind: "android", facet: "manifest", offset: 0, limit: 4096}` to read exact
+retained decoded text. Follow `coverage.next_offset`; surrogate boundaries use
+the existing text-page implementation. Exhaustion describes the retained text,
+not global completeness when the producer says partial. APK signatures and
+runtime behavior remain unverified. Engine artifact SHA-256 identifies the JAR;
+views require the authenticated APK subject and never substitute the engine
+hash for a missing target identity. Older servers require schema discovery and
+the original complete request; automatic fallback is not introduced.
+
+## Frozen runtime and real SDK pairs
+
+Source runtime: `28c84e78cf2fbfde4aceed345b773c59eb889f68`; later test-only schema
+typing corrections do not relabel these receipts. Compiled dist/bridge file-table
+SHA-256: `5332dda13981f043e146b3601ebec1f380d9f698280ca53d75786b638a912e11`.
+The frozen old runtime is `8bc1601ca51e417ca472d90ea0367e9418a1a0f2`, digest
+`9e7380f537301bf0cfe2926f5619011af848af7c220750e75189498e65d825e1`.
+
+Real NewPipe 0.29.1 bytes, acquisition and digest are pinned in `corpus.json`.
+Actual configured JADX headless MCP 0.7.1/Java runs use the same selected
+provider settings and search-path digests, one worker and 768 MiB Java heap.
+Node is 24.18.0; actual SDK client/server are 2.3.1. Processes and engines are
+fresh per trial; host page caches are not flushed. SDK schema mode is selected
+in both treatments. Heavy runs are serial, with one CPU, unchanged 3 GiB early
+stop/4 GiB hard memory, no swap, and 192 tasks.
+
+All four candidate workflows use three successful public MCP calls. Only the
+first package delivery changes. Both pairs pass settings and selected-fact
+comparison, including all declarations, manifest status/length, engine identity,
+original limitations, matching class rows and onCreate entries. Original replies
+and IDs remain separate. A fresh old-runtime complete workflow also passes
+comparison against the candidate complete request; both complete package replies
+are exactly 119,527 compact client-result JSON bytes. This is scoped compatibility
+evidence, not a guarantee for every historical release/client/host.
+
+| Trial | Package reply bytes | SDK workflow seconds | Payload CPU seconds | Kernel peak bytes |
+| -------------- | ------------------: | -------------------: | ------------------: | ----------------: |
+| sdk-complete-1 | 119,527 | 24.059 | 25.259 | 1,225,076,736 |
+| sdk-summary-1 | 8,659 | 23.694 | 24.894 | 1,226,870,784 |
+| sdk-summary-2 | 8,659 | 24.407 | 25.611 | 1,228,296,192 |
+| sdk-complete-2 | 119,527 | 24.509 | 25.740 | 1,229,844,480 |
+
+Summary replies are 8,659 bytes, about 92.8% smaller; all three replies together
+fall from 149,577 to 38,709 bytes. This is a delivery result, not a model-token
+or engine-work saving. The SDK timing differences are small. CPU and memory do
+not establish a reduction. No provider setting or analysis scope changes.
+Transport roundtrip includes server/client work and is not an engine-only timer.
+
+The complete catalog is 2,506,213 compact JSON bytes versus 2,497,635 on the old
+runtime: about 0.34% growth, including intervening main changes. Discovery cost
+remains visible alongside selected-delivery savings.
+
+## Authentic old Evidence continuation
+
+A real package record from old runtime 8bc160 imports into the candidate and
+supports retained summary, exact manifest pages, inline portable summary and
+export through actual public SDK tools. The new session explicitly selects an
+absent JADX JAR; this workflow performs no package analysis. Eleven successful
+calls preserve the original record, original ID, all declarations and all 25,222
+manifest UTF-16 units. Manifest UTF-8 SHA-256:
+`fee192fe2a6d7892c22cba115184c74c7e2c42ce464e8bb37be6289f2c16881d`.
+The exported original record is deeply equal to the imported record.
+
+## Actual Codex task outcomes
+
+Four serial trials run in complete/summary/summary/complete order on the same
+frozen candidate. Requested configuration: Codex CLI 0.161.0, `gpt-5.6-terra` high,
+three exact selected tool names and the same optional context/discovery capture
+settings. The requested alias does not certify the server-resolved model. The
+original analyst question is unchanged; equal treatment instructions explicitly
+choose package detail and explain the summary carrier. Temporary configuration
+points to the frozen modified REA MCP; no global configuration is edited.
+
+| Trial | Input | Cached | Uncached | Output | Seconds | MCP calls | Usage records | Facts |
+| ---------------- | ------: | -----: | -------: | -----: | ------: | --------: | ------------: | ----- |
+| model-complete-1 | 39,267 | 23,040 | 16,227 | 1,690 | 94.053 | 3 | 3 | Pass |
+| model-summary-1 | 128,417 | 93,440 | 34,977 | 3,633 | 150.401 | 8 | 9 | Pass |
+| model-summary-2 | 54,437 | 37,120 | 17,317 | 2,185 | 123.677 | 3 | 4 | Pass |
+| model-complete-2 | 21,332 | 9,984 | 11,348 | 667 | 20.774 | 0 | 2 | Fail |
+
+Three answers pass the original selected-fact gate against the actual SDK
+producer. Each trial preserves APK bytes and removes its isolated profile.
+The first summary trial makes five successful calls and three rejected requests:
+an invented `case_sensitive` argument, empty class-search input and empty
+class-inspection input. It then repeats successful package/search work. These
+errors and correction turns remain part of measured task cost; strict existing
+schemas correctly reject them. The second summary trial uses the intended three
+successful calls, but still does not establish improved total/uncached input or
+latency against the factually acceptable first complete control.
+
+The final complete control's first client executor filters `ALL_TOOLS` for the
+same three exact names and receives an empty array. It makes zero MCP calls,
+reports all requested facts unknown and fails the factual gate. Identical name
+filters worked in the other trials. Stderr contains no explanatory error; the
+client visibility failure remains unexplained and is not attributed to REA or
+summary delivery. Its short duration is not comparable completed work. The
+original failed receipt is retained rather than replaced by a successful trial.
+
+These measurements demonstrate no real-model token or speed gain. Smaller raw
+MCP envelopes do not establish smaller model context; observed usage also
+includes discovery, execution context, corrections and extra responses. Usage
+records count captured usage items rather than asserting exact remote request
+contents or equating MCP calls with model rounds. The complete default and
+opt-in choice remain unchanged; this is useful delivery/retention capability,
+not a general performance recommendation.
+
+All model runs have verified payload-completion witnesses with only the observer
+remaining and zero memory/task limit events at that phase. Kernel peaks are
+602,472,448–1,444,945,920 bytes; the failed no-analysis control is included in that
+range and is not evidence of lower analysis memory. Whole-scope final counters
+remain unknown and the existing full resource grader stays false. No broader
+CPU, memory, OS/provider or model claim is made.
+
+## Resource interpretation
+
+Every SDK run and portable continuation has a verified owned
+payload-exited-observer-alive witness, with only the observer remaining and no
+memory/task limit events at that phase. Kernel peak and CPU include that opt-in
+observer. Whole-scope terminal counters remain unknown; the existing complete
+physical resource grader is not changed or called successful. No OOM stress or
+resource-limit increase is used.
+
+## Verification and scope
+
+Twenty-seven relevant converted-schema, SDK, domain and existing Android
+failure/cancellation/cleanup checks pass. A three-case focused rerun passes after
+a test-only schema typing correction. The 1 MiB manifest protocol fixture verifies
+original default/full output, reconstruction after source removal, unchanged
+exported Evidence and one engine launch. Other cases preserve nulls and partial
+status, Unicode boundaries and tagged malformed/missing-identity failures, and
+reject unavailable retention or cancelled requests before provider work. These
+fixtures verify production boundaries; actual JADX claims use the real NewPipe
+runs above. Full supported single-thread compiler and unchanged lint pass under
+the same memory safeguards. No other Android OS/device/provider lane is inferred.
+
+Raw SDK/model replies, private profiles and resource receipts stay outside Git.
+Reproduce SDK pairs using the existing `measure.mjs` with `--artifact newpipe.apk`,
+`--schemas selected`, identical explicit provider configuration and frozen runtime,
+and alternating `--delivery complete` / `--delivery summary`. Preserve failed
+attempts and run `compare.mjs` on both pair orders. Model findings require their
+own identical analyst facts and explicit delivery controls.
diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs
index 3e34d935e..bfac3adb6 100644
--- a/experiments/analysis-efficiency/measure.mjs
+++ b/experiments/analysis-efficiency/measure.mjs
@@ -666,7 +666,40 @@ const workflows = {
}
},
"newpipe.apk": async () => {
- const pkg = await call("inspect_android_package", { path: values.path });
+ const packageInput = {
+ path: values.path,
+ ...(values.delivery === "summary" ? { detail: "summary" } : {}),
+ };
+ const definition = catalogDefinitions.get("inspect_android_package");
+ if (
+ definition === undefined ||
+ !new Ajv2020({ strict: false, validateFormats: false }).validate(
+ definition.inputSchema,
+ packageInput,
+ )
+ )
+ throw new Error(
+ "Selected Android delivery is not advertised by this runtime",
+ );
+ const pkg = await call("inspect_android_package", packageInput);
+ const result = normalized(pkg);
+ const report = values.delivery === "summary" ? result.summary : result;
+ if (
+ values.delivery === "summary" &&
+ (result.kind !== "summary" ||
+ result.parent_operation !== "inspect_android_package")
+ )
+ throw new Error("Runtime returned no retained Android package summary");
+ const { manifest, ...declarations } = report;
+ const manifestObservation =
+ values.delivery === "summary"
+ ? manifest
+ : {
+ status: manifest.status,
+ reported_total_bytes: manifest.reported_total_bytes,
+ text_code_units: manifest.text.length,
+ text_included: false,
+ };
const found = await call("search_android_classes", {
path: values.path,
query: "MainActivity",
@@ -677,9 +710,12 @@ const workflows = {
});
return {
artifact_sha256: targetSha(pkg),
- package_name: normalized(pkg).package_name,
- version_name: normalized(pkg).version_name,
- version_code: normalized(pkg).version_code,
+ package_name: report.package_name,
+ version_name: report.version_name,
+ version_code: report.version_code,
+ package_declarations: declarations,
+ manifest_observation: manifestObservation,
+ package_limitations: pkg.limitations,
matches: normalized(found).matches,
coverage: normalized(found).coverage,
onCreate: normalized(cls).methods.filter(
diff --git a/src/application/android/AndroidPackageInspection.ts b/src/application/android/AndroidPackageInspection.ts
new file mode 100644
index 000000000..3fbaa2f1b
--- /dev/null
+++ b/src/application/android/AndroidPackageInspection.ts
@@ -0,0 +1,43 @@
+import type { ExecutionOptions } from "../AnalysisProvider.js";
+import type { AndroidAnalysisService } from "./AndroidAnalysisService.js";
+import {
+ requireAnalysisViewRetention,
+ summarizeRetainedAnalysis,
+ type AnalysisViewRetention,
+} from "../analysisView/AnalysisViewService.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
+import { androidPackageInspectionInputSchema } from "../../domain/android/androidAnalysis.js";
+import type { Evidence } from "../../domain/evidence.js";
+import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
+import { err, type Result } from "../../domain/result.js";
+
+/** Preserve the complete provider request; select retained delivery explicitly. */
+export const inspectAndroidPackage = async (
+ service: AndroidAnalysisService,
+ rawInput: unknown,
+ retention: AnalysisViewRetention,
+ options?: ExecutionOptions,
+): Promise> => {
+ const operation = "inspect_android_package";
+ if (options?.signal?.aborted === true)
+ return err(new AnalysisCancelledError(operation));
+ const parsed = androidPackageInspectionInputSchema.safeParse(rawInput);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, {
+ cause: parsed.error,
+ }),
+ );
+ if (parsed.data.detail === "summary") {
+ const ready = requireAnalysisViewRetention(operation, retention);
+ if (!ready.ok) return ready;
+ }
+ const executed = await service.execute(
+ operation,
+ { path: parsed.data.path },
+ options,
+ );
+ if (!executed.ok || parsed.data.detail !== "summary") return executed;
+ return summarizeRetainedAnalysis(executed.value, retention);
+};
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index 682c49e6b..a4004e85b 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -16,7 +16,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_evm_interface, inspect_managed_artifact or inspect_web_network_capture Evidence without repeating analysis. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM and managed artifact 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
+ 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package or inspect_web_network_capture Evidence without repeating analysis. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts
index df2cccf59..b555fcbea 100644
--- a/src/contracts/android/androidToolContracts.ts
+++ b/src/contracts/android/androidToolContracts.ts
@@ -1,7 +1,10 @@
+import { z } from "zod";
import {
androidInputSchemas,
+ androidPackageInspectionInputSchema,
androidResultSchemas,
} from "../../domain/android/androidAnalysis.js";
+import { androidPackageSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import type { ToolContract } from "../toolContractTypes.js";
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "../toolEffects.js";
@@ -12,14 +15,21 @@ export const ANDROID_TOOL_CONTRACTS = [
name: "inspect_android_package",
...toolContractMetadata("inspect_android_package"),
description:
- "Inspect a local APK's package, SDK and permission declarations, decoded manifest and class/resource counts. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.",
+ 'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts. Omitted or complete detail preserves the complete result. Select detail: "summary" for all declarations, engine identity and counts without decoded manifest text or duplicate raw calls; retains the complete source and returns a distinct linked inspect_analysis_view summary. Read the exact retained manifest with view kind android, facet manifest, offset and limit (UTF-16 code units), using normalized_result.parent_evidence_id. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.',
kind: "android-provider",
- inputSchema: androidInputSchemas.inspect_android_package,
+ inputSchema: androidPackageInspectionInputSchema,
outputSchema: evidenceResultOf(
- androidResultSchemas.inspect_android_package,
+ z.union([
+ androidResultSchemas.inspect_android_package,
+ androidPackageSummaryViewResultSchema,
+ ]),
),
examples: [
{ title: "Inspect an APK", input: { path: "/tmp/Example.apk" } },
+ {
+ title: "Inspect APK declarations with retained manifest",
+ input: { path: "/tmp/Example.apk", detail: "summary" },
+ },
],
},
{
diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts
index 4ab9c77da..784d5dbed 100644
--- a/src/domain/analysisView/analysisView.ts
+++ b/src/domain/analysisView/analysisView.ts
@@ -56,6 +56,12 @@ import {
wasmSummarySchema,
} from "./wasmArtifactView.js";
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
+import {
+ projectAndroidPackageView,
+ androidPackageSummarySchema,
+ androidViewRequestSchema,
+ androidManifestViewItemSchema,
+} from "./androidPackageView.js";
/** Public name of the selected-view workflow. */
export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const;
@@ -169,6 +175,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
wasmViewRequestSchema,
evmViewRequestSchema,
managedViewRequestSchema,
+ androidViewRequestSchema,
apiSurfaceViewSchema,
]);
@@ -283,6 +290,7 @@ const viewResultBase = {
"inspect_wasm_artifact",
"inspect_evm_interface",
"inspect_managed_artifact",
+ "inspect_android_package",
]),
parent_digest: digestSchema,
view_digest: digestSchema,
@@ -340,6 +348,13 @@ export const managedArtifactSummaryViewResultSchema = z.strictObject({
summary: managedSummarySchema,
});
+/** Exact package declarations with retained decoded manifest text. */
+export const androidPackageSummaryViewResultSchema = z.strictObject({
+ ...summaryViewResultBase,
+ parent_operation: z.literal("inspect_android_package"),
+ summary: androidPackageSummarySchema,
+});
+
/** Projected facts for one selected view of retained analysis Evidence. */
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
z.strictObject({
@@ -362,6 +377,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
wasmSummarySchema,
evmSummarySchema,
managedSummarySchema,
+ androidPackageSummarySchema,
]),
}),
z.strictObject({
@@ -410,6 +426,13 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...managedViewContextSchema.shape,
parent_operation: z.literal("inspect_managed_artifact"),
}),
+ z.strictObject({
+ kind: z.literal("android"),
+ view: androidViewRequestSchema,
+ item: androidManifestViewItemSchema,
+ ...viewResultBase,
+ parent_operation: z.literal("inspect_android_package"),
+ }),
z.strictObject({
kind: z.literal("inventory"),
view: inventoryViewSchema,
@@ -480,6 +503,10 @@ export type UnsignedAnalysisView =
| Omit<
Extract,
"view_digest"
+ >
+ | Omit<
+ Extract,
+ "view_digest"
>;
/** Attach the content digest that must change when projected bytes change. */
@@ -525,7 +552,7 @@ 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, EVM, managed artifact, or web network capture Evidence.`,
+ `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, Android package, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -554,6 +581,7 @@ const retainedViewProjectors = new Map([
["inspect_wasm_artifact", projectWasmArtifactView],
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
+ ["inspect_android_package", projectAndroidPackageView],
["inspect_artifact", projectArtifactInventoryView],
["inventory_artifact", projectArtifactInventoryView],
]);
diff --git a/src/domain/analysisView/androidPackageView.ts b/src/domain/analysisView/androidPackageView.ts
new file mode 100644
index 000000000..caf948c10
--- /dev/null
+++ b/src/domain/analysisView/androidPackageView.ts
@@ -0,0 +1,136 @@
+import { z } from "zod";
+import type { AnalysisError } from "../analysisErrorBase.js";
+import { androidResultSchemas } from "../android/androidAnalysis.js";
+import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
+import { err, ok, type Result } from "../result.js";
+import {
+ analysisViewInputError,
+ type AnalysisViewParent,
+ type AnalysisViewRequest,
+ type UnsignedAnalysisView,
+} from "./analysisView.js";
+import { completeWithinViewCoverage } from "./analysisViewCoverage.js";
+import { analysisTextPage } from "./analysisViewText.js";
+
+const packageSchema = androidResultSchemas.inspect_android_package;
+
+/** All package declarations remain inline; decoded manifest text stays retained. */
+export const androidPackageSummarySchema = packageSchema.extend({
+ manifest: packageSchema.shape.manifest.omit({ text: true }).extend({
+ text_code_units: z.number().int().nonnegative(),
+ text_included: z.literal(false),
+ }),
+});
+
+/** Exact decoded text; offsets and limits count UTF-16 code units. */
+export const androidViewRequestSchema = z.strictObject({
+ kind: z.literal("android"),
+ facet: z.literal("manifest"),
+ offset: z.number().int().nonnegative().default(0),
+ limit: z.number().int().positive(),
+});
+
+export const androidManifestViewItemSchema = z.strictObject({
+ text: z.string(),
+ unit: z.literal("utf16-code-units"),
+ source_status: packageSchema.shape.manifest.shape.status,
+ reported_total_bytes: packageSchema.shape.manifest.shape.reported_total_bytes,
+});
+
+/** Project an authenticated package record without reopening the APK or engine. */
+export const projectAndroidPackageView = (
+ parent: AnalysisViewParent,
+ view: AnalysisViewRequest,
+): Result => {
+ if (view.kind !== "summary" && view.kind !== "android")
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view"],
+ reason: "invalid_value",
+ message:
+ "Android package Evidence supports summary and manifest views.",
+ },
+ ]),
+ );
+ const parsed = packageSchema.safeParse(parent.normalizedResult);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(
+ "inspect_analysis_view",
+ parsed.error.issues.map((issue) => ({
+ ...issue,
+ path: ["source", "normalized_result", ...issue.path],
+ })),
+ { source: { normalized_result: parent.normalizedResult } },
+ { cause: parsed.error },
+ ),
+ );
+ // Engine artifact_sha256 identifies the JAR, never the APK. A missing
+ // authenticated APK subject cannot be repaired using the engine identity.
+ if (parent.subject == null)
+ return err(
+ analysisViewInputError([
+ {
+ path: ["source", "subject"],
+ reason: "invalid_value",
+ message:
+ "Android package views require the recorded APK subject identity.",
+ },
+ ]),
+ );
+ const report = parsed.data;
+ const shared = {
+ parent_evidence_id: parent.evidenceId,
+ parent_operation: "inspect_android_package" as const,
+ parent_digest: parent.evidenceId.slice(3),
+ artifact: {
+ path: parent.subject.local_path,
+ sha256: parent.subject.digest.sha256,
+ },
+ limitations: [...parent.limitations],
+ unknowns: [
+ "APK signatures were not verified; package declarations do not establish authenticity or runtime behavior.",
+ ...(report.manifest.status === "partial"
+ ? [
+ "The decoded manifest is partial; exhausting retained text does not establish complete manifest coverage.",
+ ]
+ : []),
+ ],
+ };
+ if (view.kind === "summary") {
+ const { text, ...manifest } = report.manifest;
+ return ok({
+ kind: "summary",
+ view,
+ ...shared,
+ summary: {
+ ...report,
+ manifest: {
+ ...manifest,
+ text_code_units: text.length,
+ text_included: false,
+ },
+ },
+ coverage: completeWithinViewCoverage(1, 1),
+ });
+ }
+ const page = analysisTextPage(
+ report.manifest.text,
+ view.offset,
+ view.limit,
+ "Manifest",
+ );
+ if (!page.ok) return page;
+ return ok({
+ kind: "android",
+ view,
+ ...shared,
+ item: {
+ ...page.value.item,
+ source_status: report.manifest.status,
+ reported_total_bytes: report.manifest.reported_total_bytes,
+ },
+ coverage: page.value.coverage,
+ });
+};
diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts
index 5e5f9bb88..f4c09a68d 100644
--- a/src/domain/analysisView/javascriptApplicationView.ts
+++ b/src/domain/analysisView/javascriptApplicationView.ts
@@ -314,7 +314,7 @@ export const projectJavaScriptApplicationView = (
...shared,
});
}
- if (view.collection !== "modules")
+ if (view.kind !== "page" || view.collection !== "modules")
return err(
jsIncompatible(
"Section and symbol collections apply to inspect_binary_layout Evidence.",
diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts
index e27be2b20..ca4d6db59 100644
--- a/src/domain/android/androidAnalysis.ts
+++ b/src/domain/android/androidAnalysis.ts
@@ -45,6 +45,12 @@ export const androidInputSchemas = {
}),
} as const;
+/** Optional retained delivery is separate from the engine's APK request. */
+export const androidPackageInspectionInputSchema =
+ androidInputSchemas.inspect_android_package.extend({
+ detail: z.enum(["complete", "summary"]).optional(),
+ });
+
/** Supported static Android operations. */
export type AndroidOperation = keyof typeof androidInputSchemas;
/** Validated Android requests carry their selected operation. */
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index 12ad6418b..e445dcfab 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -515,6 +515,7 @@ const registerConfiguredAnalysisTools = (
toolLogger,
recordEvidence,
withAdmittedAnalysis,
+ evidenceById,
);
registerJebTools(
server,
diff --git a/src/server/registerAndroidTools.ts b/src/server/registerAndroidTools.ts
index 722bd6695..a8c29a187 100644
--- a/src/server/registerAndroidTools.ts
+++ b/src/server/registerAndroidTools.ts
@@ -1,6 +1,8 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
+import { inspectAndroidPackage } from "../application/android/AndroidPackageInspection.js";
+import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import { summarizeRetainedAndroidSearch } from "../application/android/AndroidSearchEvidenceSummary.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { Logger } from "pino";
@@ -18,6 +20,7 @@ export const registerAndroidTools = (
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
+ evidenceById?: EvidenceLookup,
): void => {
const handler = createContractToolHandler(
server,
@@ -44,10 +47,26 @@ export const registerAndroidTools = (
withAdmittedAnalysis,
);
const packageContract = toolContract("inspect_android_package");
+ const packageService: ContractToolService<"inspect_android_package"> = {
+ execute: (_operation, input, options) =>
+ inspectAndroidPackage(
+ service,
+ input,
+ { recordEvidence, evidenceById },
+ options,
+ ),
+ };
+ const packageHandler = createContractToolHandler(
+ server,
+ packageService,
+ logger,
+ recordEvidence,
+ withAdmittedAnalysis,
+ );
server.registerTool(
packageContract.name,
toolRegistrationOptions(packageContract),
- handler(packageContract),
+ packageHandler(packageContract),
);
const searchContract = toolContract("search_android_classes");
server.registerTool(
diff --git a/tests/boundary/mcp/androidPackageSummaryMcp.test.ts b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts
new file mode 100644
index 000000000..960886b02
--- /dev/null
+++ b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts
@@ -0,0 +1,295 @@
+import { unlink } from "node:fs/promises";
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { expect, it, onTestFinished, vi } from "vitest";
+import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
+import { inspectAndroidPackage } from "../../../src/application/android/AndroidPackageInspection.js";
+import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
+import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
+import {
+ androidPackageSummaryViewResultSchema,
+ analysisViewResultSchema,
+} from "../../../src/domain/analysisView/analysisView.js";
+import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
+import type { JsonValue } from "../../../src/domain/jsonValue.js";
+import { createServer } from "../../../src/server/createServer.js";
+import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
+import {
+ createJadxProtocolFixture,
+ verifyJadxFixtureCleanup,
+} from "../../fixtures/android/jadx.js";
+
+it.skipIf(process.platform === "win32")(
+ "retains complete APK Evidence and reads exact manifest without its source or another engine launch",
+ async () => {
+ const { provider, apk, launches } =
+ await createJadxProtocolFixture("large-manifest");
+ const harness = await createApplicationMcpHarness({
+ serverOptions: { androidAnalysis: provider },
+ });
+ onTestFinished(harness.close);
+ const { client, session } = harness;
+ const catalog = (await client.listTools()).tools;
+ const definition = catalog.find(
+ (tool) => tool.name === "inspect_android_package",
+ );
+ if (definition?.outputSchema === undefined)
+ throw new Error("Missing APK schema");
+ const outputSchema: Record = definition.outputSchema;
+ const ajv = new Ajv2020({ strict: false, validateFormats: false });
+ expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
+ expect(ajv.validateSchema(outputSchema)).toBe(true);
+ expect(
+ ajv.validate(definition.inputSchema, { path: apk, detail: "summary" }),
+ ).toBe(true);
+ expect(
+ ajv.validate(
+ catalog.find((tool) => tool.name === "inspect_android_class")!
+ .inputSchema,
+ {
+ path: apk,
+ class_name: "fixture.Target",
+ detail: "summary",
+ },
+ ),
+ ).toBe(false);
+ const reply = await client.callTool({
+ name: "inspect_android_package",
+ arguments: { path: apk, detail: "summary" },
+ });
+ expect(reply.isError).not.toBe(true);
+ expect(
+ ajv.validate(outputSchema, reply.structuredContent),
+ JSON.stringify(ajv.errors),
+ ).toBe(true);
+ const receipt = parseEvidence(reply.structuredContent);
+ const view = androidPackageSummaryViewResultSchema.parse(
+ receipt.normalized_result,
+ );
+ const parent = session.evidenceById(view.parent_evidence_id);
+ if (parent === undefined)
+ throw new Error("Complete package Evidence not retained");
+ const report = androidResultSchemas.inspect_android_package.parse(
+ parent.normalized_result,
+ );
+ expect(parent.operation).toBe("inspect_android_package");
+ expect(parent.parameters).toEqual({ path: apk });
+ expect(view.artifact.sha256).toBe(parent.subject?.digest.sha256);
+ expect(view.artifact.sha256).not.toBe(report.engine.artifact_sha256);
+ expect(receipt.evidence_id).not.toBe(parent.evidence_id);
+ expect(receipt.evidence_links).toEqual([parent.evidence_id]);
+ const { manifest, ...declarations } = report;
+ expect(view.summary).toMatchObject(declarations);
+ expect(view.summary.manifest).toEqual({
+ status: manifest.status,
+ reported_total_bytes: manifest.reported_total_bytes,
+ text_code_units: manifest.text.length,
+ text_included: false,
+ });
+ expect(receipt.raw_result).toBeNull();
+ expect(parent.raw_result).not.toBeNull();
+ for (const detail of [undefined, "complete"]) {
+ const full = parseEvidence(
+ (
+ await client.callTool({
+ name: "inspect_android_package",
+ arguments: {
+ path: apk,
+ ...(detail === undefined ? {} : { detail }),
+ },
+ })
+ ).structuredContent,
+ );
+ expect(full.operation).toBe("inspect_android_package");
+ expect(full.normalized_result).toEqual(parent.normalized_result);
+ expect(full.parameters).toEqual(parent.parameters);
+ expect(full.raw_result).not.toBeNull();
+ }
+ const before = JSON.stringify(parent);
+ await unlink(apk);
+ let text = "";
+ let offset = 0;
+ do {
+ const paged = await client.callTool({
+ name: "inspect_analysis_view",
+ arguments: {
+ source: {
+ kind: "retained-evidence",
+ evidence_id: parent.evidence_id,
+ },
+ view: { kind: "android", facet: "manifest", offset, limit: 700000 },
+ },
+ });
+ expect(paged.isError).not.toBe(true);
+ const page = analysisViewResultSchema.parse(
+ parseEvidence(paged.structuredContent).normalized_result,
+ );
+ if (page.kind !== "android") throw new Error("Unexpected manifest page");
+ text += page.item.text;
+ expect(page.item.source_status).toBe(manifest.status);
+ expect(page.item.reported_total_bytes).toBe(
+ manifest.reported_total_bytes,
+ );
+ offset = page.coverage.next_offset ?? manifest.text.length;
+ } while (offset < manifest.text.length);
+ expect(text).toBe(manifest.text);
+ const portable = inspectAnalysisView({
+ source: { kind: "inline", evidence: parseEvidence(JSON.parse(before)) },
+ view: { kind: "summary" },
+ });
+ if (!portable.ok) throw portable.error;
+ expect(portable.value.normalized_result).toEqual(view);
+ expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe(
+ before,
+ );
+ expect(
+ session
+ .exportEvidenceBundle()
+ .records.find((record) => record.evidence_id === parent.evidence_id),
+ ).toEqual(parent);
+ expect(launches).toHaveLength(1);
+ await harness.close();
+ await verifyJadxFixtureCleanup(launches);
+ },
+ 30000,
+);
+
+it("preserves partial/unknown declarations and Unicode text while rejecting missing APK identity and malformed source", () => {
+ const report = {
+ engine: {
+ name: "jadx",
+ version: null,
+ artifact_sha256: "b".repeat(64),
+ source_revision: null,
+ worker_count: 1,
+ heap_limit_mib: null,
+ },
+ package_name: null,
+ version_name: null,
+ version_code: null,
+ min_sdk: null,
+ target_sdk: null,
+ permissions: [],
+ manifest: { status: "partial", text: "a😀b", reported_total_bytes: null },
+ class_count: 0,
+ resource_count: 0,
+ signature_verification: "not_performed",
+ };
+ const evidence = (result: JsonValue = report, hasSubject = true) =>
+ createEvidence(
+ hasSubject
+ ? { path: "/original.apk", sha256: "a".repeat(64), format: "apk" }
+ : undefined,
+ { id: "jadx", name: "JADX", version: "0.7.1" },
+ {
+ operation: "inspect_android_package",
+ parameters: {},
+ result,
+ limitations: ["Original limit"],
+ },
+ );
+ const parent = evidence();
+ const source = { kind: "inline", evidence: parent };
+ const summary = inspectAnalysisView({ source, view: { kind: "summary" } });
+ if (!summary.ok) throw summary.error;
+ const overview = androidPackageSummaryViewResultSchema.parse(
+ summary.value.normalized_result,
+ );
+ expect(overview.summary.package_name).toBeNull();
+ expect(overview.summary.manifest.status).toBe("partial");
+ expect(overview.limitations).toEqual(parent.limitations);
+ const page = inspectAnalysisView({
+ source,
+ view: { kind: "android", facet: "manifest", offset: 0, limit: 4 },
+ });
+ if (!page.ok) throw page.error;
+ const result = analysisViewResultSchema.parse(page.value.normalized_result);
+ expect(result).toMatchObject({
+ kind: "android",
+ item: {
+ text: "a😀b",
+ source_status: "partial",
+ reported_total_bytes: null,
+ },
+ coverage: { exhausted: true },
+ });
+ expect(result.unknowns.join(" ")).toContain("partial");
+ for (const offset of [2])
+ expect(
+ inspectAnalysisView({
+ source,
+ view: { kind: "android", facet: "manifest", offset, limit: 2 },
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+ for (const invalid of [
+ evidence(report, false),
+ evidence({ ...report, class_count: -1 }),
+ ])
+ expect(
+ inspectAnalysisView({
+ source: { kind: "inline", evidence: invalid },
+ view: { kind: "summary" },
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+});
+
+it("rejects unavailable retention, invalid selectors and cancellation before provider execution", async () => {
+ const execute = vi.fn();
+ const provider = {
+ execute,
+ close: async () => {},
+ inspectAvailability: vi.fn(),
+ };
+ const service = new AndroidAnalysisService(provider);
+ const retention = { recordEvidence: undefined, evidenceById: undefined };
+ expect(
+ await inspectAndroidPackage(
+ service,
+ { path: "/missing.apk", detail: "summary" },
+ retention,
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisCapabilityUnavailableError" },
+ });
+ expect(
+ await inspectAndroidPackage(
+ service,
+ { path: "/missing.apk", detail: "other" },
+ retention,
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+ expect(
+ await inspectAndroidPackage(
+ service,
+ { path: "/missing.apk", detail: "summary" },
+ retention,
+ { signal: AbortSignal.abort() },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+ expect(execute).not.toHaveBeenCalled();
+ const session = createTestBinarySession(() => {
+ throw new Error("Unexpected deep provider");
+ });
+ const server = createServer(
+ { kind: "fixed", analysis: session },
+ { androidAnalysis: provider },
+ );
+ const client = new Client({ name: "android-fixed-summary", version: "1" });
+ onTestFinished(async () => {
+ await client.close();
+ await server.close();
+ await session.close();
+ });
+ const [a, b] = InMemoryTransport.createLinkedPair();
+ await server.connect(b);
+ await client.connect(a);
+ const reply = await client.callTool({
+ name: "inspect_android_package",
+ arguments: { path: "/missing.apk", detail: "summary" },
+ });
+ expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable");
+ expect(execute).not.toHaveBeenCalled();
+});
From a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 Mon Sep 17 00:00:00 2001
From: Ilian Iliev
Date: Tue, 13 Oct 2026 05:51:14 +0300
Subject: [PATCH 002/159] feat(fiddler): inspect native SAZ captures
Inspect bounded recorded raw HTTP/XML SAZ archives and page retained capture Evidence. Preserve exact stored bytes and provenance, validate ZIP/XML integrity, and require an enforceable decoder memory bound.
Verified 356 real recorded sessions against independent byte and metadata oracles, public CLI/MCP parity, retained pages, and related interface checks. Current Fiddler Everywhere GUI and non-Linux producer workflows remain unverified.
Co-authored-by: zahhak <1792039+zahhak@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
---
.../skills/reverse-engineer-anything/SKILL.md | 8 +-
.../references/runtime-observation.md | 15 +
README.md | 2 +-
docs/.vitepress/config.ts | 4 +
docs/browser-observation.md | 4 +
docs/client-configuration.md | 6 +
docs/packet-captures.md | 2 +-
docs/web-network-captures.md | 113 +++-
scripts/lib/catalog-builders.mjs | 5 +
src/application/WebNetworkCaptureService.ts | 16 +-
.../analysisView/AnalysisViewService.ts | 8 +-
src/browser/history/CaptureFailures.test.ts | 8 +-
src/browser/history/CaptureFailures.ts | 24 +-
src/browser/history/CaptureFormatError.ts | 2 +-
src/browser/history/CaptureRelease.ts | 19 +-
src/browser/history/HarCaptureAdapter.ts | 33 --
.../history/HistoricalCaptureDecoder.ts | 23 +-
.../history/HistoricalCaptureFormatAdapter.ts | 1 +
.../history/NodeCaptureAdapter.test.ts | 15 +
src/browser/history/NodeCaptureAdapter.ts | 58 ++
...aptureProcess.ts => NodeCaptureProcess.ts} | 35 +-
src/browser/history/SazCapture.ts | 174 ++++++
src/browser/history/SazHttp.ts | 72 +++
src/browser/history/SazMetadata.ts | 83 +++
src/browser/history/SazProjection.ts | 131 +++++
src/cli/webNetworkCaptureCommands.ts | 7 +-
src/composition/webNetworkCaptures.ts | 8 +-
src/contracts/analysisViewToolContracts.ts | 2 +-
.../webNetworkCaptureToolContracts.ts | 17 +-
src/domain/analysisView/analysisView.ts | 38 +-
.../analysisView/javascriptApplicationView.ts | 26 +-
.../webNetworkApiSurfaceView.test.ts | 12 +-
.../webNetworkCaptureView.test.ts | 176 ++++++
.../analysisView/webNetworkCaptureView.ts | 212 ++++++++
src/domain/webNetworkCapture.ts | 60 ++-
src/domain/webNetworkCaptureCoordinates.ts | 2 +
src/generatedPackageMetadata.ts | 2 +-
src/server/createServer.ts | 1 +
src/server/registerWebNetworkCaptureTool.ts | 36 +-
.../tools/historicalCaptureResources.test.ts | 4 +-
tests/boundary/browser/sazWebCapture.test.ts | 499 ++++++++++++++++++
website/locales/ar/messages.po | 29 +-
website/locales/de/messages.po | 30 +-
website/locales/es/messages.po | 30 +-
website/locales/fa/messages.po | 29 +-
website/locales/fr/messages.po | 30 +-
website/locales/id/messages.po | 30 +-
website/locales/it/messages.po | 30 +-
website/locales/ja/messages.po | 28 +-
website/locales/ko/messages.po | 28 +-
website/locales/messages.pot | 24 +-
website/locales/pl/messages.po | 29 +-
website/locales/pt-BR/messages.po | 30 +-
website/locales/ru/messages.po | 30 +-
website/locales/th/messages.po | 30 +-
website/locales/tr/messages.po | 30 +-
website/locales/uk/messages.po | 30 +-
website/locales/vi/messages.po | 29 +-
website/locales/zh-CN/messages.po | 27 +-
website/locales/zh-TW/messages.po | 27 +-
website/public/guides/browser/index.html | 9 +
61 files changed, 2237 insertions(+), 285 deletions(-)
delete mode 100644 src/browser/history/HarCaptureAdapter.ts
create mode 100644 src/browser/history/NodeCaptureAdapter.test.ts
create mode 100644 src/browser/history/NodeCaptureAdapter.ts
rename src/browser/history/{HarCaptureProcess.ts => NodeCaptureProcess.ts} (59%)
create mode 100644 src/browser/history/SazCapture.ts
create mode 100644 src/browser/history/SazHttp.ts
create mode 100644 src/browser/history/SazMetadata.ts
create mode 100644 src/browser/history/SazProjection.ts
create mode 100644 src/domain/analysisView/webNetworkCaptureView.test.ts
create mode 100644 src/domain/analysisView/webNetworkCaptureView.ts
create mode 100644 tests/boundary/browser/sazWebCapture.test.ts
diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md
index cd50cff64..35b99d8dc 100644
--- a/.agents/skills/reverse-engineer-anything/SKILL.md
+++ b/.agents/skills/reverse-engineer-anything/SKILL.md
@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
- version: "40"
+ version: "41"
---
# REA
@@ -61,7 +61,11 @@ explicit path or endpoint and do not need it.
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
optional metadata recovery and its supported host/layout boundary.
- User-owned browser page already open: `list_browser_targets`.
-- Retained HAR/native mitmproxy capture: `inspect_web_network_capture`. Select
+- Fiddler Everywhere traffic or a native `.saz` capture: read the
+ [Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
+ Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
+ connect Everywhere's separate MCP server for live traffic.
+- Retained HAR/SAZ/native mitmproxy capture: `inspect_web_network_capture`. Select
original record ordinals when useful; inspect producer fields and byte/number
sidecars without fetching recorded URLs or claiming live attribution. See
[historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream
diff --git a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md
index 3e16a3e19..2d660d675 100644
--- a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md
+++ b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md
@@ -26,3 +26,18 @@ passive browser/Electron Evidence exist. Prefer captured-source digest identity;
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
path mappings are inference inputs and never broaden filesystem or CDP authority.
A bundle observed at runtime does not prove every contained module executed.
+
+## Fiddler Everywhere traffic debugging
+
+For large saved captures, request `detail: "summary"`, then page retained Evidence
+with `inspect_analysis_view` and `view: {kind: "capture", detail: "preview", offset: 0, limit: 20}`.
+Select complete record pages only for needed headers, metadata or payloads; views
+read the historical parent without repeating capture decoding.
+
+Inspect native `.saz` snapshots with `inspect_web_network_capture` and
+`format: "saz"`; encrypted archives are unsupported. For live
+traffic analysis, connect Everywhere's separate MCP server and use its
+advertised session tools with an explicit `sessionsSource` (`LiveTraffic` or
+`AgentCalls`). Preserve existing sessions and filters when inspecting a
+recorded run. See the [capture and MCP guide](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md)
+for the workflow and Telerik's public setup and traffic-debugging skills.
diff --git a/README.md b/README.md
index 1e14f3a2d..49173ab4b 100644
--- a/README.md
+++ b/README.md
@@ -161,7 +161,7 @@ Additional tools and host support depend on the target:
| Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://rea.tools/guides/javascript/) |
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) |
-| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
+| Saved network captures | Requests, responses, exposed payloads and source locations | HAR or Fiddler Everywhere SAZ; mitmdump on Linux for native mitmproxy; [capture guide](docs/web-network-captures.md) |
| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
| Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) |
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 5a357c80f..001c07fbe 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -37,6 +37,10 @@ export default defineConfig({
link: "/javascript-application-workflows",
},
{ text: "Browser observation", link: "/browser-observation" },
+ {
+ text: "Web network captures",
+ link: "/web-network-captures",
+ },
{ text: "Electron observation", link: "/electron-observation" },
{ text: "Process capture", link: "/process-capture" },
{ text: ".NET assemblies", link: "/managed-code-analysis" },
diff --git a/docs/browser-observation.md b/docs/browser-observation.md
index 2e3d8e37c..2f73f4261 100644
--- a/docs/browser-observation.md
+++ b/docs/browser-observation.md
@@ -8,6 +8,10 @@ exported script's native imports under an explicit URL/import-map context.
REA can attach to a user-owned Chrome-family browser through the Chrome DevTools Protocol (CDP) and produce bounded Evidence about an existing page. This is a passive reverse-engineering capability, not a general browser automation or remote-control surface.
+For request/response bodies and traffic debugging, see
+[Fiddler Everywhere captures and MCP](web-network-captures.md#reading-fiddler-captures-saz).
+Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate MCP server.
+
## Shipped surfaces
- `list_browser_targets` / `rea list-browser-targets` discovers all eligible page targets at the endpoint by default, with an optional exact-origin filter.
diff --git a/docs/client-configuration.md b/docs/client-configuration.md
index c0e629d66..a6e254488 100644
--- a/docs/client-configuration.md
+++ b/docs/client-configuration.md
@@ -6,6 +6,12 @@ for the reviewed setup transaction and [client IDs](installation.md#supported-ag
The versioned verification notes below describe their stated runs; live service
acceptance is verified only where explicitly recorded.
+## Fiddler Everywhere alongside REA
+
+Connect Fiddler Everywhere's MCP server separately for HTTP traffic debugging,
+preserving your REA client entry. See the
+[setup instructions and public skills](web-network-captures.md#fiddler-everywhere-mcp-and-public-skills).
+
## GitHub Copilot CLI
For GitHub Copilot CLI 1.0.95, `gpt-4.1` model metadata can block ordinary
diff --git a/docs/packet-captures.md b/docs/packet-captures.md
index d56a80037..e8ce3ae92 100644
--- a/docs/packet-captures.md
+++ b/docs/packet-captures.md
@@ -4,7 +4,7 @@
in an existing capture. Network, USB or Bluetooth recordings can use the same
field/filter interface when the installed TShark supports their encapsulation
and dissectors. This tool does not acquire traffic, open an interface or control
-a device. HAR/mitmproxy web-capture workflows remain separate.
+a device. HAR/SAZ/mitmproxy web-capture workflows remain separate.
Supply an existing TShark 4.x installation **on Linux**, with util-linux
`prlimit` available at `/usr/bin/prlimit`. Override its absolute path with
diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md
index d49f1dcc3..e65887a71 100644
--- a/docs/web-network-captures.md
+++ b/docs/web-network-captures.md
@@ -25,6 +25,36 @@ and original zero-based producer ordinals; duplicate or out-of-range selections
are input errors. Every record is validated before selection, so selecting one
entry does not hide a malformed entry elsewhere.
+For agent discovery over MCP, request `detail: "summary"`. REA retains the
+complete selected Evidence in the session and returns identity and counts.
+Use `normalized_result.parent_evidence_id` with `inspect_analysis_view`:
+
+```json
+{
+ "source": {
+ "kind": "retained-evidence",
+ "evidence_id": ""
+ },
+ "view": { "kind": "capture", "detail": "preview", "offset": 0, "limit": 20 }
+}
+```
+
+Preview pages select recorded HTTP headline fields, event names/times when
+present, original locations and byte descriptors; they omit headers, XML and
+payload content. Choose `detail: "complete"` in that view for exact record
+pages, including all sidecars. Offsets follow the parent's selected record
+order; producer ordinals remain unchanged. `total_records` counts the original
+capture while `retained_records` and page coverage count records in the parent.
+An exhausted page does not establish absence from unselected records.
+
+Views do not reopen the file or rerun its decoder. They remain historical
+Evidence if the source changes or disappears; inspect the capture again to
+observe new bytes. The inspection tool's default `detail: "complete"` preserves
+existing inline delivery. For CLI use, save complete inspection JSON and pass
+it as inline Evidence to `inspect-analysis-view`; the same summary and capture
+page contracts apply. Large complete records can still exceed MCP's response
+budget; export the retained Evidence when a single record cannot fit.
+
## Formats and upstream profiles
HAR uses unchanged `har-schema@2.0.0` draft-06 schemas, `ajv@8.20.0`,
@@ -57,11 +87,80 @@ tested upstream source is tag `v12.2.3`, commit
an explicit unsupported-profile diagnostic. Non-Linux native decoding is not
supported by this adapter.
+## Reading Fiddler captures (`.saz`)
+
+Fiddler Everywhere saves traffic in **Session Archive Zip (`.saz`)** files.
+Inspect native SAZ directly, without installing Fiddler or converting to HAR:
+
+```sh
+rea inspect-web-network-capture /absolute/session.saz saz --json
+rea inspect-web-network-capture /absolute/session.saz saz --record 0 --json
+```
+
+MCP uses the same `capture_path`, `format: "saz"`, and optional
+`record_ordinals` fields as the examples above. REA pairs `raw/_c.txt`
+requests, `_s.txt` responses, and `_m.xml` metadata by filename stem. Archive
+member names identify the source; zero-based ordinals follow each session's
+first member in ZIP directory order, not its numeric ID or capture time.
+
+HTTP fields and ordered duplicate headers are retained, with stored body bytes
+in binary sidecars. Bodies are not dechunked or decompressed. Metadata is an
+XML node tree; optional `_w.txt` and `_g.txt` files retain opaque WebSocket and
+gRPC message bytes. Missing members remain null, distinct from empty bodies.
+See Telerik's [SAZ format reference](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/knowledge-base/fiddler-archives).
+
+The existing ZIP reader verifies read members; expanded archive contents are
+limited to 32 MiB in addition to the input/output budgets below. Encrypted
+archives and unrecognized raw session members are unsupported. SAZ requires an
+enforced decoder memory bound: Node's fixed heap, or the Windows Job Object
+commit limit. Bun on hosts without that Job Object limit returns capability
+unavailable before starting the SAZ decoder. Non-session
+members are inventoried without decoding their content. Original files remain
+unchanged. Linux verification includes independently checked public recorded
+Mattermost captures from [FOREST](https://github.com/dfrc-korea/FOREST/tree/405c9154658a128ea203a6c10040cec55111ed03/api-results/dataset)
+and the Fiddler author's [encoding corpus](https://github.com/ericlaw1979/Clearinet/blob/b6faf78cde1e72042980364f180ceb34b77d1434/Test/data/Encodings.saz),
+plus synthetic malformed/limit fixtures. Their exporting product versions are
+not authenticated. An interactive export from a current Fiddler Everywhere
+installation and its live MCP server remain unverified. Support here is the
+bounded documented raw HTTP/XML SAZ layout, not every product/version profile.
+
+For visual inspection, use Everywhere's **Import Snapshot**, then **Inspectors**
+and **Overview**. HAR remains an alternative: select sessions, choose
+**Export > HTTPArchive**, and confirm `log.version` is `"1.2"`. Preserve the
+original SAZ when exporting. See the [import/export instructions](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/collaboration/exporting-and-importing).
+
+## Fiddler Everywhere MCP and public skills
+
+For agent-driven traffic debugging, connect Everywhere's MCP server separately
+from REA. Run a current Everywhere release with MCP access (Trial, Pro, or
+higher), internet access, and a compatible MCP client. Use
+**Settings > MCP Server > Coding agent**, or **Manual setup** with the generated
+API key. The default endpoint is `http://localhost:8868/mcp`. See Telerik's
+[MCP setup guide](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/agent-tools/fiddler-mcp-server)
+and [agent-skills prerequisites](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/agent-tools/agent-skills).
+
+Telerik's [public skills](https://github.com/telerik/fiddler-agent-tools) cover
+installation (`fiddler-download-setup`), connection setup, and traffic analysis:
+
+```sh
+npx skills add telerik/fiddler-agent-tools --skill fiddler-mcp-setup
+npx skills add telerik/fiddler-agent-tools --skill fiddler-traffic-debugging
+```
+
+After setup, reload the agent client and use the connected server's advertised
+tool names and schemas. Check status, list sessions, then inspect relevant
+session IDs. Set `sessionsSource` to `LiveTraffic` for HTTP(S) traffic or
+`AgentCalls` for enriched LLM calls; active filters affect the session list.
+For an existing run, skip the skill's pre-capture steps that replace process
+filters and clear sessions. The documented MCP session sources do not provide
+a saved-snapshot selector; use the SAZ workflow above for saved files. REA does
+not register or wrap the Fiddler MCP server.
+
## Reading the evidence
- `artifact` identifies the caller-selected file and stable snapshot digest.
-- `records` preserve producer order/ordinals and original JSON pointers or byte
- ranges. No live browser transaction or scenario IDs are invented.
+- `records` preserve producer order/ordinals and original JSON pointers, byte
+ ranges, or SAZ member names. No live browser transaction or scenario IDs are invented.
- `reported` retains the producer's fields. HAR container metadata is returned
separately, with `log.entries` replaced by `null` and `records_pointer`
identifying the record collection. This is an inspection representation,
@@ -156,7 +255,7 @@ response fields, including HAR's `status: 0` and an empty `mimeType`, remain
unknown, not invented statuses or MIME types.
The view makes no handler-identity, schema or causality claim. It performs no
-network activity. Native mitmproxy captures return a typed unsupported error.
+network activity. Native mitmproxy and SAZ captures return a typed unsupported error.
Parents inspected with `sensitive_values`, or whose Evidence does not record
`sensitive_value_count`, return a typed unsupported error whose reason is the
canonical token `sensitive_values_unretained`. The view would write path
@@ -192,14 +291,14 @@ Complete evidence has a 32 MiB input, 96 MiB decoder reply and 64-level nesting
budget. Exceeding a budget returns no partial success.
Oversized selected captures return an input error identifying `capture_path`
and the byte budget; owned reply/output failures remain output or provider errors.
-HAR runs in an owned
-192 MiB Node old-generation heap with one V8 worker. Native decoding applies
+HAR and SAZ run in an owned
+192 MiB Node old-generation heap with one V8 worker. Mitmproxy decoding applies
Linux limits of 768 MiB address space, 30 seconds CPU and 96 MiB file output;
both commands have a 30-second wall deadline and independently supervised
process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout
and stderr; the collector drops overflow as it arrives, reports the limit
failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits.
-When REA runs on Bun, the HAR decoder runs on Bun, which ignores these V8 heap
+When REA runs on Bun, the HAR/SAZ decoder runs on Bun, which ignores these V8 heap
flags; its limitations and resource failures then omit the heap sizes. On
Windows, the decoder's owned Job Object instead limits its committed memory to
704 MiB, the 192 MiB budget plus a 512 MiB reserve for Bun itself. The
@@ -207,7 +306,7 @@ limitations state that limit, and reaching it returns `resource_constraint`
with `process_memory_limit_bytes`, whatever exit status Bun reports.
Observed decoder memory exhaustion returns `resource_constraint`, with the
-selected capture, applicable limits and retained process diagnostics. HAR's
+selected capture, applicable limits and retained process diagnostics. HAR/SAZ's
old-generation heap is fixed; inherited `NODE_OPTIONS` cannot raise it.
Record selection happens after complete decoding and does not reduce that
workload. Use a smaller capture exported by its producer or another decoder
diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs
index 1658c08fa..f97f5ed5c 100644
--- a/scripts/lib/catalog-builders.mjs
+++ b/scripts/lib/catalog-builders.mjs
@@ -272,6 +272,11 @@ export const providerCatalog = (sources) => {
contracts:
sources.webNetworkCaptureContracts.WEB_NETWORK_CAPTURE_TOOL_CONTRACTS,
},
+ {
+ identity: sources.webNetworkCaptureProvider.SAZ_CAPTURE_PROVIDER_IDENTITY,
+ contracts:
+ sources.webNetworkCaptureContracts.WEB_NETWORK_CAPTURE_TOOL_CONTRACTS,
+ },
{
identity:
sources.webNetworkCaptureProvider.MITMPROXY_CAPTURE_PROVIDER_IDENTITY,
diff --git a/src/application/WebNetworkCaptureService.ts b/src/application/WebNetworkCaptureService.ts
index 326016a26..a571fa8c1 100644
--- a/src/application/WebNetworkCaptureService.ts
+++ b/src/application/WebNetworkCaptureService.ts
@@ -16,6 +16,7 @@ import { jsonObjectSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
inspectWebNetworkCaptureInputSchema,
+ captureSensitiveValues,
webNetworkCaptureSchema,
} from "../domain/webNetworkCapture.js";
@@ -32,21 +33,8 @@ export class WebNetworkCaptureService {
): Promise> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
- const declarations =
- inspectWebNetworkCaptureInputSchema.shape.sensitive_values.safeParse(
- typeof rawInput === "object" &&
- rawInput !== null &&
- "sensitive_values" in rawInput
- ? rawInput.sensitive_values
- : undefined,
- );
const failure = (error: AnalysisError): Result =>
- err(
- redactExplicitFailure(
- error,
- declarations.success ? declarations.data : [],
- ),
- );
+ err(redactExplicitFailure(error, captureSensitiveValues(rawInput)));
const parsed = inspectWebNetworkCaptureInputSchema.safeParse(rawInput);
if (!parsed.success)
return failure(
diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts
index a5b603a86..baaa6b7ca 100644
--- a/src/application/analysisView/AnalysisViewService.ts
+++ b/src/application/analysisView/AnalysisViewService.ts
@@ -47,14 +47,8 @@ export const inspectAnalysisViewValidated = (
input: InspectAnalysisViewInput,
lookup?: EvidenceLookup,
): Result => {
- const inline = input.source.kind === "inline" ? input.source.evidence : null;
- const inlineCapture =
- typeof inline === "object" &&
- inline !== null &&
- Object.getOwnPropertyDescriptor(inline, "operation")?.value ===
- "inspect_web_network_capture";
const accumulator =
- input.view.kind === "api-surface" || inlineCapture
+ input.view.kind === "api-surface"
? new WebNetworkApiSurfaceAccumulator()
: undefined;
if (input.source.kind === "inline" && accumulator !== undefined) {
diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts
index ff7be71a6..047722ac5 100644
--- a/src/browser/history/CaptureFailures.test.ts
+++ b/src/browser/history/CaptureFailures.test.ts
@@ -4,7 +4,7 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { inspectWebNetworkCaptureInputSchema } from "../../domain/webNetworkCapture.js";
import { historicalCaptureFailure } from "./CaptureFailures.js";
-import { HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js";
+import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js";
import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
@@ -118,7 +118,7 @@ it.each([
},
});
// The enforced limit is reported only where Bun runs on Windows.
- if (HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null)
+ if (NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null)
expect(JSON.stringify(projected)).not.toContain(
"process_memory_limit_bytes",
);
@@ -126,12 +126,12 @@ it.each([
expect(projected).toMatchObject({
details: {
reported_limits: {
- process_memory_limit_bytes: HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+ process_memory_limit_bytes: NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
},
},
remediation: {
action: expect.stringContaining(
- `${String(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)} bytes`,
+ `${String(NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)} bytes`,
),
},
});
diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts
index 228541545..51eceb0b5 100644
--- a/src/browser/history/CaptureFailures.ts
+++ b/src/browser/history/CaptureFailures.ts
@@ -18,9 +18,9 @@ import {
} from "../../domain/webNetworkCapture.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
import {
- HAR_CAPTURE_HEAP_ENFORCED,
- HAR_CAPTURE_HEAP_LIMITS,
- HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+ NODE_CAPTURE_HEAP_ENFORCED,
+ NODE_CAPTURE_HEAP_LIMITS,
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
} from "./CaptureRelease.js";
const OPERATION = "inspect_web_network_capture";
@@ -78,7 +78,7 @@ export const historicalCaptureFailure = (
);
if (
phase === "decoder" &&
- input.format === "har" &&
+ input.format !== "mitmproxy" &&
cause.reason === "process" &&
(cause.snapshot?.signal === "SIGABRT" ||
(cause.snapshot?.signal === null && cause.snapshot.exitCode === 134)) &&
@@ -195,15 +195,17 @@ export const captureMemoryFailure = (
format: input.format,
maximum_input_bytes: WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
maximum_reply_bytes: WEB_NETWORK_CAPTURE_LIMITS.outputBytes,
- ...(input.format === "har" && HAR_CAPTURE_HEAP_ENFORCED
+ ...(input.format !== "mitmproxy" && NODE_CAPTURE_HEAP_ENFORCED
? {
- old_generation_heap_mib: HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB,
- semi_space_heap_mib: HAR_CAPTURE_HEAP_LIMITS.semiSpaceMiB,
+ old_generation_heap_mib: NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB,
+ semi_space_heap_mib: NODE_CAPTURE_HEAP_LIMITS.semiSpaceMiB,
}
: {}),
- ...(input.format === "har" &&
- HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null
- ? { process_memory_limit_bytes: HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES }
+ ...(input.format !== "mitmproxy" &&
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null
+ ? {
+ process_memory_limit_bytes: NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+ }
: {}),
...(cause instanceof OwnedCommandFailure
? {
@@ -214,7 +216,7 @@ export const captureMemoryFailure = (
},
{
cause,
- remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format === "har" && HAR_CAPTURE_HEAP_ENFORCED ? ` REA's HAR old-generation heap is fixed at ${HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB} MiB; inherited NODE_OPTIONS cannot raise it.` : ""}${input.format === "har" && HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null ? ` REA's HAR decoder process commit limit is fixed at ${String(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)} bytes.` : ""}`,
+ remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format !== "mitmproxy" && NODE_CAPTURE_HEAP_ENFORCED ? ` REA's Node capture old-generation heap is fixed at ${NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB} MiB; inherited NODE_OPTIONS cannot raise it.` : ""}${input.format !== "mitmproxy" && NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null ? ` REA's Node capture decoder process commit limit is fixed at ${String(NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)} bytes.` : ""}`,
...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
? {
capturedOutput: {
diff --git a/src/browser/history/CaptureFormatError.ts b/src/browser/history/CaptureFormatError.ts
index fd7a9272d..a31b3057d 100644
--- a/src/browser/history/CaptureFormatError.ts
+++ b/src/browser/history/CaptureFormatError.ts
@@ -1,7 +1,7 @@
/** Expected malformed, unsupported or oversized producer data at a historical decoder boundary. */
export class CaptureFormatError extends Error {
constructor(
- readonly reason: "format" | "unsupported" | "input-limit",
+ readonly reason: "format" | "unsupported" | "input-limit" | "limit",
message: string,
readonly pointer = "",
) {
diff --git a/src/browser/history/CaptureRelease.ts b/src/browser/history/CaptureRelease.ts
index d8af643aa..512c2fa6e 100644
--- a/src/browser/history/CaptureRelease.ts
+++ b/src/browser/history/CaptureRelease.ts
@@ -8,19 +8,19 @@ export const HAR_CAPTURE_PROVIDER_IDENTITY = {
version: "har-schema@2.0.0;jsonc-parser@3.3.1;lossless-json@4.3.1",
} as const;
-/** Fixed V8 heap settings used by the owned HAR decoder process. */
-export const HAR_CAPTURE_HEAP_LIMITS = {
+/** Fixed V8 heap settings used by the owned Node capture decoder process. */
+export const NODE_CAPTURE_HEAP_LIMITS = {
oldGenerationMiB: 192,
semiSpaceMiB: 8,
} as const;
-/** The HAR decoder runs on this executable; Bun accepts but ignores V8 heap flags. */
-export const HAR_CAPTURE_HEAP_ENFORCED =
+/** The Node capture decoder runs on this executable; Bun accepts but ignores V8 heap flags. */
+export const NODE_CAPTURE_HEAP_ENFORCED =
javaScriptRuntime(process.versions).id === "node";
/** Windows Job Object commit limit replacing the ignored heap flags under Bun; otherwise null. */
-export const HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES =
- javaScriptWorkerMemoryLimitBytes(HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB);
+export const NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES =
+ javaScriptWorkerMemoryLimitBytes(NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB);
/** Native executable profile verified with unchanged mitmproxy raw-state decoding. */
export const MITMPROXY_CAPTURE_PROVIDER_IDENTITY = {
@@ -28,3 +28,10 @@ export const MITMPROXY_CAPTURE_PROVIDER_IDENTITY = {
name: "REA offline native mitmproxy adapter",
version: "12.2.3",
} as const;
+
+/** Native SAZ member decoding with the existing ZIP and XML package profiles. */
+export const SAZ_CAPTURE_PROVIDER_IDENTITY = {
+ id: "fiddler-saz",
+ name: "REA Fiddler SAZ capture adapter",
+ version: "1;zip.js@2.15.0;xmldom@0.9.12",
+} as const;
diff --git a/src/browser/history/HarCaptureAdapter.ts b/src/browser/history/HarCaptureAdapter.ts
deleted file mode 100644
index 9a5204257..000000000
--- a/src/browser/history/HarCaptureAdapter.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { fileURLToPath } from "node:url";
-import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js";
-import {
- HAR_CAPTURE_PROVIDER_IDENTITY,
- HAR_CAPTURE_HEAP_LIMITS,
- HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
-} from "./CaptureRelease.js";
-
-/** HAR's parser/schema packages run in one bounded Node heap, without network or target execution. */
-export class HarCaptureAdapter implements HistoricalCaptureFormatAdapter {
- readonly format = "har";
- readonly identity = HAR_CAPTURE_PROVIDER_IDENTITY;
- async command(requestPath: string): Promise<{
- command: string;
- arguments: readonly string[];
- memoryLimitBytes?: number;
- }> {
- return {
- command: process.execPath,
- arguments: [
- `--max-old-space-size=${HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB}`,
- `--max-semi-space-size=${HAR_CAPTURE_HEAP_LIMITS.semiSpaceMiB}`,
- "--v8-pool-size=1",
- "--single-threaded",
- fileURLToPath(new URL("./HarCaptureProcess.js", import.meta.url)),
- requestPath,
- ],
- ...(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
- ? {}
- : { memoryLimitBytes: HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES }),
- };
- }
-}
diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts
index 155ba229b..f17f45a21 100644
--- a/src/browser/history/HistoricalCaptureDecoder.ts
+++ b/src/browser/history/HistoricalCaptureDecoder.ts
@@ -4,9 +4,9 @@ import {
captureMemoryFailure,
} from "./CaptureFailures.js";
import {
- HAR_CAPTURE_HEAP_ENFORCED,
- HAR_CAPTURE_HEAP_LIMITS,
- HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+ NODE_CAPTURE_HEAP_ENFORCED,
+ NODE_CAPTURE_HEAP_LIMITS,
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
} from "./CaptureRelease.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js";
@@ -208,10 +208,11 @@ export class HistoricalCaptureDecoder {
format: input.format,
runtime_attribution: "unknown",
limitations: [
+ ...(adapter.limitations ?? []),
"This is retained producer evidence, without live browser transaction IDs, scenario provenance, execution attribution or deployment authenticity. Recorded URLs are never fetched.",
"Every record is decoded before selection; no partial success is returned on malformed input or a resource limit.",
"Known authentication headers/cookies and explicitly marked literal values are excluded. Unknown extension names do not establish sensitivity.",
- `A 32 MiB input, 96 MiB reply and 64-level nesting budget bound complete evidence. ${harMemoryLimitation()}; native decoding uses a 768 MiB Linux address-space limit. Each owned command has a 30-second deadline with independent cleanup.`,
+ `A 32 MiB input, 96 MiB reply and 64-level nesting budget bound complete evidence. ${nodeMemoryLimitation()}; mitmproxy decoding uses a 768 MiB Linux address-space limit. Each owned command has a 30-second deadline with independent cleanup.`,
],
});
} catch (cause: unknown) {
@@ -251,13 +252,13 @@ export class HistoricalCaptureDecoder {
}
}
-/** The HAR decoder runs on this executable; only Node.js enforces its V8 heap flags. */
-const harMemoryLimitation = (): string => {
- if (HAR_CAPTURE_HEAP_ENFORCED)
- return `HAR uses a ${String(HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB)} MiB Node old-generation heap`;
- return HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
- ? "HAR runs on Bun, which does not enforce REA's V8 heap flags"
- : `HAR runs on Bun, which does not enforce REA's V8 heap flags, under a ${String(HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)}-byte Windows Job Object commit limit`;
+/** The Node capture decoder runs on this executable; only Node.js enforces its V8 heap flags. */
+const nodeMemoryLimitation = (): string => {
+ if (NODE_CAPTURE_HEAP_ENFORCED)
+ return `HAR/SAZ use a ${String(NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB)} MiB Node old-generation heap`;
+ return NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
+ ? "HAR/SAZ run on Bun, which does not enforce REA's V8 heap flags"
+ : `HAR/SAZ run on Bun, which does not enforce REA's V8 heap flags, under a ${String(NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)}-byte Windows Job Object commit limit`;
};
const replyFailureReason = (cause: unknown): string => {
diff --git a/src/browser/history/HistoricalCaptureFormatAdapter.ts b/src/browser/history/HistoricalCaptureFormatAdapter.ts
index 811ea56e2..9660fd579 100644
--- a/src/browser/history/HistoricalCaptureFormatAdapter.ts
+++ b/src/browser/history/HistoricalCaptureFormatAdapter.ts
@@ -3,6 +3,7 @@ import type { InspectWebNetworkCaptureInput } from "../../domain/webNetworkCaptu
/** Format-specific process profile keeps mature upstream code behind a replaceable adapter seam. */
export interface HistoricalCaptureFormatAdapter {
readonly format: InspectWebNetworkCaptureInput["format"];
+ readonly limitations?: readonly string[];
readonly identity: {
readonly id: string;
readonly name: string;
diff --git a/src/browser/history/NodeCaptureAdapter.test.ts b/src/browser/history/NodeCaptureAdapter.test.ts
new file mode 100644
index 000000000..c341949db
--- /dev/null
+++ b/src/browser/history/NodeCaptureAdapter.test.ts
@@ -0,0 +1,15 @@
+import { expect, it, vi } from "vitest";
+import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
+import { NodeCaptureAdapter } from "./NodeCaptureAdapter.js";
+
+vi.mock("./CaptureRelease.js", async (original) => ({
+ ...(await original()),
+ NODE_CAPTURE_HEAP_ENFORCED: false,
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES: null,
+}));
+
+it("refuses a SAZ worker when the runtime has no enforceable memory bound", async () => {
+ await expect(
+ new NodeCaptureAdapter("saz").command("/owned/request.json"),
+ ).rejects.toBeInstanceOf(AnalysisCapabilityUnavailableError);
+});
diff --git a/src/browser/history/NodeCaptureAdapter.ts b/src/browser/history/NodeCaptureAdapter.ts
new file mode 100644
index 000000000..f1e98a07b
--- /dev/null
+++ b/src/browser/history/NodeCaptureAdapter.ts
@@ -0,0 +1,58 @@
+import { fileURLToPath } from "node:url";
+import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js";
+import {
+ NODE_CAPTURE_HEAP_ENFORCED,
+ HAR_CAPTURE_PROVIDER_IDENTITY,
+ SAZ_CAPTURE_PROVIDER_IDENTITY,
+ NODE_CAPTURE_HEAP_LIMITS,
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+} from "./CaptureRelease.js";
+import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
+
+/** Package-backed capture decoders share one bounded offline Node process profile. */
+export class NodeCaptureAdapter implements HistoricalCaptureFormatAdapter {
+ readonly identity;
+ readonly limitations;
+ constructor(readonly format: "har" | "saz") {
+ this.identity =
+ format === "har"
+ ? HAR_CAPTURE_PROVIDER_IDENTITY
+ : SAZ_CAPTURE_PROVIDER_IDENTITY;
+ this.limitations =
+ format === "saz"
+ ? [
+ "SAZ ordinals follow first session-member occurrence in ZIP directory order, not chronology or numeric session ID.",
+ "HTTP fields use byte-preserving Latin-1 text; start-line separators and header delimiters are not projected. Bodies retain stored bytes without transfer/content decoding. Absent members are null, distinct from empty bodies.",
+ "XML preserves node order and attribute/text values, not its original byte serialization. WebSocket and gRPC members retain opaque message-file bytes, not decoded protocol events.",
+ "Non-session archive members are inventoried but their content is not decoded.",
+ ]
+ : [];
+ }
+ async command(requestPath: string) {
+ if (
+ this.format === "saz" &&
+ !NODE_CAPTURE_HEAP_ENFORCED &&
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
+ )
+ throw new AnalysisCapabilityUnavailableError(
+ this.identity.id,
+ "inspect_web_network_capture",
+ "SAZ decoding requires an enforced decoder memory limit. Run REA with Node.js on this host.",
+ );
+ return {
+ command: process.execPath,
+ arguments: [
+ `--max-old-space-size=${NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB}`,
+ `--max-semi-space-size=${NODE_CAPTURE_HEAP_LIMITS.semiSpaceMiB}`,
+ "--v8-pool-size=1",
+ "--single-threaded",
+ fileURLToPath(new URL("./NodeCaptureProcess.js", import.meta.url)),
+ requestPath,
+ this.format,
+ ],
+ ...(NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
+ ? {}
+ : { memoryLimitBytes: NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES }),
+ };
+ }
+}
diff --git a/src/browser/history/HarCaptureProcess.ts b/src/browser/history/NodeCaptureProcess.ts
similarity index 59%
rename from src/browser/history/HarCaptureProcess.ts
rename to src/browser/history/NodeCaptureProcess.ts
index 1e4c66c76..e94295da4 100644
--- a/src/browser/history/HarCaptureProcess.ts
+++ b/src/browser/history/NodeCaptureProcess.ts
@@ -3,7 +3,6 @@ import { z } from "zod";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import { CaptureFormatError } from "./CaptureFormatError.js";
-import { decodeHarCapture, decodeHarText } from "./HarCapture.js";
const requestSchema = z.strictObject({
snapshot_path: z.string(),
@@ -11,24 +10,34 @@ const requestSchema = z.strictObject({
sensitive_values: z.array(z.string().min(1)),
});
-/** Decode untrusted HAR in an owned, bounded heap rather than the long-lived MCP process. */
+/** Parse untrusted captures in an owned heap and return one complete bounded reply. */
const main = async (): Promise => {
+ const format = z.enum(["har", "saz"]).parse(process.argv[3]);
const request = requestSchema.parse(
JSON.parse(await readFile(process.argv[2] ?? "", "utf8")),
);
let reply: unknown;
try {
- const snapshot = await readStableArtifact(
- request.snapshot_path,
- WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
- );
- reply = {
- ok: true,
- value: decodeHarCapture(
+ let value;
+ if (format === "har") {
+ const { decodeHarCapture, decodeHarText } =
+ await import("./HarCapture.js");
+ const snapshot = await readStableArtifact(
+ request.snapshot_path,
+ WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
+ );
+ value = decodeHarCapture(
decodeHarText(snapshot.bytes),
request.sensitive_values,
- ),
- };
+ );
+ } else {
+ const { decodeSazCapture } = await import("./SazCapture.js");
+ value = await decodeSazCapture(
+ request.snapshot_path,
+ request.sensitive_values,
+ );
+ }
+ reply = { ok: true, value };
} catch (cause: unknown) {
reply =
cause instanceof CaptureFormatError
@@ -41,7 +50,7 @@ const main = async (): Promise => {
: {
ok: false,
reason: "decoder",
- message: `HAR decoder failed unexpectedly (${cause instanceof Error ? cause.name : "unknown exception"}).`,
+ message: `Capture decoder failed unexpectedly (${cause instanceof Error ? cause.name : "unknown exception"}).`,
pointer: "",
};
}
@@ -50,7 +59,7 @@ const main = async (): Promise => {
text = JSON.stringify({
ok: false,
reason: "limit",
- message: "HAR reply exceeds the complete-evidence output budget.",
+ message: "Capture reply exceeds the complete-evidence output budget.",
pointer: "",
});
await writeFile(request.reply_path, text, { flag: "wx", mode: 0o600 });
diff --git a/src/browser/history/SazCapture.ts b/src/browser/history/SazCapture.ts
new file mode 100644
index 000000000..0d597eb5a
--- /dev/null
+++ b/src/browser/history/SazCapture.ts
@@ -0,0 +1,174 @@
+import { ZipArtifactReader } from "../../artifacts/ZipArtifactReader.js";
+import {
+ ArtifactReaderFailure,
+ type ArtifactEntry,
+} from "../../artifacts/ArtifactReader.js";
+import {
+ WEB_NETWORK_CAPTURE_LIMITS,
+ type WebNetworkCaptureRecord,
+} from "../../domain/webNetworkCapture.js";
+import { SAZ_CAPTURE_PROVIDER_IDENTITY } from "./CaptureRelease.js";
+import { CaptureFormatError } from "./CaptureFormatError.js";
+import { decodeSazHttp } from "./SazHttp.js";
+import { decodeSazMetadata } from "./SazMetadata.js";
+import { projectSazRecord, type SazValue } from "./SazProjection.js";
+
+/** Decode native SAZ sessions without extracting files, running Fiddler or converting to HAR. */
+export const decodeSazCapture = async (
+ snapshotPath: string,
+ sensitiveValues: readonly string[],
+) => {
+ const reader = new ZipArtifactReader(
+ snapshotPath,
+ "zip",
+ WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
+ );
+ try {
+ const groups = new Map();
+ const inventory: SazValue[] = [];
+ const paths = new Set();
+ let declaredBytes = 0;
+ for await (const entry of reader.entries()) {
+ if (paths.has(entry.path))
+ throw new CaptureFormatError(
+ "format",
+ "SAZ contains duplicate archive member names.",
+ );
+ paths.add(entry.path);
+ if (entry.encrypted)
+ throw new CaptureFormatError(
+ "unsupported",
+ "Encrypted SAZ archives are unsupported; export an unencrypted copy from Fiddler Everywhere.",
+ );
+ if (entry.kind !== "file" && entry.kind !== "directory")
+ throw new CaptureFormatError(
+ "unsupported",
+ "SAZ archive links are unsupported.",
+ );
+ if (entry.declaredSize !== null) {
+ if (!Number.isSafeInteger(entry.declaredSize) || entry.declaredSize < 0)
+ throw new CaptureFormatError("format", "SAZ member size is invalid.");
+ declaredBytes += entry.declaredSize;
+ }
+ if (declaredBytes > WEB_NETWORK_CAPTURE_LIMITS.inputBytes)
+ throw new CaptureFormatError(
+ "limit",
+ "SAZ expanded members exceed the 32 MiB complete-evidence budget.",
+ );
+ inventory.push({
+ path: entry.path,
+ kind: entry.kind,
+ bytes: entry.declaredSize,
+ });
+ if (entry.kind !== "file") continue;
+ const match = /^raw\/(\d+)_(c\.txt|s\.txt|m\.xml|w\.txt|g\.txt)$/.exec(
+ entry.path,
+ );
+ if (match?.[1] !== undefined) {
+ const group = groups.get(match[1]) ?? [];
+ group.push(entry);
+ groups.set(match[1], group);
+ } else if (entry.path.startsWith("raw/")) {
+ throw new CaptureFormatError(
+ "unsupported",
+ "SAZ contains an unrecognized raw session member.",
+ );
+ }
+ }
+ if (groups.size === 0)
+ throw new CaptureFormatError(
+ "format",
+ "Archive contains no SAZ session members.",
+ );
+ const records: WebNetworkCaptureRecord[] = [];
+ for (const [session_id, entries] of groups) {
+ const source: Record = {
+ session_id,
+ request: null,
+ response: null,
+ metadata: null,
+ websocket: null,
+ grpc: null,
+ };
+ for (const entry of entries) {
+ const stream = await reader.open(entry);
+ const chunks: Buffer[] = [];
+ let length = 0;
+ for await (const chunk of stream) {
+ if (!Buffer.isBuffer(chunk))
+ throw new CaptureFormatError(
+ "format",
+ "SAZ member stream returned non-byte data.",
+ );
+ length += chunk.length;
+ if (length > (entry.declaredSize ?? 0))
+ throw new CaptureFormatError(
+ "format",
+ "SAZ member exceeds its declared size.",
+ );
+ chunks.push(chunk);
+ }
+ if (length !== entry.declaredSize)
+ throw new CaptureFormatError(
+ "format",
+ "SAZ member does not match its declared size.",
+ );
+ const bytes = Buffer.concat(chunks, length);
+ if (entry.path.endsWith("_c.txt"))
+ source.request = {
+ member: entry.path,
+ ...decodeSazHttp(bytes, true),
+ };
+ else if (entry.path.endsWith("_s.txt"))
+ source.response = {
+ member: entry.path,
+ ...decodeSazHttp(bytes, false),
+ };
+ else if (entry.path.endsWith("_m.xml"))
+ source.metadata = {
+ member: entry.path,
+ document: decodeSazMetadata(bytes),
+ };
+ else
+ source[entry.path.endsWith("_w.txt") ? "websocket" : "grpc"] = {
+ member: entry.path,
+ messages: bytes,
+ };
+ }
+ records.push({
+ ordinal: records.length,
+ location: {
+ kind: "archive-members",
+ members: entries.map((entry) => entry.path),
+ },
+ ...projectSazRecord(source, sensitiveValues),
+ limitations: [],
+ });
+ }
+ const container = projectSazRecord({ members: inventory }, sensitiveValues);
+ return {
+ decoder: { ...SAZ_CAPTURE_PROVIDER_IDENTITY },
+ container: {
+ reported: container.reported,
+ numeric_literals: container.numeric_literals,
+ redactions: container.redactions,
+ records_pointer: null,
+ },
+ total_records: records.length,
+ records,
+ };
+ } catch (cause: unknown) {
+ if (cause instanceof CaptureFormatError) throw cause;
+ if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
+ throw new CaptureFormatError(
+ "limit",
+ "SAZ archive exceeds the ZIP reader's resource budget.",
+ );
+ throw new CaptureFormatError(
+ "format",
+ "SAZ archive or session data is malformed or fails ZIP integrity validation.",
+ );
+ } finally {
+ await reader.close();
+ }
+};
diff --git a/src/browser/history/SazHttp.ts b/src/browser/history/SazHttp.ts
new file mode 100644
index 000000000..246dd6164
--- /dev/null
+++ b/src/browser/history/SazHttp.ts
@@ -0,0 +1,72 @@
+import { CaptureFormatError } from "./CaptureFormatError.js";
+
+/** Preserve ordered HTTP fields and the stored body; never dechunk, decompress or fetch. */
+export const decodeSazHttp = (bytes: Buffer, request: boolean) => {
+ const crlfSplit = bytes.indexOf("\r\n\r\n");
+ // Once a CRLF separator is found, only an earlier LF separator can win.
+ // Avoid searching an otherwise unrelated large body a second time.
+ const lfSplit = bytes
+ .subarray(0, crlfSplit < 0 ? bytes.length : crlfSplit)
+ .indexOf("\n\n");
+ // The first separator ends the headers. Later separators belong to the
+ // stored body, which may use different line endings or contain binary data.
+ const split =
+ crlfSplit >= 0 && (lfSplit < 0 || crlfSplit < lfSplit) ? crlfSplit : -1;
+ const boundary = split < 0 ? lfSplit : split;
+ if (boundary < 0)
+ throw new CaptureFormatError(
+ "format",
+ "SAZ HTTP message has no header/body separator.",
+ );
+ const lines = bytes
+ .subarray(0, boundary)
+ .toString("latin1")
+ .split(split < 0 ? "\n" : "\r\n");
+ const first = lines.shift() ?? "";
+ const fields = request
+ ? /^(\S+) +(\S+) +(HTTP\/\d+(?:\.\d+)?)$/.exec(first)
+ : /^(HTTP\/\d+(?:\.\d+)?) +(\d{3})(?: (.*))?$/.exec(first);
+ if (fields === null)
+ throw new CaptureFormatError("format", "SAZ HTTP start line is malformed.");
+ const headers: { name: string; value: string }[] = [];
+ for (const line of lines) {
+ if (/^[ \t]/.test(line)) {
+ const previous = headers.at(-1);
+ if (previous === undefined)
+ throw new CaptureFormatError(
+ "format",
+ "SAZ HTTP header continuation has no field.",
+ );
+ previous.value += `${split < 0 ? "\n" : "\r\n"}${line}`;
+ continue;
+ }
+ const colon = line.indexOf(":");
+ // HTTP/2 pseudo-headers use the second colon as the delimiter.
+ const delimiter = colon === 0 ? line.indexOf(":", 1) : colon;
+ if (delimiter < 1)
+ throw new CaptureFormatError("format", "SAZ HTTP header is malformed.");
+ const name = line.slice(0, delimiter);
+ if (!/^:?[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name))
+ throw new CaptureFormatError(
+ "format",
+ "SAZ HTTP header name is malformed.",
+ );
+ headers.push({ name, value: line.slice(delimiter + 1) });
+ }
+ const start = request
+ ? {
+ method: fields[1] ?? "",
+ url: fields[2] ?? "",
+ http_version: fields[3] ?? "",
+ }
+ : {
+ http_version: fields[1] ?? "",
+ status: fields[2] ?? "",
+ reason: fields[3] ?? "",
+ };
+ return {
+ ...start,
+ headers,
+ body: bytes.subarray(boundary + (split < 0 ? 2 : 4)),
+ };
+};
diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts
new file mode 100644
index 000000000..6e311f8e6
--- /dev/null
+++ b/src/browser/history/SazMetadata.ts
@@ -0,0 +1,83 @@
+import { DOMParser, ParseError, type Node } from "@xmldom/xmldom";
+import type { JsonValue } from "../../domain/jsonValue.js";
+import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
+import { CaptureFormatError } from "./CaptureFormatError.js";
+
+/** Preserve XML node order, attribute names and text without interpreting producer-specific flags. */
+export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
+ let xml: string;
+ try {
+ const encoding =
+ bytes[0] === 0xff && bytes[1] === 0xfe
+ ? "utf-16le"
+ : bytes[0] === 0xfe && bytes[1] === 0xff
+ ? "utf-16be"
+ : "utf-8";
+ xml = new TextDecoder(encoding, { fatal: true }).decode(bytes);
+ } catch {
+ throw new CaptureFormatError(
+ "unsupported",
+ "SAZ metadata must use UTF-8 or BOM-marked UTF-16.",
+ );
+ }
+ let parseFailed = false;
+ let document: ReturnType;
+ try {
+ document = new DOMParser({
+ normalizeLineEndings: (source) => source,
+ onError: () => {
+ parseFailed = true;
+ },
+ }).parseFromString(xml, "application/xml");
+ } catch (cause: unknown) {
+ if (!(cause instanceof ParseError)) throw cause;
+ throw new CaptureFormatError(
+ "format",
+ "SAZ metadata is not well-formed XML.",
+ );
+ }
+ // The pinned parser does not resolve external or DTD-defined entities.
+ // Inspect its parsed node; text inside comments/CDATA is ordinary evidence.
+ if (document.doctype !== null)
+ throw new CaptureFormatError(
+ "unsupported",
+ "SAZ metadata DTDs are unsupported.",
+ );
+ if (parseFailed)
+ throw new CaptureFormatError(
+ "format",
+ "SAZ metadata is not well-formed XML.",
+ );
+ if (document.documentElement?.tagName !== "Session")
+ throw new CaptureFormatError(
+ "format",
+ "SAZ metadata must have a Session document element.",
+ );
+ const visit = (node: Node, depth: number): JsonValue => {
+ if (depth > WEB_NETWORK_CAPTURE_LIMITS.depth)
+ throw new CaptureFormatError(
+ "input-limit",
+ "SAZ metadata exceeds the complete-evidence nesting budget.",
+ );
+ const children: JsonValue[] = [];
+ for (let child = node.firstChild; child !== null; child = child.nextSibling)
+ children.push(visit(child, depth + 1));
+ const attributes: JsonValue[] = [];
+ if (node.nodeType === 1) {
+ const element = node as import("@xmldom/xmldom").Element;
+ for (let i = 0; i < element.attributes.length; i++) {
+ const attribute = element.attributes.item(i);
+ if (attribute !== null)
+ attributes.push({ name: attribute.name, value: attribute.value });
+ }
+ }
+ return {
+ name: node.nodeName,
+ type: node.nodeType,
+ value: node.nodeValue,
+ attributes,
+ children,
+ };
+ };
+ return visit(document, 0);
+};
diff --git a/src/browser/history/SazProjection.ts b/src/browser/history/SazProjection.ts
new file mode 100644
index 000000000..eba813628
--- /dev/null
+++ b/src/browser/history/SazProjection.ts
@@ -0,0 +1,131 @@
+import { createHash } from "node:crypto";
+import { isUtf8 } from "node:buffer";
+import type { JsonValue } from "../../domain/jsonValue.js";
+import {
+ WEB_NETWORK_CAPTURE_LIMITS,
+ type WebNetworkCaptureRecord,
+} from "../../domain/webNetworkCapture.js";
+import { CaptureRedaction, capturePointer } from "./CaptureRedaction.js";
+import { CaptureFormatError } from "./CaptureFormatError.js";
+
+export type SazValue =
+ | JsonValue
+ | Buffer
+ | { readonly [key: string]: SazValue }
+ | readonly SazValue[];
+
+/** Project native byte fields with the same credential and explicit-literal exclusions as HAR. */
+export const projectSazRecord = (
+ source: SazValue,
+ sensitiveValues: readonly string[],
+) => {
+ const redactor = new CaptureRedaction(sensitiveValues);
+ const binary_fields: WebNetworkCaptureRecord["binary_fields"] = [];
+ const visit = (
+ value: SazValue,
+ pointer: string,
+ depth: number,
+ parent?: Readonly>,
+ ): JsonValue => {
+ if (depth > WEB_NETWORK_CAPTURE_LIMITS.depth)
+ throw new CaptureFormatError(
+ "input-limit",
+ "SAZ projection exceeds the complete-evidence nesting budget.",
+ pointer,
+ );
+ if (parent !== undefined && redactor.harCredential(pointer, parent))
+ return redactor.credential(pointer);
+ if (Buffer.isBuffer(value)) {
+ const base64 = value.toString("base64");
+ const sha256 = createHash("sha256").update(value).digest("hex");
+ const hidden =
+ redactor.sensitiveBytes(value) ||
+ sensitiveValues.some(
+ (literal) =>
+ base64.includes(literal) ||
+ sha256.includes(literal) ||
+ String(value.length).includes(literal),
+ );
+ if (hidden) {
+ binary_fields.push({
+ pointer,
+ representation: "producer-bytes",
+ state: "redacted",
+ content_base64: null,
+ bytes: null,
+ sha256: null,
+ });
+ redactor.redactions.push({
+ pointer,
+ reason: "explicit-sensitive-value",
+ });
+ return null;
+ }
+ binary_fields.push({
+ pointer,
+ representation: "producer-bytes",
+ state: "retained",
+ content_base64: base64,
+ bytes: value.length,
+ sha256,
+ });
+ return isUtf8(value) ? value.toString("utf8") : null;
+ }
+ if (typeof value === "string") {
+ // HTTP fields are Latin-1 views of bytes. A UTF-8 declaration must also
+ // exclude its byte spelling, even when its display is mojibake.
+ if (
+ /^\/(request|response)\//.test(pointer) &&
+ !pointer.endsWith("/member") &&
+ redactor.sensitiveBytes(Buffer.from(value, "latin1"))
+ ) {
+ redactor.redactions.push({
+ pointer,
+ reason: "explicit-sensitive-value",
+ });
+ return null;
+ }
+ return redactor.text(
+ value,
+ pointer,
+ redactor.harTransportUrl(pointer, parent),
+ );
+ }
+ if (value === null || typeof value === "boolean") return value;
+ if (typeof value === "number")
+ return redactor.text(String(value), pointer) === null ? null : value;
+ if (Array.isArray(value))
+ return value.map((child: SazValue, i: number) =>
+ visit(child, capturePointer(pointer, String(i)), depth + 1),
+ );
+ return Object.fromEntries(
+ Object.entries(value).flatMap(([key, child]) => {
+ if (sensitiveValues.some((literal) => key.includes(literal))) {
+ redactor.redactions.push({
+ pointer,
+ reason: "explicit-sensitive-value",
+ scope: "property-name",
+ });
+ return [];
+ }
+ return [
+ [
+ key,
+ visit(
+ child,
+ capturePointer(pointer, key),
+ depth + 1,
+ value as Readonly>,
+ ),
+ ],
+ ];
+ }),
+ );
+ };
+ return {
+ reported: visit(source, "", 0),
+ binary_fields,
+ numeric_literals: [],
+ redactions: redactor.redactions,
+ };
+};
diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts
index 1b902969c..336c79364 100644
--- a/src/cli/webNetworkCaptureCommands.ts
+++ b/src/cli/webNetworkCaptureCommands.ts
@@ -1,4 +1,5 @@
import { z } from "incur";
+import { WEB_NETWORK_CAPTURE_FORMATS } from "../domain/webNetworkCapture.js";
import { createWebNetworkCaptureService } from "../composition/webNetworkCaptures.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -16,10 +17,12 @@ export const registerWebNetworkCaptureCommands = (
const service = createWebNetworkCaptureService(environment);
cli.command(CLI_COMMANDS.inspectWebNetworkCapture, {
description:
- "Inspect retained HAR or native mitmproxy records without fetching recorded URLs",
+ "Inspect retained HAR, native mitmproxy, or Fiddler SAZ records without fetching recorded URLs",
args: z.object({
capturePath: z.string().describe("Absolute local capture path"),
- format: z.enum(["har", "mitmproxy"]).describe("Producer capture format"),
+ format: z
+ .enum(WEB_NETWORK_CAPTURE_FORMATS)
+ .describe("Producer capture format"),
}),
options: z.object({
record: z
diff --git a/src/composition/webNetworkCaptures.ts b/src/composition/webNetworkCaptures.ts
index 6dc7668d4..017182500 100644
--- a/src/composition/webNetworkCaptures.ts
+++ b/src/composition/webNetworkCaptures.ts
@@ -1,6 +1,6 @@
import { WebNetworkCaptureService } from "../application/WebNetworkCaptureService.js";
import { HistoricalCaptureDecoder } from "../browser/history/HistoricalCaptureDecoder.js";
-import { HarCaptureAdapter } from "../browser/history/HarCaptureAdapter.js";
+import { NodeCaptureAdapter } from "../browser/history/NodeCaptureAdapter.js";
import { MitmproxyCaptureAdapter } from "../browser/history/MitmproxyCaptureAdapter.js";
/** Compose offline format adapters without import-time reads, installation or process startup. */
@@ -9,7 +9,11 @@ export const createWebNetworkCaptureService = (
): WebNetworkCaptureService =>
new WebNetworkCaptureService(
new HistoricalCaptureDecoder(
- [new HarCaptureAdapter(), new MitmproxyCaptureAdapter(environment)],
+ [
+ new NodeCaptureAdapter("har"),
+ new NodeCaptureAdapter("saz"),
+ new MitmproxyCaptureAdapter(environment),
+ ],
environment,
),
);
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index a4004e85b..161d9d7b4 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -16,7 +16,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_evm_interface, inspect_managed_artifact, inspect_android_package or inspect_web_network_capture Evidence without repeating analysis. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
+ 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
diff --git a/src/contracts/webNetworkCaptureToolContracts.ts b/src/contracts/webNetworkCaptureToolContracts.ts
index cb919172f..06420f960 100644
--- a/src/contracts/webNetworkCaptureToolContracts.ts
+++ b/src/contracts/webNetworkCaptureToolContracts.ts
@@ -4,7 +4,11 @@ import {
} from "../domain/webNetworkCapture.js";
import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
-import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
+import {
+ analysisDetailSchema,
+ analysisDetailOutputSchemaOf,
+} from "./analysisDetail.js";
+import { webNetworkCaptureSummaryViewResultSchema } from "../domain/analysisView/analysisView.js";
/** Historical inspection is distinct from live browser capture and runtime authority. */
export const WEB_NETWORK_CAPTURE_TOOL_CONTRACTS = [
@@ -12,10 +16,15 @@ export const WEB_NETWORK_CAPTURE_TOOL_CONTRACTS = [
name: "inspect_web_network_capture",
...toolContractMetadata("inspect_web_network_capture"),
description:
- "Inspect retained local HAR 1.2 or native mitmproxy capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named members such as __proto__ are preserved as ordinary own JSON members without prototype mutation. Uses pinned upstream parsers, owned temporary files and bounded offline processes. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.",
+ "Request detail summary for capture identity and counts; then page retained records with inspect_analysis_view kind capture (preview or complete), avoiding repeated decoding. Default detail complete preserves existing inline delivery. Inspect retained local HAR 1.2, native mitmproxy, or Fiddler SAZ capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges/archive member names, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named members such as __proto__ are preserved as ordinary own JSON members without prototype mutation. Uses pinned upstream parsers, owned temporary files and bounded offline processes. SAZ preserves HTTP fields, XML metadata and encoded body/message bytes without replay or HAR conversion; encrypted archives are unsupported. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.",
kind: "browser-provider",
- inputSchema: inspectWebNetworkCaptureInputSchema,
- outputSchema: evidenceResultOf(webNetworkCaptureSchema),
+ inputSchema: inspectWebNetworkCaptureInputSchema.extend({
+ detail: analysisDetailSchema,
+ }),
+ outputSchema: analysisDetailOutputSchemaOf(
+ webNetworkCaptureSchema,
+ webNetworkCaptureSummaryViewResultSchema,
+ ),
examples: [
{
title: "Inspect historical HAR entries",
diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts
index 784d5dbed..6b3b6bef8 100644
--- a/src/domain/analysisView/analysisView.ts
+++ b/src/domain/analysisView/analysisView.ts
@@ -55,6 +55,12 @@ import {
wasmViewItemSchema,
wasmSummarySchema,
} from "./wasmArtifactView.js";
+import {
+ projectWebNetworkCaptureView,
+ captureViewRequestSchema,
+ captureViewItemsSchema,
+ captureSummarySchema,
+} from "./webNetworkCaptureView.js";
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
import {
projectAndroidPackageView,
@@ -177,6 +183,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
managedViewRequestSchema,
androidViewRequestSchema,
apiSurfaceViewSchema,
+ captureViewRequestSchema,
]);
// The application owns admission and authenticated Evidence parsing. SDK and
@@ -216,7 +223,7 @@ const artifactSchema = z.strictObject({
path: z
.string()
.describe("Exact recorded artifact path, including an empty string."),
- sha256: digestSchema,
+ sha256: digestSchema.nullable(),
});
const layoutSummarySchema = z.strictObject({
@@ -290,6 +297,7 @@ const viewResultBase = {
"inspect_wasm_artifact",
"inspect_evm_interface",
"inspect_managed_artifact",
+ "inspect_web_network_capture",
"inspect_android_package",
]),
parent_digest: digestSchema,
@@ -348,6 +356,13 @@ export const managedArtifactSummaryViewResultSchema = z.strictObject({
summary: managedSummarySchema,
});
+/** Exact historical capture summary, with no body or member-inventory payload. */
+export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
+ ...summaryViewResultBase,
+ parent_operation: z.literal("inspect_web_network_capture"),
+ summary: captureSummarySchema,
+});
+
/** Exact package declarations with retained decoded manifest text. */
export const androidPackageSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -377,9 +392,17 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
wasmSummarySchema,
evmSummarySchema,
managedSummarySchema,
+ captureSummarySchema,
androidPackageSummarySchema,
]),
}),
+ z.strictObject({
+ kind: z.literal("capture"),
+ view: captureViewRequestSchema,
+ items: captureViewItemsSchema,
+ ...viewResultBase,
+ parent_operation: z.literal("inspect_web_network_capture"),
+ }),
z.strictObject({
kind: z.literal("facet"),
view: facetViewSchema,
@@ -500,6 +523,10 @@ export type UnsignedAnalysisView =
>
| Omit, "view_digest">
| Omit, "view_digest">
+ | Omit<
+ Extract,
+ "view_digest"
+ >
| Omit<
Extract,
"view_digest"
@@ -603,10 +630,7 @@ export const projectAnalysisView = (
},
]),
);
- if (
- view.kind === "api-surface" ||
- parent.operation === "inspect_web_network_capture"
- ) {
+ if (view.kind === "api-surface") {
const projected = projectWebNetworkApiSurfaceView(
parent,
view,
@@ -614,6 +638,10 @@ export const projectAnalysisView = (
);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
+ if (parent.operation === "inspect_web_network_capture") {
+ const projected = projectWebNetworkCaptureView(parent, view);
+ return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
+ }
if (parent.operation === "inspect_binary_layout") {
const layout = binaryLayoutSchema.safeParse(parent.normalizedResult);
if (!layout.success)
diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts
index f4c09a68d..41a22f8d9 100644
--- a/src/domain/analysisView/javascriptApplicationView.ts
+++ b/src/domain/analysisView/javascriptApplicationView.ts
@@ -241,22 +241,14 @@ export const projectJavaScriptApplicationView = (
analysis: JavaScriptApplicationAnalysisResult,
view: Exclude,
): Result => {
+ if (view.kind !== "summary" && view.kind !== "item" && view.kind !== "page")
+ return err(
+ jsIncompatible(
+ "JavaScript application Evidence supports summary, module item or module page views.",
+ ),
+ );
const shared = parentFields(parent, analysis);
const modules = moduleEntries(analysis.graph.nodes);
- if (view.kind === "native")
- return err(
- jsIncompatible("Native views require analyze_function Evidence."),
- );
- if (view.kind === "inventory")
- return err(jsIncompatible("Inventory views require artifact Evidence."));
- if (view.kind === "wasm")
- return err(
- jsIncompatible("WASM views require inspect_wasm_artifact Evidence."),
- );
- if (view.kind === "evm")
- return err(
- jsIncompatible("EVM views require inspect_evm_interface Evidence."),
- );
if (view.kind === "summary")
return ok({
kind: "summary",
@@ -291,12 +283,6 @@ export const projectJavaScriptApplicationView = (
coverage: completeCoverage(modules.length, analysis.graph.nodes.length),
...shared,
});
- if (view.kind === "facet")
- return err(
- jsIncompatible(
- "facet views apply to inspect_binary_layout Evidence (mitigations or linkage).",
- ),
- );
if (view.kind === "item") {
if (view.collection !== "modules")
return err(
diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts
index f13e392ff..b57d54b59 100644
--- a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts
+++ b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts
@@ -85,11 +85,13 @@ it("pages groups independently of record completeness and preserves null digests
});
it("rejects native captures and malformed parents with typed errors", () => {
const capture = analysisViewCaptureFixture();
- capture.format = "mitmproxy";
- expect(project(capture)).toMatchObject({
- ok: false,
- error: { _tag: "AnalysisUnsupportedTargetError" },
- });
+ for (const format of ["mitmproxy", "saz"] as const) {
+ capture.format = format;
+ expect(project(capture)).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisUnsupportedTargetError" },
+ });
+ }
expect(
projectAnalysisView(
{
diff --git a/src/domain/analysisView/webNetworkCaptureView.test.ts b/src/domain/analysisView/webNetworkCaptureView.test.ts
new file mode 100644
index 000000000..ff73b29a6
--- /dev/null
+++ b/src/domain/analysisView/webNetworkCaptureView.test.ts
@@ -0,0 +1,176 @@
+import { expect, it } from "vitest";
+import { createHash } from "node:crypto";
+import { createEvidence } from "../evidence.js";
+import { webNetworkCaptureSchema } from "../webNetworkCapture.js";
+import { projectAnalysisView } from "./analysisView.js";
+
+const capture = () => {
+ const body = Buffer.from("original body");
+ const artifact = {
+ path: "/recordings/input.saz",
+ sha256: "b".repeat(64),
+ bytes: 100,
+ };
+ const decoder = { id: "fixture", name: "Fixture", version: "1" };
+ const report = webNetworkCaptureSchema.parse({
+ artifact,
+ decoder,
+ format: "saz",
+ runtime_attribution: "unknown",
+ limitations: [],
+ container: {
+ reported: { members: [] },
+ numeric_literals: [],
+ redactions: [],
+ records_pointer: null,
+ },
+ total_records: 9,
+ records: [7, 2].map((ordinal) => ({
+ ordinal,
+ location: { kind: "archive-members", members: [`raw/${ordinal}_c.txt`] },
+ reported: {
+ session_id: String(ordinal),
+ time: null,
+ request: {
+ method: "POST",
+ url: "https://example.invalid/a",
+ body: body.toString(),
+ },
+ },
+ binary_fields: [
+ {
+ pointer: "/request/body",
+ representation: "producer-bytes",
+ state: "retained",
+ content_base64: body.toString("base64"),
+ bytes: body.length,
+ sha256: createHash("sha256").update(body).digest("hex"),
+ },
+ ],
+ numeric_literals: [
+ {
+ pointer: "/time",
+ producer_type: "json-number",
+ literal: "9007199254740993",
+ },
+ ],
+ redactions: [],
+ limitations: [],
+ })),
+ });
+ const evidence = createEvidence({ ...artifact, format: "file" }, decoder, {
+ operation: "inspect_web_network_capture",
+ parameters: { record_ordinals: [7, 2] },
+ result: report,
+ confidence: "observed",
+ authority: "historical-reference",
+ });
+ return {
+ report,
+ parent: {
+ evidenceId: evidence.evidence_id,
+ operation: evidence.operation,
+ normalizedResult: report,
+ subject: evidence.subject,
+ limitations: evidence.limitations,
+ },
+ };
+};
+
+it("keeps selected-record page coverage distinct from capture coverage and unsafe number absence", () => {
+ const { parent, report } = capture();
+ const summary = projectAnalysisView(parent, { kind: "summary" });
+ expect(summary.ok && summary.value).toMatchObject({
+ summary: { total_records: 9, retained_records: 2 },
+ coverage: { examined: 2, total: 2 },
+ });
+ const preview = projectAnalysisView(parent, {
+ kind: "capture",
+ detail: "preview",
+ offset: 0,
+ limit: 1,
+ });
+ expect(preview.ok && preview.value).toMatchObject({
+ items: [
+ {
+ ordinal: 7,
+ reported: { time: null },
+ numeric_literals: [{ pointer: "/time", literal: "9007199254740993" }],
+ },
+ ],
+ coverage: { total: 2, examined: 1, next_offset: 1, exhausted: false },
+ });
+ expect(JSON.stringify(preview)).not.toContain("original body");
+ expect(JSON.stringify(preview)).not.toContain("content_base64");
+ const complete = projectAnalysisView(parent, {
+ kind: "capture",
+ detail: "complete",
+ offset: 1,
+ limit: 1,
+ });
+ expect(complete.ok && complete.value).toMatchObject({
+ items: [report.records[1]],
+ coverage: { total: 2, examined: 1, next_offset: null, exhausted: true },
+ });
+ const empty = projectAnalysisView(parent, {
+ kind: "capture",
+ detail: "preview",
+ offset: 50,
+ limit: 1,
+ });
+ expect(empty.ok && empty.value).toMatchObject({
+ items: [],
+ coverage: {
+ status: "empty",
+ total: 2,
+ examined: 0,
+ next_offset: null,
+ exhausted: true,
+ },
+ });
+});
+
+it("retains explicitly unknown digests and rejects a different subject path", () => {
+ const { parent, report } = capture();
+ const unknown = projectAnalysisView(
+ {
+ ...parent,
+ subject: null,
+ normalizedResult: {
+ ...report,
+ artifact: { ...report.artifact, sha256: null },
+ },
+ },
+ { kind: "summary" },
+ );
+ expect(unknown.ok && unknown.value).toMatchObject({
+ artifact: { sha256: null },
+ });
+ expect(unknown.ok && unknown.value.unknowns.join(" ")).toContain(
+ "excluded artifact digest",
+ );
+ if (parent.subject === null) throw new Error("Expected fixture subject");
+ const mismatched = projectAnalysisView(
+ {
+ ...parent,
+ subject: { ...parent.subject, local_path: "/different/source.saz" },
+ },
+ { kind: "summary" },
+ );
+ expect(mismatched.ok).toBe(false);
+});
+
+it("rejects invalid ordinal coverage in a borrowed parent", () => {
+ const { parent, report } = capture();
+ const result = projectAnalysisView(
+ {
+ ...parent,
+ normalizedResult: {
+ ...report,
+ records: [report.records[0], report.records[0]],
+ },
+ },
+ { kind: "summary" },
+ );
+ expect(result.ok).toBe(false);
+});
diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts
new file mode 100644
index 000000000..07495466c
--- /dev/null
+++ b/src/domain/analysisView/webNetworkCaptureView.ts
@@ -0,0 +1,212 @@
+import { z } from "zod";
+import type { AnalysisError } from "../analysisErrorBase.js";
+import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
+import type { JsonValue } from "../jsonValue.js";
+import { err, ok, type Result } from "../result.js";
+import {
+ webNetworkCaptureRecordSchema,
+ webNetworkCaptureBinaryDescriptorSchema,
+ webNetworkCaptureSchema,
+ type WebNetworkCaptureRecord,
+} from "../webNetworkCapture.js";
+import {
+ analysisViewInputError,
+ type AnalysisViewParent,
+ type AnalysisViewRequest,
+ type UnsignedAnalysisView,
+} from "./analysisView.js";
+import {
+ completeWithinViewCoverage,
+ pageViewCoverage,
+} from "./analysisViewCoverage.js";
+
+/** Offsets page the parent's retained record order, preserving producer ordinals. */
+export const captureViewRequestSchema = z.strictObject({
+ kind: z.literal("capture"),
+ detail: z.enum(["preview", "complete"]).default("preview"),
+ offset: z.number().int().nonnegative().default(0),
+ limit: z.number().int().positive().default(64),
+});
+
+const previewSchema = webNetworkCaptureRecordSchema.extend({
+ binary_fields: z.array(webNetworkCaptureBinaryDescriptorSchema),
+});
+export const captureViewItemsSchema = z.array(
+ z.union([webNetworkCaptureRecordSchema, previewSchema]),
+);
+
+/** Count/identity overview; bodies and the archive member inventory stay in the parent. */
+export const captureSummarySchema = z.strictObject({
+ artifact: webNetworkCaptureSchema.shape.artifact,
+ decoder: webNetworkCaptureSchema.shape.decoder,
+ format: webNetworkCaptureSchema.shape.format,
+ total_records: z.number().int().nonnegative(),
+ retained_records: z.number().int().nonnegative(),
+ runtime_attribution: z.literal("unknown"),
+});
+
+const object = (
+ value: JsonValue,
+): Readonly> | null =>
+ value !== null && typeof value === "object" && !Array.isArray(value)
+ ? value
+ : null;
+
+/** Select already parsed producer facts without interpreting HTTP or payload bytes again. */
+const preview = (record: WebNetworkCaptureRecord) => {
+ const source = object(record.reported);
+ const reported: Record = {};
+ const selectedPointers = new Set();
+ if (source !== null) {
+ for (const key of ["session_id", "name", "time", "startedDateTime", "type"])
+ if (Object.hasOwn(source, key) && source[key] !== undefined) {
+ reported[key] = source[key];
+ selectedPointers.add(`/${key}`);
+ }
+ for (const key of ["request", "response"]) {
+ const value = source[key];
+ if (!Object.hasOwn(source, key) || value === undefined) continue;
+ const fields = object(value);
+ if (fields === null) {
+ // Preserve a reported null/scalar instead of inventing HTTP fields.
+ reported[key] = value;
+ continue;
+ }
+ const selected: Record = {};
+ for (const field of [
+ "method",
+ "url",
+ "httpVersion",
+ "http_version",
+ "status",
+ "statusText",
+ "reason",
+ ])
+ if (Object.hasOwn(fields, field) && fields[field] !== undefined) {
+ selected[field] = fields[field];
+ selectedPointers.add(`/${key}/${field}`);
+ }
+ reported[key] = selected;
+ }
+ }
+ return {
+ ...record,
+ reported: source === null ? record.reported : reported,
+ binary_fields: record.binary_fields.map(
+ ({ content_base64: _content, ...field }) => field,
+ ),
+ numeric_literals:
+ source === null
+ ? record.numeric_literals
+ : record.numeric_literals.filter(({ pointer }) =>
+ selectedPointers.has(pointer),
+ ),
+ };
+};
+
+/** Page immutable retained facts; no capture file, provider or network is consulted. */
+export const projectWebNetworkCaptureView = (
+ parent: AnalysisViewParent,
+ view: AnalysisViewRequest,
+): Result => {
+ if (view.kind !== "summary" && view.kind !== "capture")
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view"],
+ reason: "invalid_value",
+ message:
+ "Historical captures support summary, capture record pages, and admitted api-surface views.",
+ },
+ ]),
+ );
+ const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(
+ "inspect_analysis_view",
+ parsed.error.issues.map((issue) => ({
+ ...issue,
+ path: ["source", "normalized_result", ...issue.path],
+ })),
+ { source: { normalized_result: parent.normalizedResult } },
+ { cause: parsed.error },
+ ),
+ );
+ const report = parsed.data;
+ if (
+ report.artifact.sha256 !== null &&
+ (parent.subject == null ||
+ parent.subject.local_path !== report.artifact.path ||
+ parent.subject.digest.sha256 !== report.artifact.sha256)
+ )
+ return err(
+ analysisViewInputError([
+ {
+ path: ["source", "subject"],
+ reason: "invalid_value",
+ message:
+ "Capture parent subject must identify the report's exact path and SHA-256.",
+ },
+ ]),
+ );
+ const shared = {
+ parent_evidence_id: parent.evidenceId,
+ parent_operation: "inspect_web_network_capture" as const,
+ parent_digest: parent.evidenceId.slice(3),
+ artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
+ limitations: [
+ ...new Set([
+ ...parent.limitations,
+ ...report.limitations,
+ "Pages cover the records selected into the parent Evidence, in that selected order; total_records counts the original decoded capture.",
+ ]),
+ ],
+ unknowns: [
+ "Historical records do not establish live runtime attribution or deployment authenticity.",
+ ...(report.artifact.sha256 === null
+ ? ["The explicitly excluded artifact digest remains unknown."]
+ : []),
+ ],
+ };
+ if (view.kind === "summary")
+ return ok({
+ kind: "summary",
+ view,
+ ...shared,
+ summary: {
+ artifact: report.artifact,
+ decoder: report.decoder,
+ format: report.format,
+ total_records: report.total_records,
+ retained_records: report.records.length,
+ runtime_attribution: report.runtime_attribution,
+ },
+ coverage: completeWithinViewCoverage(
+ report.records.length,
+ report.records.length,
+ ),
+ });
+ const records = report.records.slice(view.offset, view.offset + view.limit);
+ return ok({
+ kind: "capture",
+ view,
+ ...shared,
+ items: captureViewItemsSchema.parse(
+ view.detail === "complete" ? records : records.map(preview),
+ ),
+ coverage: pageViewCoverage(
+ view.offset,
+ records.length,
+ report.records.length,
+ ),
+ limitations: [
+ ...shared.limitations,
+ ...(view.detail === "preview"
+ ? [
+ "Preview selects recorded headline fields and byte descriptors; headers, XML, bodies, message data and other fields remain in complete record pages. Missing preview fields do not establish their absence from the producer record.",
+ ]
+ : []),
+ ],
+ });
+};
diff --git a/src/domain/webNetworkCapture.ts b/src/domain/webNetworkCapture.ts
index ed31d5c70..ec98ca496 100644
--- a/src/domain/webNetworkCapture.ts
+++ b/src/domain/webNetworkCapture.ts
@@ -10,12 +10,14 @@ export const WEB_NETWORK_CAPTURE_LIMITS = {
depth: 64,
} as const;
+export const WEB_NETWORK_CAPTURE_FORMATS = ["har", "mitmproxy", "saz"] as const;
+
/** Inspect an explicit retained capture without contacting its recorded destinations. */
export const inspectWebNetworkCaptureInputSchema = z.strictObject({
capture_path: localPathStringSchema.describe(
- "Absolute local HAR or mitmproxy capture path.",
+ "Absolute local HAR, mitmproxy, or Fiddler SAZ capture path.",
),
- format: z.enum(["har", "mitmproxy"]),
+ format: z.enum(WEB_NETWORK_CAPTURE_FORMATS),
record_ordinals: z
.array(z.number().int().nonnegative())
.min(1)
@@ -31,6 +33,21 @@ export const inspectWebNetworkCaptureInputSchema = z.strictObject({
),
});
+/** Read declarations through their owning schema for service and adapter failures. */
+export const captureSensitiveValues = (
+ rawInput: unknown,
+): readonly string[] => {
+ const parsed =
+ inspectWebNetworkCaptureInputSchema.shape.sensitive_values.safeParse(
+ typeof rawInput === "object" &&
+ rawInput !== null &&
+ "sensitive_values" in rawInput
+ ? rawInput.sensitive_values
+ : undefined,
+ );
+ return parsed.success ? parsed.data : [];
+};
+
const pointerSchema = z
.string()
.describe("RFC 6901 pointer in the producer's reported object.");
@@ -53,21 +70,26 @@ const binaryBase = {
pointer: pointerSchema,
representation: z.enum(["producer-bytes", "har-base64-content"]),
};
+const retainedBinaryDescriptorSchema = z.strictObject({
+ ...binaryBase,
+ state: z.literal("retained"),
+ bytes: z.number().int().nonnegative(),
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
+});
+const redactedBinaryDescriptorSchema = z.strictObject({
+ ...binaryBase,
+ state: z.literal("redacted"),
+ bytes: z.null(),
+ sha256: z.null(),
+});
+/** Exact byte facts shared by complete records and previews that omit content. */
+export const webNetworkCaptureBinaryDescriptorSchema = z.discriminatedUnion(
+ "state",
+ [retainedBinaryDescriptorSchema, redactedBinaryDescriptorSchema],
+);
const binarySchema = z.discriminatedUnion("state", [
- z.strictObject({
- ...binaryBase,
- state: z.literal("retained"),
- content_base64: z.string(),
- bytes: z.number().int().nonnegative(),
- sha256: z.string().regex(/^[0-9a-f]{64}$/),
- }),
- z.strictObject({
- ...binaryBase,
- state: z.literal("redacted"),
- content_base64: z.null(),
- bytes: z.null(),
- sha256: z.null(),
- }),
+ retainedBinaryDescriptorSchema.extend({ content_base64: z.string() }),
+ redactedBinaryDescriptorSchema.extend({ content_base64: z.null() }),
]);
const numberSchema = z.strictObject({
pointer: pointerSchema,
@@ -84,6 +106,10 @@ export const webNetworkCaptureRecordSchema = z.strictObject({
reason: z.literal("explicit-sensitive-value"),
}),
z.strictObject({ kind: z.literal("json-pointer"), pointer: pointerSchema }),
+ z.strictObject({
+ kind: z.literal("archive-members"),
+ members: z.array(z.string()).min(1),
+ }),
z.strictObject({
kind: z.literal("byte-range"),
offset: z.number().int().nonnegative(),
@@ -156,7 +182,7 @@ export const webNetworkCaptureSchema =
),
bytes: z.number().int().nonnegative(),
}),
- format: z.enum(["har", "mitmproxy"]),
+ format: z.enum(WEB_NETWORK_CAPTURE_FORMATS),
runtime_attribution: z.literal("unknown"),
limitations: z.array(z.string()),
});
diff --git a/src/domain/webNetworkCaptureCoordinates.ts b/src/domain/webNetworkCaptureCoordinates.ts
index 6cba46cd9..617dd7607 100644
--- a/src/domain/webNetworkCaptureCoordinates.ts
+++ b/src/domain/webNetworkCaptureCoordinates.ts
@@ -22,6 +22,8 @@ export const excludeCaptureCoordinates = (
const location =
(record.location.kind === "json-pointer" &&
sensitive(record.location.pointer)) ||
+ (record.location.kind === "archive-members" &&
+ record.location.members.some(sensitive)) ||
(record.location.kind === "byte-range" &&
(sensitive(String(record.location.offset)) ||
sensitive(String(record.location.bytes))))
diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts
index 1af9203ad..bada60663 100644
--- a/src/generatedPackageMetadata.ts
+++ b/src/generatedPackageMetadata.ts
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
- skillVersion: "40",
+ skillVersion: "41",
} as const;
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index e445dcfab..c213efb0b 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -617,6 +617,7 @@ const registerConfiguredAnalysisTools = (
toolLogger,
recordEvidence,
withAdmittedAnalysis,
+ evidenceById,
);
registerJavaScriptRecoveryTool(
server,
diff --git a/src/server/registerWebNetworkCaptureTool.ts b/src/server/registerWebNetworkCaptureTool.ts
index 9f0c9dec8..8b1203745 100644
--- a/src/server/registerWebNetworkCaptureTool.ts
+++ b/src/server/registerWebNetworkCaptureTool.ts
@@ -1,5 +1,14 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { StandardSchemaWithJSON } from "@modelcontextprotocol/server";
+import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
+import {
+ requireAnalysisViewRetention,
+ summarizeRetainedAnalysis,
+} from "../application/analysisView/AnalysisViewService.js";
+import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
+import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js";
+import { captureSensitiveValues } from "../domain/webNetworkCapture.js";
+import { err } from "../domain/result.js";
import type { WebNetworkCaptureService } from "../application/WebNetworkCaptureService.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { toolContract } from "../contracts/toolContracts.js";
@@ -15,6 +24,7 @@ export const registerWebNetworkCaptureTool = (
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
+ evidenceById?: EvidenceLookup,
): void => {
const contract = toolContract("inspect_web_network_capture");
const registration = toolRegistrationOptions(contract);
@@ -30,7 +40,31 @@ export const registerWebNetworkCaptureTool = (
};
const handler = createEvidenceToolHandler(
server,
- (input, options) => service.inspect(input, options),
+ async (rawInput, options) => {
+ const parsed = contract.inputSchema.safeParse(rawInput);
+ if (!parsed.success)
+ return err(
+ redactExplicitFailure(
+ analysisInputErrorFromIssues(
+ contract.name,
+ parsed.error.issues,
+ rawInput,
+ { cause: parsed.error },
+ ),
+ captureSensitiveValues(rawInput),
+ ),
+ );
+ const { detail, ...input } = parsed.data;
+ const retention = { recordEvidence, evidenceById };
+ if (detail === "summary") {
+ const ready = requireAnalysisViewRetention(contract.name, retention);
+ if (!ready.ok) return ready;
+ }
+ const inspected = await service.inspect(input, options);
+ return detail === "summary" && inspected.ok
+ ? summarizeRetainedAnalysis(inspected.value, retention)
+ : inspected;
+ },
logger,
recordEvidence,
withAdmittedAnalysis,
diff --git a/tests/acceptance/tools/historicalCaptureResources.test.ts b/tests/acceptance/tools/historicalCaptureResources.test.ts
index d4c56d644..64a7b6638 100644
--- a/tests/acceptance/tools/historicalCaptureResources.test.ts
+++ b/tests/acceptance/tools/historicalCaptureResources.test.ts
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
-import { HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../../src/browser/history/CaptureRelease.js";
+import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../../src/browser/history/CaptureRelease.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../../src/domain/webNetworkCapture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
@@ -46,7 +46,7 @@ const capture = (count: number) =>
// Node.js enforces the decoder's V8 heap flags. Bun ignores them; on Windows
// the decoder's Job Object commit limit is the enforced bound instead, and
// Bun elsewhere has no bound this capture can reach.
-const jobLimit = HAR_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES;
+const jobLimit = NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES;
it.skipIf(process.versions.bun !== undefined && jobLimit === null)(
"reports real HAR memory exhaustion through CLI and MCP and preserves server usability",
async () => {
diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts
new file mode 100644
index 000000000..69eca33b8
--- /dev/null
+++ b/tests/boundary/browser/sazWebCapture.test.ts
@@ -0,0 +1,499 @@
+import { createHash } from "node:crypto";
+import { execFile } from "node:child_process";
+import { readFile, unlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { fileURLToPath } from "node:url";
+import { gzipSync } from "node:zlib";
+import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { expect, it as runtimeTest } from "vitest";
+import { z } from "zod";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
+import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+import {
+ NODE_CAPTURE_HEAP_ENFORCED,
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+} from "../../../src/browser/history/CaptureRelease.js";
+const hasMemoryBound =
+ NODE_CAPTURE_HEAP_ENFORCED ||
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null;
+
+const it = runtimeTest.skipIf(!hasMemoryBound);
+
+// Synthetic member fixtures based on Telerik's SAZ layout; not a real-producer golden.
+const compressedBody = gzipSync(Buffer.from("stored compressed body"));
+const members = (): [string, Buffer][] => [
+ [
+ "raw/0042_s.txt",
+ Buffer.concat([
+ Buffer.from(
+ "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nSet-Cookie: server-credential\r\n\r\n",
+ ),
+ compressedBody,
+ ]),
+ ],
+ [
+ "raw/7_c.txt",
+ Buffer.concat([
+ Buffer.from("POST /bytes HTTP/2.0\r\n:authority: example.test\r\n\r\n"),
+ Buffer.from([0xff, 0, 0x80]),
+ ]),
+ ],
+ [
+ "raw/0042_c.txt",
+ Buffer.from(
+ "GET https://user:password@example.test/a HTTP/1.1\r\nAuthorization: Bearer client-credential\r\nCookie: cookie-credential\r\nX-Duplicate: one\r\nX-Duplicate: two\r\nX-Text: unicode-\u03b2\r\n\r\n",
+ ),
+ ],
+ [
+ "raw/0042_m.xml",
+ Buffer.from(
+ '',
+ ),
+ ],
+ ["raw/0042_w.txt", Buffer.from("opaque websocket message file")],
+ ["raw/7_g.txt", Buffer.from([0, 1, 2])],
+ ["[Content_Types].xml", Buffer.from("")],
+];
+const archive = async (entries = members(), password?: string, level = 6) => {
+ const writer = new ZipWriter(new Uint8ArrayWriter(), {
+ useWebWorkers: false,
+ level,
+ ...(password === undefined ? {} : { password }),
+ });
+ for (const [name, bytes] of entries)
+ await writer.add(name, new Uint8ArrayReader(bytes));
+ return Buffer.from(await writer.close());
+};
+const fixture = async (bytes: Buffer) => {
+ const root = await createTestTempDirectory("rea-saz-capture-");
+ const path = join(root, "capture.saz");
+ await writeFile(path, bytes);
+ return path;
+};
+const entrypoint = fileURLToPath(
+ new URL("../../../scripts/rea.mjs", import.meta.url),
+);
+const cliResponse = async (path: string, extra: string[] = []) => {
+ try {
+ const { stdout } = await promisify(execFile)(
+ process.execPath,
+ [
+ entrypoint,
+ "inspect-web-network-capture",
+ path,
+ "saz",
+ "--json",
+ ...extra,
+ ],
+ { timeout: 40_000, maxBuffer: 4 * 1024 * 1024 },
+ );
+ return { ok: true, stdout };
+ } catch (cause: unknown) {
+ if (
+ cause instanceof Error &&
+ "stdout" in cause &&
+ typeof cause.stdout === "string"
+ )
+ return { ok: false, stdout: cause.stdout };
+ throw cause;
+ }
+};
+const cli = async (path: string, extra: string[] = []) => {
+ const result = await cliResponse(path, extra);
+ expect(result.ok, result.stdout).toBe(true);
+ return parseEvidence(JSON.parse(result.stdout));
+};
+
+it("inspects native SAZ through the public CLI and MCP with equal evidence, bytes and archive coordinates", async () => {
+ const bytes = await archive();
+ const path = await fixture(bytes);
+ const evidence = await cli(path, ["--record", "1", "--record", "0"]);
+ const capture = webNetworkCaptureSchema.parse(evidence.normalized_result);
+ expect(capture).toMatchObject({
+ format: "saz",
+ total_records: 2,
+ runtime_attribution: "unknown",
+ records: [
+ { ordinal: 1, reported: { session_id: "7", response: null } },
+ {
+ ordinal: 0,
+ reported: {
+ session_id: "0042",
+ request: { url: "https://example.test/a" },
+ response: { status: "200" },
+ },
+ },
+ ],
+ });
+ expect(capture.records[1]?.location).toEqual({
+ kind: "archive-members",
+ members: [
+ "raw/0042_s.txt",
+ "raw/0042_c.txt",
+ "raw/0042_m.xml",
+ "raw/0042_w.txt",
+ ],
+ });
+ expect(capture.records[1]?.binary_fields).toContainEqual({
+ pointer: "/response/body",
+ representation: "producer-bytes",
+ state: "retained",
+ content_base64: compressedBody.toString("base64"),
+ bytes: compressedBody.length,
+ sha256: createHash("sha256").update(compressedBody).digest("hex"),
+ });
+ expect(capture.records[0]?.binary_fields).toContainEqual(
+ expect.objectContaining({
+ pointer: "/request/body",
+ content_base64: "/wCA",
+ }),
+ );
+ expect(capture.records[1]?.binary_fields).toContainEqual(
+ expect.objectContaining({
+ pointer: "/request/body",
+ bytes: 0,
+ content_base64: "",
+ }),
+ );
+ expect(capture.records[1]?.reported).toMatchObject({
+ request: {
+ headers: [
+ { name: "Authorization", value: null },
+ { name: "Cookie", value: null },
+ { name: "X-Duplicate", value: " one" },
+ { name: "X-Duplicate", value: " two" },
+ { name: "X-Text" },
+ ],
+ },
+ });
+ expect(JSON.stringify(capture)).toContain("retained metadata");
+ for (const secret of [
+ "user:password",
+ "client-credential",
+ "server-credential",
+ "cookie-credential",
+ ])
+ expect(JSON.stringify(capture)).not.toContain(secret);
+ expect(capture.artifact.sha256).toBe(
+ createHash("sha256").update(bytes).digest("hex"),
+ );
+ expect(await readFile(path)).toEqual(bytes);
+
+ const { client, call } = await connectLocalToolsMcp();
+ const tool = (await client.listTools()).tools.find(
+ (item) => item.name === "inspect_web_network_capture",
+ );
+ if (tool?.outputSchema === undefined)
+ throw new Error("Missing capture schema");
+ const args = { capture_path: path, format: "saz", record_ordinals: [1, 0] };
+ const ajv = new Ajv2020({ strict: false, validateFormats: false });
+ expect(ajv.validate(tool.inputSchema, args)).toBe(true);
+ const response = await call("inspect_web_network_capture", args);
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ const fromMcp = parseEvidence(response.structuredContent);
+ expect(fromMcp.normalized_result).toEqual(evidence.normalized_result);
+ const bundle = await client.callTool({
+ name: "get_evidence_bundle",
+ arguments: { detail: "complete" },
+ });
+ const records = z
+ .object({ result: z.object({ records: z.array(z.unknown()) }) })
+ .parse(bundle.structuredContent)
+ .result.records.map(parseEvidence);
+ expect(
+ records.find((record) => record.evidence_id === fromMcp.evidence_id),
+ ).toEqual(fromMcp);
+}, 60_000);
+
+it("pages retained SAZ Evidence through public MCP and portable CLI after source deletion", async () => {
+ const path = await fixture(await archive());
+ const { call } = await connectLocalToolsMcp();
+ const args = { capture_path: path, format: "saz", record_ordinals: [1, 0] };
+ const full = await call("inspect_web_network_capture", args);
+ const fromMcp = parseEvidence(full.structuredContent);
+ const capture = webNetworkCaptureSchema.parse(fromMcp.normalized_result);
+ const summaryArgs = { ...args, detail: "summary" };
+ const summaryResponse = await call(
+ "inspect_web_network_capture",
+ summaryArgs,
+ );
+ expect(summaryResponse.isError, JSON.stringify(summaryResponse)).not.toBe(
+ true,
+ );
+ const summary = parseEvidence(summaryResponse.structuredContent);
+ expect(summary.normalized_result).toMatchObject({
+ kind: "summary",
+ parent_evidence_id: fromMcp.evidence_id,
+ summary: { format: "saz", total_records: 2, retained_records: 2 },
+ });
+ expect(JSON.stringify(summary)).not.toContain(
+ compressedBody.toString("base64"),
+ );
+
+ // Retained pages must keep their snapshot semantics after the input disappears.
+ await unlink(path);
+ const source = {
+ kind: "retained-evidence",
+ evidence_id: fromMcp.evidence_id,
+ };
+ const previewArgs = {
+ source,
+ view: { kind: "capture", offset: 0, limit: 1 },
+ };
+ const previewResponse = await call("inspect_analysis_view", previewArgs);
+ expect(previewResponse.isError, JSON.stringify(previewResponse)).not.toBe(
+ true,
+ );
+ const preview = parseEvidence(previewResponse.structuredContent);
+ expect(preview.normalized_result).toMatchObject({
+ items: [
+ {
+ ordinal: 1,
+ reported: {
+ session_id: "7",
+ request: { method: "POST", url: "/bytes" },
+ },
+ },
+ ],
+ coverage: { examined: 1, total: 2, next_offset: 1, exhausted: false },
+ });
+ expect(JSON.stringify(preview)).not.toContain("content_base64");
+ const completeResponse = await call("inspect_analysis_view", {
+ source,
+ view: { kind: "capture", detail: "complete", offset: 1, limit: 1 },
+ });
+ expect(completeResponse.isError, JSON.stringify(completeResponse)).not.toBe(
+ true,
+ );
+ const complete = parseEvidence(completeResponse.structuredContent);
+ expect(complete.normalized_result).toMatchObject({
+ items: [capture.records[1]],
+ coverage: { next_offset: null, exhausted: true },
+ });
+
+ const workflow = join(path, "..", "summary-view.json");
+ await writeFile(
+ workflow,
+ JSON.stringify({
+ source: { kind: "inline", evidence: fromMcp },
+ view: { kind: "summary" },
+ }),
+ );
+ const standalone = await promisify(execFile)(process.execPath, [
+ entrypoint,
+ "inspect-analysis-view",
+ workflow,
+ "--json",
+ ]);
+ expect(
+ parseEvidence(JSON.parse(standalone.stdout)).normalized_result,
+ ).toEqual(summary.normalized_result);
+}, 60_000);
+
+it("keeps CRLF separators inside a body after LF-only HTTP headers", async () => {
+ const body = Buffer.from("\ufefffirst\r\n\r\nsecond\n\nthird");
+ const bytes = await archive([
+ [
+ "raw/1_c.txt",
+ Buffer.concat([
+ Buffer.from("POST https://example.test/ HTTP/1.1\nX-Test: request\n\n"),
+ body,
+ ]),
+ ],
+ [
+ "raw/1_s.txt",
+ Buffer.concat([
+ Buffer.from("HTTP/1.1 200 OK\nX-Test: response\n\n"),
+ body,
+ ]),
+ ],
+ ]);
+ const path = await fixture(bytes);
+ const capture = webNetworkCaptureSchema.parse(
+ (await cli(path)).normalized_result,
+ );
+ expect(capture.records[0]?.reported).toMatchObject({
+ request: {
+ headers: [{ name: "X-Test", value: " request" }],
+ body: body.toString("utf8"),
+ },
+ response: {
+ headers: [{ name: "X-Test", value: " response" }],
+ body: body.toString("utf8"),
+ },
+ });
+ for (const pointer of ["/request/body", "/response/body"])
+ expect(capture.records[0]?.binary_fields).toContainEqual({
+ pointer,
+ representation: "producer-bytes",
+ state: "retained",
+ content_base64: body.toString("base64"),
+ bytes: body.length,
+ sha256: createHash("sha256").update(body).digest("hex"),
+ });
+ expect(await readFile(path)).toEqual(bytes);
+});
+
+it("excludes explicit literals from XML, HTTP byte views, member coordinates and binary sidecars", async () => {
+ const path = await fixture(await archive());
+ const result = await cli(path, [
+ "--sensitive-value",
+ "0042",
+ "--sensitive-value",
+ "unicode-\u03b2",
+ "--sensitive-value",
+ compressedBody.toString("base64"),
+ "--sensitive-value",
+ "retained metadata",
+ ]);
+ const capture = webNetworkCaptureSchema.parse(result.normalized_result);
+ expect(capture.records[0]?.location).toEqual({
+ kind: "unknown",
+ reason: "explicit-sensitive-value",
+ });
+ expect(capture.records[0]?.binary_fields).toContainEqual({
+ pointer: "/response/body",
+ representation: "producer-bytes",
+ state: "redacted",
+ bytes: null,
+ sha256: null,
+ content_base64: null,
+ });
+ const text = JSON.stringify(result);
+ for (const secret of [
+ "0042",
+ "unicode-",
+ "retained metadata",
+ compressedBody.toString("base64"),
+ createHash("sha256").update(compressedBody).digest("hex"),
+ ])
+ expect(text).not.toContain(secret);
+});
+
+it("retains folded headers, stored chunk framing and BOM-marked UTF-16 metadata", async () => {
+ const body = "3\r\none\r\n0\r\n\r\n";
+ const path = await fixture(
+ await archive([
+ [
+ "raw/1_s.txt",
+ Buffer.from(
+ `HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nX-Folded: first\r\n second\r\n\r\n${body}`,
+ ),
+ ],
+ [
+ "raw/1_m.xml",
+ Buffer.from(
+ '\ufeff\u03b2]]>',
+ "utf16le",
+ ),
+ ],
+ [
+ "raw/2_c.txt",
+ Buffer.from(
+ "CONNECT example.test:443 HTTP/1.1\nHost: example.test:443\n\n",
+ ),
+ ],
+ ]),
+ );
+ const evidence = await cli(path);
+ const capture = webNetworkCaptureSchema.parse(evidence.normalized_result);
+ expect(capture.records[0]?.reported).toMatchObject({
+ request: null,
+ response: {
+ body,
+ headers: [
+ { name: "Transfer-Encoding", value: " chunked" },
+ { name: "X-Folded", value: " first\r\n second" },
+ ],
+ },
+ });
+ expect(capture.records[0]?.binary_fields).toContainEqual(
+ expect.objectContaining({
+ pointer: "/response/body",
+ content_base64: Buffer.from(body).toString("base64"),
+ }),
+ );
+ expect(JSON.stringify(capture.records[0]?.reported)).toContain("\u03b2");
+ expect(capture.records[1]?.reported).toMatchObject({
+ request: { method: "CONNECT", url: "example.test:443", body: "" },
+ });
+});
+
+it.each([
+ "bad-http",
+ "bad-xml",
+ "dtd",
+ "unknown-member",
+ "encrypted",
+ "duplicate",
+ "crc",
+ "expanded-limit",
+])("rejects %s before returning a selected subset", async (variant) => {
+ let entries = members();
+ if (variant === "bad-http")
+ entries.push(["raw/8_c.txt", Buffer.from("malformed-private-input")]);
+ if (variant === "bad-xml")
+ entries.push(["raw/8_m.xml", Buffer.from("")]);
+ if (variant === "dtd")
+ entries.push([
+ "raw/8_m.xml",
+ Buffer.from(
+ ']>&external;',
+ ),
+ ]);
+ if (variant === "unknown-member")
+ entries.push(["raw/8_unknown.txt", Buffer.from("unknown extension")]);
+ if (variant === "duplicate")
+ entries = [
+ ["raw/8_c.txt", Buffer.from("GET /a HTTP/1.1\r\n\r\n")],
+ ["raw/9_c.txt", Buffer.from("GET /b HTTP/1.1\r\n\r\n")],
+ ];
+ let bytes = await archive(
+ entries,
+ variant === "encrypted" ? "fixture-password" : undefined,
+ variant === "crc" ? 0 : 6,
+ );
+ if (variant === "duplicate")
+ bytes = Buffer.from(
+ bytes.toString("latin1").replaceAll("raw/9_c.txt", "raw/8_c.txt"),
+ "latin1",
+ );
+ if (variant === "crc") {
+ const offset = bytes.indexOf(Buffer.from("HTTP/1.1 200 OK"));
+ expect(offset).toBeGreaterThan(0);
+ bytes[offset + 9] = 0x35;
+ }
+ if (variant === "expanded-limit") {
+ const central = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
+ bytes.writeUInt32LE(33 * 1024 * 1024, central + 24);
+ }
+ const path = await fixture(bytes);
+ const result = await cliResponse(path, ["--record", "0"]);
+ expect(result.ok, result.stdout).toBe(false);
+ expect(JSON.parse(result.stdout).category).toBe(
+ ["encrypted", "unknown-member", "dtd"].includes(variant)
+ ? "unsupported_provider"
+ : variant === "expanded-limit"
+ ? "execution_failure"
+ : "invalid_input",
+ );
+ if (variant === "expanded-limit")
+ expect(JSON.parse(result.stdout).code).toBe("unreadable_output");
+ expect(JSON.stringify(result)).not.toContain("malformed-private-input");
+});
+
+runtimeTest.runIf(!hasMemoryBound)(
+ "reports SAZ unavailable when its runtime cannot enforce decoder memory",
+ async () => {
+ const response = await cliResponse(await fixture(await archive()));
+ expect(response.ok).toBe(false);
+ expect(JSON.parse(response.stdout)).toMatchObject({
+ code: "capability_unavailable",
+ });
+ },
+);
diff --git a/website/locales/ar/messages.po b/website/locales/ar/messages.po
index 8a7ea9d40..329f0cd96 100644
--- a/website/locales/ar/messages.po
+++ b/website/locales/ar/messages.po
@@ -70,7 +70,7 @@ msgstr "بواسطة N0zoM1z0 * 10 أكتوبر 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4660,7 +4660,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "نسخ قراءة البرامج النصية المحملة"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "العودة للأعلى"
@@ -11469,7 +11469,7 @@ msgstr "الأدلة"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11489,7 +11489,7 @@ msgstr "الكود المصدري"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11509,7 +11509,7 @@ msgstr "رخصة MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11529,7 +11529,7 @@ msgstr "انضم إلى المجتمع"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12461,6 +12461,23 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "التبديل إلى الوضع الداكن"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"لفحص أجسام طلبات HTTP واستجاباتها، استخدم {ph0}Fiddler Everywhere: تسجيلات "
+"SAZ وMCP والمهارات العامة{ph1}. صدّر بتنسيق SAZ ?? HAR 1.2 للحصول على أدلة "
+"تسجيل في REA دون اتصال، أو اربط خادم MCP في Everywhere لفحص حركة المرور "
+"المسجلة مع وكيلك."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "دليل الإعداد"
diff --git a/website/locales/de/messages.po b/website/locales/de/messages.po
index 3b4690d00..ee3fec463 100644
--- a/website/locales/de/messages.po
+++ b/website/locales/de/messages.po
@@ -71,7 +71,7 @@ msgstr "Von N0zoM1z0 · 10. Oktober 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4854,7 +4854,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Kopieren Lesen Sie die geladenen Skripte"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Nach oben"
@@ -11930,7 +11930,7 @@ msgstr "Anleitungen"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11950,7 +11950,7 @@ msgstr "Quellcode"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11970,7 +11970,7 @@ msgstr "MIT-Lizenz"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11990,7 +11990,7 @@ msgstr "Zur Community"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12939,6 +12939,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Zum dunklen Modus wechseln"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Für HTTP-Anfrage- und Antwortinhalte verwenden Sie {ph0}Fiddler Everywhere: "
+"SAZ-Aufzeichnungen, MCP und öffentliche Skills{ph1}. Exportieren Sie SAZ "
+"oder HAR 1.2 für die Offline-Auswertung der Aufzeichnung als REA Evidence, "
+"oder verbinden Sie den MCP-Server von Everywhere, um aufgezeichneten "
+"Datenverkehr mit Ihrem Agenten zu untersuchen."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Einrichtungsanleitung"
diff --git a/website/locales/es/messages.po b/website/locales/es/messages.po
index a9d6e8fb6..f40b06204 100644
--- a/website/locales/es/messages.po
+++ b/website/locales/es/messages.po
@@ -71,7 +71,7 @@ msgstr "Por N0zoM1z0 * 10 octubre 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4827,7 +4827,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Copiar leer los scripts cargados"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Volver arriba"
@@ -11849,7 +11849,7 @@ msgstr "Guías"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11869,7 +11869,7 @@ msgstr "Código fuente"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11889,7 +11889,7 @@ msgstr "Licencia MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11909,7 +11909,7 @@ msgstr "Únete a la comunidad"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12853,6 +12853,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Cambiar al modo oscuro"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Para examinar los cuerpos de las solicitudes y respuestas HTTP, use "
+"{ph0}Fiddler Everywhere: capturas SAZ, MCP y habilidades públicas{ph1}. "
+"Exporte SAZ o HAR 1.2 para obtener Evidence de capturas en REA sin conexión,"
+" o conecte el servidor MCP de Everywhere para inspeccionar el tráfico "
+"capturado con su agente."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Guía de instalación"
diff --git a/website/locales/fa/messages.po b/website/locales/fa/messages.po
index 65fd093ac..e163a131b 100644
--- a/website/locales/fa/messages.po
+++ b/website/locales/fa/messages.po
@@ -70,7 +70,7 @@ msgstr "توسط N0zoM1z0 * 10 اکتبر 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4694,7 +4694,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "کپی خواندن اسکریپت های لود شده"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "بازگشت به بالا"
@@ -11564,7 +11564,7 @@ msgstr "راهنماها"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11584,7 +11584,7 @@ msgstr "کد منبع"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11604,7 +11604,7 @@ msgstr "مجوز MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11624,7 +11624,7 @@ msgstr "به جامعه بپیوندید"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12563,6 +12563,23 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "تغییر به حالت تیره"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"برای بررسی بدنهٔ درخواستها و پاسخهای HTTP، از {ph0}Fiddler Everywhere: "
+"ضبطهای SAZ، سرور MCP و مهارتهای عمومی{ph1} استفاده کنید. برای Evidence ضبط"
+" در REA بهصورت آفلاین، خروجی SAZ ?? HAR 1.2 بگیرید، یا سرور MCP نرمافزار "
+"Everywhere را متصل کنید تا ترافیک ضبطشده را با عامل خود بررسی کنید."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "راهنمای نصب"
diff --git a/website/locales/fr/messages.po b/website/locales/fr/messages.po
index 899844e40..f6e6ab357 100644
--- a/website/locales/fr/messages.po
+++ b/website/locales/fr/messages.po
@@ -71,7 +71,7 @@ msgstr "Par N0zoM1z0 · 10 octobre 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4834,7 +4834,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Copier lire les scripts chargés"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Haut de page"
@@ -11860,7 +11860,7 @@ msgstr "Guides"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11880,7 +11880,7 @@ msgstr "Code source"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11900,7 +11900,7 @@ msgstr "Licence MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11920,7 +11920,7 @@ msgstr "Rejoindre la communauté"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12867,6 +12867,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Passer au mode sombre"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Pour examiner les corps des requêtes et réponses HTTP, utilisez {ph0}Fiddler"
+" Everywhere : captures SAZ, MCP et compétences publiques{ph1}. Exportez en "
+"SAZ ou HAR 1.2 pour obtenir des Evidence de capture dans REA hors ligne, ou "
+"connectez le serveur MCP d’Everywhere pour inspecter le trafic capturé avec "
+"votre agent."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Guide d’installation"
diff --git a/website/locales/id/messages.po b/website/locales/id/messages.po
index 39e1ae619..6faa06dad 100644
--- a/website/locales/id/messages.po
+++ b/website/locales/id/messages.po
@@ -71,7 +71,7 @@ msgstr "Oleh N0zoM1z0 · 10 Oktober 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4785,7 +4785,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Salin baca skrip yang dimuat"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Kembali ke atas"
@@ -11739,7 +11739,7 @@ msgstr "Panduan"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11759,7 +11759,7 @@ msgstr "Kode sumber"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11779,7 +11779,7 @@ msgstr "Lisensi MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11799,7 +11799,7 @@ msgstr "Gabung komunitas"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12740,6 +12740,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Beralih ke mode gelap"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Untuk memeriksa isi permintaan dan respons HTTP, gunakan {ph0}Fiddler "
+"Everywhere: rekaman SAZ, MCP, dan skill publik{ph1}. Ekspor SAZ atau HAR 1.2"
+" untuk menghasilkan Evidence rekaman di REA secara luring, atau hubungkan "
+"server MCP Everywhere untuk memeriksa lalu lintas yang direkam bersama agen "
+"Anda."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Panduan pemasangan"
diff --git a/website/locales/it/messages.po b/website/locales/it/messages.po
index 0c1aff1c9..fdebe81d8 100644
--- a/website/locales/it/messages.po
+++ b/website/locales/it/messages.po
@@ -70,7 +70,7 @@ msgstr "Di N0zoM1z0 * 10 ottobre 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4809,7 +4809,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Copia leggi gli script caricati"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Torna in alto"
@@ -11797,7 +11797,7 @@ msgstr "Guide"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11817,7 +11817,7 @@ msgstr "Codice sorgente"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11837,7 +11837,7 @@ msgstr "Licenza MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11857,7 +11857,7 @@ msgstr "Unisciti alla community"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12801,6 +12801,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Passa alla modalità scura"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Per esaminare i corpi delle richieste e delle risposte HTTP, usa "
+"{ph0}Fiddler Everywhere: acquisizioni SAZ, MCP e skill pubbliche{ph1}. "
+"Esporta SAZ o HAR 1.2 per ottenere Evidence delle acquisizioni in REA "
+"offline, oppure collega il server MCP di Everywhere per esaminare il "
+"traffico acquisito con il tuo agente."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Guida all’installazione"
diff --git a/website/locales/ja/messages.po b/website/locales/ja/messages.po
index 8f23848c6..10ed8ee27 100644
--- a/website/locales/ja/messages.po
+++ b/website/locales/ja/messages.po
@@ -69,7 +69,7 @@ msgstr "によってN0zoM1z0•10 10月2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4440,7 +4440,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "ロードされたスクリプトをコピーして読みます"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "ページ上部へ"
@@ -10971,7 +10971,7 @@ msgstr "ガイド"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -10991,7 +10991,7 @@ msgstr "ソースコード"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11011,7 +11011,7 @@ msgstr "MIT ライセンス"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11031,7 +11031,7 @@ msgstr "コミュニティに参加"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -11947,6 +11947,22 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "ダークモードに切り替える"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"HTTP リクエストとレスポンスの本文を調べるには、{ph0}Fiddler Everywhere:SAZ "
+"キャプチャ、MCP、公開スキル{ph1}を利用してください。SAZ ??? HAR 1.2 をエクスポートすれば REA でオフラインのキャプチャ "
+"Evidence を取得できます。また、Everywhere の MCP サーバーを接続して、エージェントと一緒に記録済みの通信を調べることもできます。"
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "セットアップガイド"
diff --git a/website/locales/ko/messages.po b/website/locales/ko/messages.po
index 62a18cf20..efcf4a708 100644
--- a/website/locales/ko/messages.po
+++ b/website/locales/ko/messages.po
@@ -68,7 +68,7 @@ msgstr "여N0zoM1z0·10 월 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4422,7 +4422,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "로드 된 스크립트 읽기 복사"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "맨 위로"
@@ -10937,7 +10937,7 @@ msgstr "가이드"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -10957,7 +10957,7 @@ msgstr "소스 코드"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -10977,7 +10977,7 @@ msgstr "MIT 라이선스"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -10997,7 +10997,7 @@ msgstr "커뮤니티 참여"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -11911,6 +11911,22 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "다크 모드로 전환"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"HTTP 요청과 응답 본문을 살펴보려면 {ph0}Fiddler Everywhere: SAZ 캡처, MCP 및 공개 스킬{ph1}을 "
+"사용하세요. SAZ ?? HAR 1.2로 내보내면 REA에서 오프라인 캡처 Evidence를 얻을 수 있으며, Everywhere의 "
+"MCP 서버를 연결하여 에이전트와 함께 캡처된 트래픽을 살펴볼 수도 있습니다."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "설정 가이드"
diff --git a/website/locales/messages.pot b/website/locales/messages.pot
index e5f6fea82..ab1a4a17e 100644
--- a/website/locales/messages.pot
+++ b/website/locales/messages.pot
@@ -131,7 +131,7 @@ msgstr ""
#: website/public/examples/dino-lab/index.html:467 website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490 website/public/guides/index.html:274
+#: website/public/guides/browser/index.html:499 website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
#: website/public/showcase/aegis/index.html:800
@@ -148,7 +148,7 @@ msgstr ""
#: website/public/examples/dino-lab/index.html:468 website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491 website/public/guides/index.html:275
+#: website/public/guides/browser/index.html:500 website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
#: website/public/showcase/aegis/index.html:801
@@ -165,7 +165,7 @@ msgstr ""
#: website/public/examples/dino-lab/index.html:469 website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492 website/public/guides/index.html:276
+#: website/public/guides/browser/index.html:501 website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
#: website/public/showcase/aegis/index.html:802
@@ -182,7 +182,7 @@ msgstr ""
#: website/public/examples/dino-lab/index.html:470 website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493 website/public/guides/index.html:277
+#: website/public/guides/browser/index.html:502 website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
#: website/public/showcase/aegis/index.html:803
@@ -201,7 +201,7 @@ msgstr ""
#: website/public/examples/dino-lab/index.html:475 website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498 website/public/guides/index.html:282
+#: website/public/guides/browser/index.html:507 website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
#: website/public/showcase/aegis/index.html:808
@@ -4299,6 +4299,18 @@ msgid ""
"workflow{ph1} to follow files and process boundaries."
msgstr ""
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ captures, MCP "
+"and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA capture Evidence, or "
+"connect Everywhere's MCP server to inspect captured traffic with your agent."
+msgstr ""
+
#: website/public/guides/browser/index.html:6
msgctxt "page:guides/browser/index.html"
msgid "Browser and Network Analysis — REA"
@@ -4346,7 +4358,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr ""
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr ""
diff --git a/website/locales/pl/messages.po b/website/locales/pl/messages.po
index 2c80f47bd..3a6657178 100644
--- a/website/locales/pl/messages.po
+++ b/website/locales/pl/messages.po
@@ -70,7 +70,7 @@ msgstr "Przez N0zoM1z0 * 10 października 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4754,7 +4754,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Kopiuj przeczytaj załadowane Skrypty"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Do góry"
@@ -11703,7 +11703,7 @@ msgstr "Poradniki"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11723,7 +11723,7 @@ msgstr "Kod źródłowy"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11743,7 +11743,7 @@ msgstr "Licencja MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11763,7 +11763,7 @@ msgstr "Dołącz do społeczności"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12706,6 +12706,23 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Przełącz na ciemny motyw"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Aby zbadać treść żądań i odpowiedzi HTTP, użyj {ph0}Fiddler Everywhere: "
+"zapisów SAZ, MCP i publicznych umiejętności{ph1}. Wyeksportuj SAZ lub HAR "
+"1.2, aby uzyskać Evidence z zapisu w REA offline, lub połącz serwer MCP "
+"Everywhere, aby analizować zapisany ruch z agentem."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Instrukcja instalacji"
diff --git a/website/locales/pt-BR/messages.po b/website/locales/pt-BR/messages.po
index 0e1b8a3a3..a0741271d 100644
--- a/website/locales/pt-BR/messages.po
+++ b/website/locales/pt-BR/messages.po
@@ -70,7 +70,7 @@ msgstr "Por N0zoM1z0 * 10 de outubro de 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4793,7 +4793,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Copiar ler os scripts carregados"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Voltar ao topo"
@@ -11780,7 +11780,7 @@ msgstr "Guias"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11800,7 +11800,7 @@ msgstr "Código-fonte"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11820,7 +11820,7 @@ msgstr "Licença MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11840,7 +11840,7 @@ msgstr "Participe da comunidade"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12780,6 +12780,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Alternar para o modo escuro"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Para examinar os corpos das requisições e respostas HTTP, use {ph0}Fiddler "
+"Everywhere: capturas SAZ, MCP e habilidades públicas{ph1}. Exporte SAZ ou "
+"HAR 1.2 para obter Evidence das capturas no REA offline, ou conecte o "
+"servidor MCP do Everywhere para inspecionar o tráfego capturado com seu "
+"agente."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Guia de instalação"
diff --git a/website/locales/ru/messages.po b/website/locales/ru/messages.po
index 4349d04ef..9f0486916 100644
--- a/website/locales/ru/messages.po
+++ b/website/locales/ru/messages.po
@@ -70,7 +70,7 @@ msgstr "Автор: N0zoM1z0 · 10 октября 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4804,7 +4804,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Копировать читать загруженные скрипты"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Наверх"
@@ -11817,7 +11817,7 @@ msgstr "Руководства"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11837,7 +11837,7 @@ msgstr "Исходный код"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11857,7 +11857,7 @@ msgstr "Лицензия MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11877,7 +11877,7 @@ msgstr "Вступить в сообщество"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12822,6 +12822,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Переключить на тёмную тему"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Для изучения тел запросов и ответов HTTP используйте {ph0}Fiddler "
+"Everywhere: захваты SAZ, MCP и общедоступные навыки{ph1}. Экспортируйте SAZ "
+"??? HAR 1.2 для получения Evidence захвата в REA без подключения к сети или "
+"подключите MCP-сервер Everywhere, чтобы исследовать записанный трафик вместе"
+" с агентом."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Руководство по установке"
diff --git a/website/locales/th/messages.po b/website/locales/th/messages.po
index 2fab386ea..94e6d1873 100644
--- a/website/locales/th/messages.po
+++ b/website/locales/th/messages.po
@@ -70,7 +70,7 @@ msgstr "โดย N0zoM1z0 ·10 ตุลาคม 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4674,7 +4674,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "คัดลอกอ่านสคริปต์ที่โหลด"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "กลับขึ้นด้านบน"
@@ -11461,7 +11461,7 @@ msgstr "คู่มือ"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11481,7 +11481,7 @@ msgstr "ซอร์สโค้ด"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11501,7 +11501,7 @@ msgstr "สัญญาอนุญาต MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11521,7 +11521,7 @@ msgstr "เข้าร่วมชุมชน"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12459,6 +12459,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "เปลี่ยนเป็นโหมดมืด"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"หากต้องการตรวจสอบเนื้อหาคำขอและคำตอบ HTTP ให้ใช้ {ph0}Fiddler Everywhere: "
+"ข้อมูลที่บันทึกในรูปแบบ SAZ, MCP และทักษะสาธารณะ{ph1} ส่งออกเป็น SAZ ???? "
+"HAR 1.2 เพื่อรับ Evidence จากข้อมูลที่บันทึกใน REA แบบออฟไลน์ "
+"หรือเชื่อมต่อเซิร์ฟเวอร์ MCP ของ Everywhere "
+"เพื่อตรวจสอบทราฟฟิกที่บันทึกไว้ร่วมกับเอเจนต์ของคุณ"
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "คู่มือการติดตั้ง"
diff --git a/website/locales/tr/messages.po b/website/locales/tr/messages.po
index 7de2f7e5a..e4d0ba4a0 100644
--- a/website/locales/tr/messages.po
+++ b/website/locales/tr/messages.po
@@ -71,7 +71,7 @@ msgstr "Başlangıç N0zoM1z0 * 10 Ekim 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4754,7 +4754,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Kopyala yüklenen komut dosyalarını oku"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Başa dön"
@@ -11690,7 +11690,7 @@ msgstr "Rehberler"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11710,7 +11710,7 @@ msgstr "Kaynak kod"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11730,7 +11730,7 @@ msgstr "MIT lisansı"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11750,7 +11750,7 @@ msgstr "Topluluğa katıl"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12688,6 +12688,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Koyu temaya geç"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"HTTP istek ve yanıt gövdelerini incelemek için {ph0}Fiddler Everywhere: SAZ "
+"kayıtları, MCP ve herkese açık beceriler{ph1} rehberini kullanın. REA ile "
+"çevrimdışı kayıt Evidence elde etmek için SAZ veya HAR 1.2 dışa aktarın veya"
+" kaydedilen trafiği ajanınızla incelemek için Everywhere MCP sunucusunu "
+"bağlayın."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Kurulum rehberi"
diff --git a/website/locales/uk/messages.po b/website/locales/uk/messages.po
index ddd250d24..a5fae5c80 100644
--- a/website/locales/uk/messages.po
+++ b/website/locales/uk/messages.po
@@ -70,7 +70,7 @@ msgstr "Автор: N0zoM1z0 · 10 жовтня 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4780,7 +4780,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Копіювати читати завантажені скрипти"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Нагору"
@@ -11769,7 +11769,7 @@ msgstr "Посібники"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11789,7 +11789,7 @@ msgstr "Вихідний код"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11809,7 +11809,7 @@ msgstr "Ліцензія MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11829,7 +11829,7 @@ msgstr "Приєднатися до спільноти"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12773,6 +12773,24 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Перемкнути на темну тему"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Для дослідження тіл запитів і відповідей HTTP використовуйте {ph0}Fiddler "
+"Everywhere: захоплення SAZ, MCP і загальнодоступні навички{ph1}. Експортуйте"
+" SAZ ??? HAR 1.2 для отримання Evidence захоплення в REA без підключення до "
+"мережі або підключіть MCP-сервер Everywhere, щоб досліджувати записаний "
+"трафік разом з агентом."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Посібник зі встановлення"
diff --git a/website/locales/vi/messages.po b/website/locales/vi/messages.po
index 3aa144d2f..d9dd57fb0 100644
--- a/website/locales/vi/messages.po
+++ b/website/locales/vi/messages.po
@@ -70,7 +70,7 @@ msgstr "Bởi N0zoM1z0 * 10 tháng mười 2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4764,7 +4764,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "Sao chép đọc các tập lệnh đã tải"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "Về đầu trang"
@@ -11679,7 +11679,7 @@ msgstr "Hướng dẫn"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -11699,7 +11699,7 @@ msgstr "Mã nguồn"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -11719,7 +11719,7 @@ msgstr "Giấy phép MIT"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -11739,7 +11739,7 @@ msgstr "Tham gia cộng đồng"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -12675,6 +12675,23 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "Chuyển sang giao diện tối"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"Để kiểm tra phần thân yêu cầu và phản hồi HTTP, hãy dùng {ph0}Fiddler "
+"Everywhere: bản ghi SAZ, MCP và kỹ năng công khai{ph1}. Xuất SAZ ho?c HAR "
+"1.2 để lấy Evidence từ bản ghi trong REA ngoại tuyến, hoặc kết nối máy chủ "
+"MCP của Everywhere để kiểm tra lưu lượng đã ghi cùng trợ lý của bạn."
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "Hướng dẫn cài đặt"
diff --git a/website/locales/zh-CN/messages.po b/website/locales/zh-CN/messages.po
index b9350e625..91728965c 100644
--- a/website/locales/zh-CN/messages.po
+++ b/website/locales/zh-CN/messages.po
@@ -68,7 +68,7 @@ msgstr "通过N0zoM1z0*10十月2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4237,7 +4237,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "复制读取加载的脚本"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "回到顶部"
@@ -10700,7 +10700,7 @@ msgstr "指南"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -10720,7 +10720,7 @@ msgstr "源代码"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -10740,7 +10740,7 @@ msgstr "MIT 许可证"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -10760,7 +10760,7 @@ msgstr "加入社区"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -11666,6 +11666,21 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "切换到深色模式"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"要检查 HTTP 请求和响应正文,请使用{ph0}Fiddler Everywhere:SAZ 捕获、MCP 和公开技能{ph1}。导出 SAZ ? "
+"HAR 1.2 可在 REA 中离线获取捕获 Evidence,也可以连接 Everywhere 的 MCP 服务器,让智能体检查已捕获的流量。"
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "安装指南"
diff --git a/website/locales/zh-TW/messages.po b/website/locales/zh-TW/messages.po
index 98984f130..875779f14 100644
--- a/website/locales/zh-TW/messages.po
+++ b/website/locales/zh-TW/messages.po
@@ -68,7 +68,7 @@ msgstr "透過N0zoM1z0*10十月2026"
#: website/public/faq/index.html:573
#: website/public/first-investigation/index.html:523
#: website/public/get-started/index.html:882
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
#: website/public/guides/index.html:282
#: website/public/guides/javascript/index.html:503
#: website/public/guides/native/index.html:509 website/public/index.html:1191
@@ -4252,7 +4252,7 @@ msgctxt "page:guides/browser/index.html"
msgid "Copy read the loaded scripts"
msgstr "複製讀取載入的指令碼"
-#: website/public/guides/browser/index.html:498
+#: website/public/guides/browser/index.html:507
msgctxt "page:guides/browser/index.html"
msgid "Back to top"
msgstr "回到頂部"
@@ -10719,7 +10719,7 @@ msgstr "指南"
#: website/public/faq/index.html:565
#: website/public/first-investigation/index.html:515
#: website/public/get-started/index.html:874
-#: website/public/guides/browser/index.html:490
+#: website/public/guides/browser/index.html:499
#: website/public/guides/index.html:274
#: website/public/guides/javascript/index.html:495
#: website/public/guides/native/index.html:501 website/public/index.html:1183
@@ -10739,7 +10739,7 @@ msgstr "原始碼"
#: website/public/faq/index.html:566
#: website/public/first-investigation/index.html:516
#: website/public/get-started/index.html:875
-#: website/public/guides/browser/index.html:491
+#: website/public/guides/browser/index.html:500
#: website/public/guides/index.html:275
#: website/public/guides/javascript/index.html:496
#: website/public/guides/native/index.html:502 website/public/index.html:1184
@@ -10759,7 +10759,7 @@ msgstr "MIT 許可證"
#: website/public/faq/index.html:567
#: website/public/first-investigation/index.html:517
#: website/public/get-started/index.html:876
-#: website/public/guides/browser/index.html:492
+#: website/public/guides/browser/index.html:501
#: website/public/guides/index.html:276
#: website/public/guides/javascript/index.html:497
#: website/public/guides/native/index.html:503 website/public/index.html:1185
@@ -10779,7 +10779,7 @@ msgstr "加入社群"
#: website/public/faq/index.html:568
#: website/public/first-investigation/index.html:518
#: website/public/get-started/index.html:877
-#: website/public/guides/browser/index.html:493
+#: website/public/guides/browser/index.html:502
#: website/public/guides/index.html:277
#: website/public/guides/javascript/index.html:498
#: website/public/guides/native/index.html:504 website/public/index.html:1186
@@ -11686,6 +11686,21 @@ msgctxt "runtime"
msgid "Switch to dark mode"
msgstr "切換至深色模式"
+#. {ph0}:
+#. {ph1}:
+#: website/public/guides/browser/index.html:480
+#, python-brace-format
+msgctxt "page:guides/browser/index.html"
+msgid ""
+"For HTTP request and response bodies, use {ph0}Fiddler Everywhere: SAZ "
+"captures, MCP and public skills{ph1}. Export SAZ or HAR 1.2 for offline REA "
+"capture Evidence, or connect Everywhere's MCP server to inspect captured "
+"traffic with your agent."
+msgstr ""
+"若要檢查 HTTP 請求與回應本文,請使用{ph0}Fiddler Everywhere:SAZ 擷取、MCP 與公開技能{ph1}。匯出 SAZ ? "
+"HAR 1.2 可在 REA 中離線取得擷取 Evidence,也可以連接 Everywhere 的 MCP 伺服器,讓代理檢查已擷取的流量。"
+
#~ msgctxt "page:index.html"
#~ msgid "Setup guide"
#~ msgstr "安裝指南"
diff --git a/website/public/guides/browser/index.html b/website/public/guides/browser/index.html
index 6437873c2..60185ddb6 100644
--- a/website/public/guides/browser/index.html
+++ b/website/public/guides/browser/index.html
@@ -477,6 +477,15 @@ link.click();
application workflow to follow files
and process boundaries.
+
+ For HTTP request and response bodies, use
+ Fiddler Everywhere: SAZ captures, MCP and public skills. Export SAZ or HAR 1.2 for offline REA capture Evidence, or
+ connect Everywhere's MCP server to inspect captured traffic with
+ your agent.
+
From bb154d1067b0fc250452b7d468d0b0523031a16f Mon Sep 17 00:00:00 2001
From: Luis <86675342+LuisTaffelli@users.noreply.github.com>
Date: Tue, 13 Oct 2026 00:24:05 -0300
Subject: [PATCH 003/159] feat(browser): inspect retained aioquic qlog JSON
captures
Inspect bounded offline aioquic1.5.0 JSON qlog0.3 and page retained event/transport facts with explicit trace and stream selectors. Preserve producer coordinates, exact numeric lexemes, global pre-selection fact scope and endpoint declaration uncertainty.
Verified four real HTTP/3 recordings with an independent interval oracle, public CLI/MCP/schema parity, auth exclusions, source-deletion views and actual decoder heap exhaustion. Unverified producer platforms and qlog-specific lifecycle induction remain documented.
Co-authored-by: LuisTaffelli <86675342+LuisTaffelli@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
---
.../skills/reverse-engineer-anything/SKILL.md | 9 +-
docs/web-network-captures.md | 114 +++-
package.json | 3 +-
scripts/verify-qlog-captures.mjs | 215 ++++++++
src/application/WebNetworkCaptureService.ts | 10 +
src/browser/history/CaptureFailures.ts | 2 +-
.../history/{HarJson.ts => CaptureJson.ts} | 27 +-
src/browser/history/CaptureRelease.ts | 8 +
src/browser/history/HarCapture.test.ts | 20 +-
src/browser/history/HarCapture.ts | 18 +-
.../history/HistoricalCaptureDecoder.ts | 30 +-
.../history/NodeCaptureAdapter.test.ts | 13 +-
src/browser/history/NodeCaptureAdapter.ts | 11 +-
src/browser/history/NodeCaptureProcess.ts | 15 +-
src/browser/history/QlogCapture.test.ts | 259 +++++++++
src/browser/history/QlogCapture.ts | 503 ++++++++++++++++++
src/cli/webNetworkCaptureCommands.ts | 2 +-
src/composition/webNetworkCaptures.ts | 1 +
src/contracts/analysisViewToolContracts.ts | 2 +-
.../webNetworkCaptureToolContracts.ts | 10 +-
src/domain/analysisView/analysisView.ts | 15 +
src/domain/analysisView/qlogCaptureView.ts | 248 +++++++++
.../analysisView/webNetworkCaptureView.ts | 90 +++-
src/domain/qlogCapture.ts | 76 +++
src/domain/webNetworkCapture.ts | 140 +++--
src/domain/webNetworkCaptureCoordinates.ts | 22 +
src/server/registerAnalysisViewTool.ts | 11 +-
src/server/registerWebNetworkCaptureTool.ts | 14 +-
src/server/toolRegistrationOptions.ts | 12 +
tests/acceptance/tools/qlogCapture.test.ts | 457 ++++++++++++++++
30 files changed, 2206 insertions(+), 151 deletions(-)
create mode 100644 scripts/verify-qlog-captures.mjs
rename src/browser/history/{HarJson.ts => CaptureJson.ts} (83%)
create mode 100644 src/browser/history/QlogCapture.test.ts
create mode 100644 src/browser/history/QlogCapture.ts
create mode 100644 src/domain/analysisView/qlogCaptureView.ts
create mode 100644 src/domain/qlogCapture.ts
create mode 100644 tests/acceptance/tools/qlogCapture.test.ts
diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md
index 35b99d8dc..3b508af1c 100644
--- a/.agents/skills/reverse-engineer-anything/SKILL.md
+++ b/.agents/skills/reverse-engineer-anything/SKILL.md
@@ -65,11 +65,16 @@ explicit path or endpoint and do not need it.
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
connect Everywhere's separate MCP server for live traffic.
-- Retained HAR/SAZ/native mitmproxy capture: `inspect_web_network_capture`. Select
+- Retained HAR/SAZ/native mitmproxy/aioquic qlog capture: `inspect_web_network_capture`. Select
original record ordinals when useful; inspect producer fields and byte/number
sidecars without fetching recorded URLs or claiming live attribution. See
[historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream
- profile and credential exclusions.
+ profile and credential exclusions. Qlog support starts with aioquic 1.5.0
+ JSON 0.3; stream coverage and endpoint loss declarations do not recover bodies
+ or establish network loss. Prefer detail summary, then kind qlog trace/stream
+ pages; select one trace, stream ID and direction for contribution/range pages.
+ Facts retain all-recorded-events-before-selection scope. Other qlog layouts
+ and PCAP correlation are unsupported.
- User-owned Electron runtime already open: `list_electron_targets`.
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see
diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md
index e65887a71..c447c4783 100644
--- a/docs/web-network-captures.md
+++ b/docs/web-network-captures.md
@@ -255,7 +255,7 @@ response fields, including HAR's `status: 0` and an empty `mimeType`, remain
unknown, not invented statuses or MIME types.
The view makes no handler-identity, schema or causality claim. It performs no
-network activity. Native mitmproxy and SAZ captures return a typed unsupported error.
+network activity. Native mitmproxy, SAZ and qlog captures return a typed unsupported error.
Parents inspected with `sensitive_values`, or whose Evidence does not record
`sensitive_value_count`, return a typed unsupported error whose reason is the
canonical token `sensitive_values_unretained`. The view would write path
@@ -291,14 +291,15 @@ Complete evidence has a 32 MiB input, 96 MiB decoder reply and 64-level nesting
budget. Exceeding a budget returns no partial success.
Oversized selected captures return an input error identifying `capture_path`
and the byte budget; owned reply/output failures remain output or provider errors.
-HAR and SAZ run in an owned
+HAR, SAZ and qlog run in an owned
192 MiB Node old-generation heap with one V8 worker. Mitmproxy decoding applies
Linux limits of 768 MiB address space, 30 seconds CPU and 96 MiB file output;
both commands have a 30-second wall deadline and independently supervised
process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout
and stderr; the collector drops overflow as it arrives, reports the limit
failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits.
-When REA runs on Bun, the HAR/SAZ decoder runs on Bun, which ignores these V8 heap
+SAZ and qlog refuse decoding when neither a Node heap limit nor a Windows Job Object bound can be enforced. Existing HAR availability is preserved.
+When REA runs on Bun, the HAR/SAZ/qlog decoder runs on Bun, which ignores these V8 heap
flags; its limitations and resource failures then omit the heap sizes. On
Windows, the decoder's owned Job Object instead limits its committed memory to
704 MiB, the 192 MiB budget plus a 512 MiB reserve for Bun itself. The
@@ -306,7 +307,7 @@ limitations state that limit, and reaching it returns `resource_constraint`
with `process_memory_limit_bytes`, whatever exit status Bun reports.
Observed decoder memory exhaustion returns `resource_constraint`, with the
-selected capture, applicable limits and retained process diagnostics. HAR/SAZ's
+selected capture, applicable limits and retained process diagnostics. HAR/SAZ/qlog's
old-generation heap is fixed; inherited `NODE_OPTIONS` cannot raise it.
Record selection happens after complete decoding and does not reduce that
workload. Use a smaller capture exported by its producer or another decoder
@@ -333,3 +334,108 @@ Command cleanup failures retain the original status and both diagnostic streams;
the decoder's ownership preparation receives the same cancellation signal.
MCP advertises the same strict named input contract used by the application. Historical inspection delegates argument validation to that shared application boundary so accepted explicit-sensitive declarations also cover invalid argument names and other correction details. The SDK receives raw arguments through its Standard Schema interface; malformed inputs still fail before decoder or filesystem effects and return the normal structured REA error. Protocol-envelope validation remains owned by MCP.
+
+## Retained aioquic qlog
+
+Select `format: "qlog"` through `inspect_web_network_capture`, or:
+
+```bash
+rea inspect-web-network-capture /absolute/origin.qlog qlog --json
+rea inspect-web-network-capture /absolute/origin.qlog qlog --record 95 --record 0 --json
+```
+
+The initial profile is **aioquic 1.5.0, JSON qlog 0.3**: one JSON object with
+`qlog_format: "JSON"`, `qlog_version: "0.3"`, `traces` containing object events
+with `name`, finite numeric `time` and object `data`. JSON-SEQ, JSONL, columnar
+array events and other versions are unsupported. Duplicate JSON members,
+malformed UTF-8, invalid interpreted QUIC integers and excessive nesting fail
+without partial success, including in events outside the selected ordinals.
+Well-formed event subsets and missing frame metadata remain inspectable.
+Unknown extensions are retained; this is not validation of every qlog schema.
+
+Choosing the format selects the interpretation profile; the file does not
+necessarily report or authenticate an aioquic version. The profile's clock
+uses Unix-epoch milliseconds, verified in aioquic 1.5.0's `QuicLoggerTrace`
+`log_event`/`encode_time`. The `qlog.clock.basis` and Evidence analysis profile
+state that assumption explicitly. Numeric lexemes survive independently of
+safe JSON-number projections; decimal spelling does not establish physical
+clock precision or monotonicity.
+
+Every event has a global ordinal from trace/event source traversal and its
+original `/traces/T/events/E` pointer. This order is not a global time sort.
+Trace index and the selected artifact identify a connection observation;
+reported ODCID is preserved without claiming global uniqueness. Streams are
+scoped by trace, stream ID and sent/received direction. Control/QPACK streams
+are retained, so counting streams does not count HTTP requests.
+
+`qlog` is a typed facet required exactly for this format. Its scope is
+`all-recorded-events-before-selection`: selection changes returned event
+records while facts cite the original ordinals across the entire recorded
+file. The container retains trace metadata with each events array replaced
+by null; there is no single events pointer shared by all traces.
+
+| Fact | Basis and limits |
+| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Original event and loss declaration | Observed endpoint report; `recovery:packet_lost` does not prove actual network loss. Missing packet number/type remains unknown. |
+| STREAM ranges and byte totals | Derived per trace and direction from reported offsets and lengths using exact integer arithmetic. `unique_bytes` unions intervals; `frame_length_sum` includes overlaps; `repeated_bytes` is their difference. Overlap alone does not prove retransmission or equal contents. |
+| FIN final sizes | Reported offset plus length where FIN is true; multiple conflicting sizes remain visible. A final size does not establish complete coverage or a received body. |
+| Incomplete fact records | Ordinals with omitted/redacted interpretable frame metadata; usable fields in other records survive. An empty list does not establish complete logging or capture. |
+| HTTP bodies and causality | `body_bytes: "not-reported"`; lengths and headers are metadata, with no reconstructed body, hash, timing causality or live process attribution. |
+
+QUIC integer fields interpreted here accept exact nonnegative JSON integer
+values through `2^62 - 1`, including equivalent exponent notation. Offsets plus
+lengths must fit that range. Derived sums are decimal strings and are not
+rounded to JavaScript numbers. A missing offset is unknown, never defaulted to
+zero. Ranges can start above zero or contain gaps. Packet headers, HTTP events,
+raw lengths and unknown extensions remain producer evidence rather than being
+normalized into invented HAR requests.
+
+The adapter excludes authorization/proxy authorization/cookie/set-cookie
+values in known HTTP frame events, reset tokens in transport parameters and
+NEW_CONNECTION_ID frames, and NEW_TOKEN frame tokens. Known URL header userinfo
+and explicit sensitive values follow the shared exclusions. Ordinary extension
+fields and token-looking query text remain retained. Explicit declarations also
+cover normalized numeric projections, derived totals and trace pointers; facts
+that cannot be safely shown are omitted with incomplete-record coordinates.
+No keylog, PCAP, live URL or recorded target is opened by the qlog decoder.
+
+To repeat the real-provider lane with **local caller-supplied evidence** from
+the educational aioquic 1.5.0 experiment (one origin trace, known 32,803-byte
+stream coverage and one 1,169-byte repeated range in the loss case):
+
+```bash
+npm run verify:web:qlog -- /absolute/control/origin.qlog /absolute/loss/origin.qlog /absolute/new-report.json
+```
+
+This lane checks built CLI/stdio MCP parity, SDK-converted JSON Schemas,
+artifact digests, original event count, selection scope, retained parent lookup,
+credential exclusions and the known control/loss facts. The report is created
+exclusively and contains no keys or captured credential values. It is a
+fixture-specific verification, not a general loss detector. CI uses synthetic
+format inputs for malformed cases and limits; those tests do not establish
+producer provenance. PCAP parsing and correlation remain outside this stage.
+
+### Focused qlog views
+
+Use MCP `inspect_web_network_capture` with `format: "qlog", detail: "summary"`
+for identity, profile, scope and retained-fact counts. Then pass its
+`normalized_result.parent_evidence_id` to `inspect_analysis_view` as a
+`retained-evidence` source.
+
+```json
+{"kind":"qlog","facet":"traces","offset":0,"limit":10}
+{"kind":"qlog","facet":"streams","trace_index":0,"offset":0,"limit":10}
+{"kind":"qlog","facet":"contributions","trace_index":0,"stream_id":"0","direction":"sent","offset":0,"limit":20}
+```
+
+Trace pages omit nested lists and give their counts. Stream pages preserve
+exact byte totals but omit contributions, ranges and final-size lists. For
+those lists select `contributions`, `ranges` or `final-sizes` with all three
+stream selectors. `loss-declarations` and `incomplete-records` need only
+`trace_index`. Offsets count rows in that retained facet; defaults are 0 and 64. Missing or ambiguous selectors fail rather than choosing a connection.
+
+These views page already interpreted facts without decoding again or opening
+the source, and work after its deletion. Facts still cover all recorded events
+before parent record selection. Their exhaustion does not prove logging or
+network completeness. Event preview/complete pages remain available through
+kind `capture`, with original numeric lexemes and producer ordinals preserved.
diff --git a/package.json b/package.json
index f803cc00c..801f72ebf 100644
--- a/package.json
+++ b/package.json
@@ -278,7 +278,8 @@
"artifacts:error-schema": "node scripts/generate-error-schema.mjs",
"test:prepare": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:mcp-catalog artifacts:product-catalog artifacts:managed-evidence",
"evidence:check:built": "node scripts/generate-completion-ledger.mjs --check",
- "docs:prepare": "node scripts/generate-package-metadata.mjs && turbo run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema"
+ "docs:prepare": "node scripts/generate-package-metadata.mjs && turbo run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema",
+ "verify:web:qlog": "npm run build:cached && node scripts/verify-qlog-captures.mjs"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
diff --git a/scripts/verify-qlog-captures.mjs b/scripts/verify-qlog-captures.mjs
new file mode 100644
index 000000000..8d1a16f6c
--- /dev/null
+++ b/scripts/verify-qlog-captures.mjs
@@ -0,0 +1,215 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { readFile, writeFile } from "node:fs/promises";
+import { isAbsolute } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { parseEvidence } from "../dist/domain/evidence.js";
+
+// Caller-supplied producer evidence stays local; no fixture capture or secrets are bundled.
+const [controlPath, lossPath, reportPath] = process.argv.slice(2);
+if (
+ ![controlPath, lossPath, reportPath].every(
+ (value) => typeof value === "string" && isAbsolute(value),
+ )
+)
+ throw new Error(
+ "Usage: node scripts/verify-qlog-captures.mjs /absolute/control/origin.qlog /absolute/loss/origin.qlog /absolute/new-report.json",
+ );
+const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
+const client = new Client({ name: "qlog-producer-verifier", version: "1" });
+const environment = {
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
+ REA_LOG_LEVEL: "silent",
+ HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
+};
+const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+});
+let diagnostics = "";
+transport.stderr?.on("data", (bytes) => {
+ if (diagnostics.length < 65536) diagnostics += bytes.toString("utf8");
+});
+const ajv = new Ajv2020({ strict: false, validateFormats: false });
+const measurements = [];
+try {
+ try {
+ await client.connect(transport);
+ } catch (cause) {
+ throw new Error(`MCP startup failed: ${diagnostics}`, { cause });
+ }
+ const tool = (await client.listTools()).tools.find(
+ (tool) => tool.name === "inspect_web_network_capture",
+ );
+ assert.ok(tool?.outputSchema);
+ for (const schema of [tool.inputSchema, tool.outputSchema])
+ assert.ok(ajv.validateSchema(schema));
+ const validateOutput = ajv.compile(tool.outputSchema);
+ for (const [lane, path] of [
+ ["control", controlPath],
+ ["loss", lossPath],
+ ]) {
+ const source = await readFile(path);
+ const raw = JSON.parse(source.toString("utf8"));
+ const sha256 = createHash("sha256").update(source).digest("hex");
+ assert.equal(raw.qlog_format, "JSON");
+ assert.equal(raw.qlog_version, "0.3");
+ const input = { capture_path: path, format: "qlog" };
+ assert.ok(ajv.validate(tool.inputSchema, input));
+ const response = await client.callTool({
+ name: tool.name,
+ arguments: input,
+ });
+ assert.notEqual(response.isError, true);
+ assert.ok(
+ validateOutput(response.structuredContent),
+ JSON.stringify(validateOutput.errors),
+ );
+ const mcp = parseEvidence(response.structuredContent);
+ const completed = await promisify(execFile)(
+ process.execPath,
+ [entrypoint, "inspect-web-network-capture", path, "qlog", "--json"],
+ { env: environment, timeout: 40_000, maxBuffer: 96 * 1024 * 1024 },
+ );
+ const cli = parseEvidence(JSON.parse(completed.stdout));
+ assert.equal(cli.evidence_id, mcp.evidence_id);
+ assert.deepEqual(cli.normalized_result, mcp.normalized_result);
+ const capture = mcp.normalized_result;
+ assert.equal(capture.artifact.sha256, sha256);
+ assert.equal(
+ capture.total_records,
+ raw.traces.reduce((sum, trace) => sum + trace.events.length, 0),
+ );
+ for (const [ordinal, original] of raw.traces
+ .flatMap((trace) => trace.events)
+ .entries()) {
+ const record = capture.records[ordinal];
+ assert.equal(record.ordinal, ordinal);
+ assert.equal(record.reported.name, original.name);
+ assert.equal(record.reported.time, original.time);
+ assert.deepEqual(record.binary_fields, []);
+ assert.ok(
+ record.numeric_literals.some(
+ (number) =>
+ number.pointer === "/time" &&
+ Number(number.literal) === original.time,
+ ),
+ );
+ }
+ assert.equal(capture.qlog.body_bytes, "not-reported");
+ assert.equal(mcp.authority, "historical-reference");
+ assert.equal(
+ mcp.analysis_profile.parameters.qlog_profile,
+ "aioquic-1.5.0-json-0.3",
+ );
+ const trace = capture.qlog.traces[0];
+ assert.equal(trace.vantage_point, "server");
+ const stream = trace.streams.find(
+ (stream) => stream.stream_id === "0" && stream.direction === "sent",
+ );
+ assert.ok(stream);
+ assert.equal(stream.unique_bytes, "32803");
+ assert.equal(
+ stream.frame_length_sum,
+ lane === "control" ? "32803" : "33972",
+ );
+ assert.equal(stream.repeated_bytes, lane === "control" ? "0" : "1169");
+ assert.equal(trace.loss_declarations.length, lane === "control" ? 0 : 1);
+ if (lane === "loss")
+ assert.deepEqual(trace.loss_declarations[0].packet_number, {
+ state: "reported",
+ value: "5",
+ });
+ // Structurally-known secrets may not survive in Evidence, including sidecars.
+ const serialized = JSON.stringify(mcp);
+ for (const original of raw.traces.flatMap((trace) => trace.events)) {
+ if (
+ original.name === "transport:parameters_set" &&
+ typeof original.data.stateless_reset_token === "string"
+ )
+ assert.ok(!serialized.includes(original.data.stateless_reset_token));
+ for (const frame of original.data.frames ?? []) {
+ if (
+ frame.frame_type === "new_connection_id" &&
+ typeof frame.reset_token === "string"
+ )
+ assert.ok(!serialized.includes(frame.reset_token));
+ if (frame.frame_type === "new_token" && typeof frame.token === "string")
+ assert.ok(!serialized.includes(frame.token));
+ }
+ }
+ const retained = await client.callTool({
+ name: "inspect_analysis_view",
+ arguments: {
+ source: { kind: "retained-evidence", evidence_id: mcp.evidence_id },
+ view: { kind: "api-surface" },
+ },
+ });
+ assert.equal(retained.isError, true);
+ assert.equal(
+ JSON.parse(retained.content.find((item) => item.type === "text").text)
+ .error.code,
+ "unsupported_target",
+ );
+ const selected = await client.callTool({
+ name: tool.name,
+ arguments: { ...input, record_ordinals: [capture.total_records - 1, 0] },
+ });
+ const subset = parseEvidence(selected.structuredContent).normalized_result;
+ assert.deepEqual(
+ subset.records.map((record) => record.ordinal),
+ [capture.total_records - 1, 0],
+ );
+ assert.deepEqual(subset.qlog, capture.qlog);
+ measurements.push({
+ lane,
+ path,
+ sha256,
+ evidence_id: mcp.evidence_id,
+ profile_digest: mcp.analysis_profile.digest,
+ events: capture.total_records,
+ stream_id: stream.stream_id,
+ unique_bytes: stream.unique_bytes,
+ frame_length_sum: stream.frame_length_sum,
+ repeated_bytes: stream.repeated_bytes,
+ loss_declarations: trace.loss_declarations.length,
+ cli_mcp_parity: true,
+ retained_reference: true,
+ selected_scope_preserved: true,
+ auth_exclusions: true,
+ });
+ }
+ await client.ping();
+ const report = {
+ status: "passed",
+ profile: "aioquic-1.5.0-json-0.3",
+ measurements,
+ limitations: [
+ "Caller supplies provenance; format selection does not authenticate the producer.",
+ "These known loopback cases do not establish WAN behavior, other qlog producers, platforms or general completeness.",
+ "No PCAP or keylog is read by this verifier; repeated STREAM coverage is not proof of retransmission or network loss.",
+ ],
+ };
+ await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n", {
+ flag: "wx",
+ mode: 0o600,
+ });
+ console.log(
+ JSON.stringify({
+ status: report.status,
+ cases: measurements.length,
+ report: reportPath,
+ }),
+ );
+} finally {
+ await client.close();
+ await transport.close();
+}
diff --git a/src/application/WebNetworkCaptureService.ts b/src/application/WebNetworkCaptureService.ts
index a571fa8c1..8e29ebb10 100644
--- a/src/application/WebNetworkCaptureService.ts
+++ b/src/application/WebNetworkCaptureService.ts
@@ -10,6 +10,7 @@ import {
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
+import { createAnalysisProfile } from "../domain/analysisProfile.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
@@ -175,6 +176,15 @@ export class WebNetworkCaptureService {
? [...limitations, subjectUnavailableReason]
: limitations,
},
+ ...(safeValue.format !== "qlog"
+ ? {}
+ : {
+ analysisProfile: createAnalysisProfile(value.decoder, {
+ qlog_profile: safeValue.qlog.profile,
+ clock: safeValue.qlog.clock,
+ scope: safeValue.qlog.scope,
+ }),
+ }),
confidence: "observed",
authority: "historical-reference",
limitations,
diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts
index 51eceb0b5..b525126a5 100644
--- a/src/browser/history/CaptureFailures.ts
+++ b/src/browser/history/CaptureFailures.ts
@@ -86,7 +86,7 @@ export const historicalCaptureFailure = (
)
return captureMemoryFailure(
input,
- "The HAR decoder exhausted its fixed V8 heap",
+ `The ${input.format} decoder exhausted its fixed V8 heap`,
cause,
);
}
diff --git a/src/browser/history/HarJson.ts b/src/browser/history/CaptureJson.ts
similarity index 83%
rename from src/browser/history/HarJson.ts
rename to src/browser/history/CaptureJson.ts
index 257630932..59743788a 100644
--- a/src/browser/history/HarJson.ts
+++ b/src/browser/history/CaptureJson.ts
@@ -2,6 +2,7 @@ import { visit } from "jsonc-parser";
import { LosslessNumber } from "lossless-json";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import { CaptureFormatError } from "./CaptureFormatError.js";
+import { TextDecoder } from "node:util";
type Frame =
| {
@@ -13,7 +14,10 @@ type Frame =
| { kind: "array"; pointer: string; items: unknown[] };
/** Materialize upstream JSON events without duplicate folding, prototype setters or numeric rounding. */
-export const parseHarJson = (text: string): unknown => {
+export const parseCaptureJson = (
+ text: string,
+ format: "HAR" | "qlog",
+): unknown => {
const frames: Frame[] = [];
let result: unknown;
const valuePointer = (): string => {
@@ -29,7 +33,7 @@ export const parseHarJson = (text: string): unknown => {
if (frames.length > WEB_NETWORK_CAPTURE_LIMITS.depth)
throw new CaptureFormatError(
"input-limit",
- "HAR exceeds the 64-level complete-evidence nesting budget.",
+ `${format} exceeds the 64-level complete-evidence nesting budget.`,
valuePointer(),
);
};
@@ -69,7 +73,7 @@ export const parseHarJson = (text: string): unknown => {
if (frame.members.has(key))
throw new CaptureFormatError(
"format",
- "HAR JSON contains duplicate object members.",
+ `${format} JSON contains duplicate object members.`,
);
frame.pendingKey = key;
},
@@ -103,7 +107,7 @@ export const parseHarJson = (text: string): unknown => {
throw new TypeError("JSON visitor emitted an unsupported literal.");
},
onError: () => {
- throw new CaptureFormatError("format", "HAR is malformed JSON.");
+ throw new CaptureFormatError("format", `${format} is malformed JSON.`);
},
},
{
@@ -114,3 +118,18 @@ export const parseHarJson = (text: string): unknown => {
);
return result;
};
+
+/** UTF-8 JSON decoding skips one permitted BOM and rejects malformed bytes. */
+export const decodeCaptureText = (
+ bytes: Uint8Array,
+ format: "HAR" | "qlog",
+): string => {
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ } catch {
+ throw new CaptureFormatError(
+ "format",
+ `${format} is not valid UTF-8 JSON.`,
+ );
+ }
+};
diff --git a/src/browser/history/CaptureRelease.ts b/src/browser/history/CaptureRelease.ts
index 512c2fa6e..77f29df51 100644
--- a/src/browser/history/CaptureRelease.ts
+++ b/src/browser/history/CaptureRelease.ts
@@ -35,3 +35,11 @@ export const SAZ_CAPTURE_PROVIDER_IDENTITY = {
name: "REA Fiddler SAZ capture adapter",
version: "1;zip.js@2.15.0;xmldom@0.9.12",
} as const;
+
+/** Fixed offline interpretation profile, not an authenticated producer claim. */
+export const QLOG_CAPTURE_PROVIDER_IDENTITY = {
+ id: "aioquic-qlog-json",
+ name: "REA aioquic qlog capture adapter",
+ version:
+ "aioquic-profile@1.5.0;qlog@0.3;jsonc-parser@3.3.1;lossless-json@4.3.1",
+} as const;
diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts
index 2eb1fe21a..9614384cc 100644
--- a/src/browser/history/HarCapture.test.ts
+++ b/src/browser/history/HarCapture.test.ts
@@ -1,7 +1,8 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { historicalHar } from "../../../tests/fixtures/historicalHar.js";
-import { decodeHarCapture, decodeHarText } from "./HarCapture.js";
+import { decodeCaptureText } from "./CaptureJson.js";
+import { decodeHarCapture } from "./HarCapture.js";
it.each([
{ values: [] },
@@ -563,18 +564,21 @@ it("ignores one leading byte-order mark, as HAR 1.2 requires of readers", () =>
const text = JSON.stringify(historicalHar());
const body = Buffer.from(text, "utf8");
const marked = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]);
- expect(decodeHarText(marked)).toBe(text);
- expect(decodeHarCapture(decodeHarText(marked), [])).toEqual(
- decodeHarCapture(decodeHarText(body), []),
+ expect(decodeCaptureText(marked, "HAR")).toBe(text);
+ expect(decodeHarCapture(decodeCaptureText(marked, "HAR"), [])).toEqual(
+ decodeHarCapture(decodeCaptureText(body, "HAR"), []),
);
// A second mark is content, not an encoding signature.
expect(() =>
decodeHarCapture(
- decodeHarText(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), marked])),
+ decodeCaptureText(
+ Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), marked]),
+ "HAR",
+ ),
[],
),
).toThrow("HAR is malformed JSON.");
- expect(() => decodeHarText(Buffer.from([0x7b, 0xff, 0x7d]))).toThrow(
- "HAR is not valid UTF-8 JSON.",
- );
+ expect(() =>
+ decodeCaptureText(Buffer.from([0x7b, 0xff, 0x7d]), "HAR"),
+ ).toThrow("HAR is not valid UTF-8 JSON.");
});
diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts
index f3a83af15..83b442f5c 100644
--- a/src/browser/history/HarCapture.ts
+++ b/src/browser/history/HarCapture.ts
@@ -1,7 +1,6 @@
import { createHash } from "node:crypto";
-import { TextDecoder } from "node:util";
import { isSafeNumber, LosslessNumber } from "lossless-json";
-import { parseHarJson } from "./HarJson.js";
+import { parseCaptureJson } from "./CaptureJson.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import {
WEB_NETWORK_CAPTURE_LIMITS,
@@ -26,25 +25,12 @@ type Projection = Pick<
"reported" | "numeric_literals" | "binary_fields" | "redactions"
>;
-/**
- * Decode retained HAR bytes as UTF-8. HAR 1.2 lets writers emit a byte-order
- * mark and requires readers to ignore it, so one leading BOM is skipped.
- */
-export const decodeHarText = (bytes: Uint8Array): string => {
- try {
- // ignoreBOM: false (the default) is what skips the mark; true keeps it.
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
- } catch {
- throw new CaptureFormatError("format", "HAR is not valid UTF-8 JSON.");
- }
-};
-
/** Decode a retained HAR with exact numeric lexemes and unchanged upstream schema validation. */
export const decodeHarCapture = (
text: string,
sensitiveValues: readonly string[],
): DecodedCapture => {
- const raw = parseHarJson(text);
+ const raw = parseCaptureJson(text, "HAR");
const validation = projectHar(raw, [], false);
createHarValidator()(validation.reported);
if (
diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts
index f17f45a21..7f6e7b83a 100644
--- a/src/browser/history/HistoricalCaptureDecoder.ts
+++ b/src/browser/history/HistoricalCaptureDecoder.ts
@@ -198,15 +198,14 @@ export class HistoricalCaptureDecoder {
OPERATION,
"Capture decoder changed the selected upstream profile identity.",
);
- result = ok({
- ...reply.value,
+ const common = {
artifact: {
path: input.capture_path,
sha256: snapshot.sha256,
bytes: snapshot.bytes.length,
},
- format: input.format,
- runtime_attribution: "unknown",
+
+ runtime_attribution: "unknown" as const,
limitations: [
...(adapter.limitations ?? []),
"This is retained producer evidence, without live browser transaction IDs, scenario provenance, execution attribution or deployment authenticity. Recorded URLs are never fetched.",
@@ -214,7 +213,22 @@ export class HistoricalCaptureDecoder {
"Known authentication headers/cookies and explicitly marked literal values are excluded. Unknown extension names do not establish sensitivity.",
`A 32 MiB input, 96 MiB reply and 64-level nesting budget bound complete evidence. ${nodeMemoryLimitation()}; mitmproxy decoding uses a 768 MiB Linux address-space limit. Each owned command has a 30-second deadline with independent cleanup.`,
],
- });
+ };
+ if (input.format === "qlog") {
+ if (!("qlog" in reply.value))
+ throw new AnalysisOutputError(
+ OPERATION,
+ "Capture decoder omitted qlog transport facts.",
+ );
+ result = ok({ ...reply.value, ...common, format: "qlog" });
+ } else {
+ if ("qlog" in reply.value)
+ throw new AnalysisOutputError(
+ OPERATION,
+ "Capture decoder supplied inapplicable qlog transport facts.",
+ );
+ result = ok({ ...reply.value, ...common, format: input.format });
+ }
} catch (cause: unknown) {
result = err(
redactExplicitFailure(
@@ -255,10 +269,10 @@ export class HistoricalCaptureDecoder {
/** The Node capture decoder runs on this executable; only Node.js enforces its V8 heap flags. */
const nodeMemoryLimitation = (): string => {
if (NODE_CAPTURE_HEAP_ENFORCED)
- return `HAR/SAZ use a ${String(NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB)} MiB Node old-generation heap`;
+ return `HAR/SAZ/qlog use a ${String(NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB)} MiB Node old-generation heap`;
return NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
- ? "HAR/SAZ run on Bun, which does not enforce REA's V8 heap flags"
- : `HAR/SAZ run on Bun, which does not enforce REA's V8 heap flags, under a ${String(NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)}-byte Windows Job Object commit limit`;
+ ? "HAR/SAZ/qlog run on Bun, which does not enforce REA's V8 heap flags"
+ : `HAR/SAZ/qlog run on Bun, which does not enforce REA's V8 heap flags, under a ${String(NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES)}-byte Windows Job Object commit limit`;
};
const replyFailureReason = (cause: unknown): string => {
diff --git a/src/browser/history/NodeCaptureAdapter.test.ts b/src/browser/history/NodeCaptureAdapter.test.ts
index c341949db..bf2528f5b 100644
--- a/src/browser/history/NodeCaptureAdapter.test.ts
+++ b/src/browser/history/NodeCaptureAdapter.test.ts
@@ -8,8 +8,11 @@ vi.mock("./CaptureRelease.js", async (original) => ({
NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES: null,
}));
-it("refuses a SAZ worker when the runtime has no enforceable memory bound", async () => {
- await expect(
- new NodeCaptureAdapter("saz").command("/owned/request.json"),
- ).rejects.toBeInstanceOf(AnalysisCapabilityUnavailableError);
-});
+it.each(["saz", "qlog"] as const)(
+ "refuses a %s worker when the runtime has no enforceable memory bound",
+ async (format) => {
+ await expect(
+ new NodeCaptureAdapter(format).command("/owned/request.json"),
+ ).rejects.toBeInstanceOf(AnalysisCapabilityUnavailableError);
+ },
+);
diff --git a/src/browser/history/NodeCaptureAdapter.ts b/src/browser/history/NodeCaptureAdapter.ts
index f1e98a07b..8f33430ca 100644
--- a/src/browser/history/NodeCaptureAdapter.ts
+++ b/src/browser/history/NodeCaptureAdapter.ts
@@ -4,6 +4,7 @@ import {
NODE_CAPTURE_HEAP_ENFORCED,
HAR_CAPTURE_PROVIDER_IDENTITY,
SAZ_CAPTURE_PROVIDER_IDENTITY,
+ QLOG_CAPTURE_PROVIDER_IDENTITY,
NODE_CAPTURE_HEAP_LIMITS,
NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
} from "./CaptureRelease.js";
@@ -13,11 +14,13 @@ import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCo
export class NodeCaptureAdapter implements HistoricalCaptureFormatAdapter {
readonly identity;
readonly limitations;
- constructor(readonly format: "har" | "saz") {
+ constructor(readonly format: "har" | "saz" | "qlog") {
this.identity =
format === "har"
? HAR_CAPTURE_PROVIDER_IDENTITY
- : SAZ_CAPTURE_PROVIDER_IDENTITY;
+ : format === "saz"
+ ? SAZ_CAPTURE_PROVIDER_IDENTITY
+ : QLOG_CAPTURE_PROVIDER_IDENTITY;
this.limitations =
format === "saz"
? [
@@ -30,14 +33,14 @@ export class NodeCaptureAdapter implements HistoricalCaptureFormatAdapter {
}
async command(requestPath: string) {
if (
- this.format === "saz" &&
+ this.format !== "har" &&
!NODE_CAPTURE_HEAP_ENFORCED &&
NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES === null
)
throw new AnalysisCapabilityUnavailableError(
this.identity.id,
"inspect_web_network_capture",
- "SAZ decoding requires an enforced decoder memory limit. Run REA with Node.js on this host.",
+ `${this.format} decoding requires an enforced decoder memory limit. Run REA with Node.js on this host.`,
);
return {
command: process.execPath,
diff --git a/src/browser/history/NodeCaptureProcess.ts b/src/browser/history/NodeCaptureProcess.ts
index e94295da4..ff45a869a 100644
--- a/src/browser/history/NodeCaptureProcess.ts
+++ b/src/browser/history/NodeCaptureProcess.ts
@@ -12,22 +12,25 @@ const requestSchema = z.strictObject({
/** Parse untrusted captures in an owned heap and return one complete bounded reply. */
const main = async (): Promise => {
- const format = z.enum(["har", "saz"]).parse(process.argv[3]);
+ const format = z.enum(["har", "saz", "qlog"]).parse(process.argv[3]);
const request = requestSchema.parse(
JSON.parse(await readFile(process.argv[2] ?? "", "utf8")),
);
let reply: unknown;
try {
let value;
- if (format === "har") {
- const { decodeHarCapture, decodeHarText } =
- await import("./HarCapture.js");
+ if (format !== "saz") {
+ const { decodeCaptureText } = await import("./CaptureJson.js");
+ const decoder =
+ format === "har"
+ ? (await import("./HarCapture.js")).decodeHarCapture
+ : (await import("./QlogCapture.js")).decodeQlogCapture;
const snapshot = await readStableArtifact(
request.snapshot_path,
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
);
- value = decodeHarCapture(
- decodeHarText(snapshot.bytes),
+ value = decoder(
+ decodeCaptureText(snapshot.bytes, format === "har" ? "HAR" : "qlog"),
request.sensitive_values,
);
} else {
diff --git a/src/browser/history/QlogCapture.test.ts b/src/browser/history/QlogCapture.test.ts
new file mode 100644
index 000000000..bd67d4e80
--- /dev/null
+++ b/src/browser/history/QlogCapture.test.ts
@@ -0,0 +1,259 @@
+import { expect, it } from "vitest";
+import { decodeQlogCapture } from "./QlogCapture.js";
+import { decodeCaptureText } from "./CaptureJson.js";
+import { CaptureFormatError } from "./CaptureFormatError.js";
+
+// Synthetic format inputs exercise distinct parser semantics, not producer provenance.
+const event = (frames: unknown[], name = "transport:packet_sent") => ({
+ name,
+ time: 1791819895000.25,
+ data: { header: { packet_type: "1RTT", packet_number: 5 }, frames },
+});
+const frame = (offset: number, length: number, fin = false) => ({
+ frame_type: "stream",
+ stream_id: 0,
+ offset,
+ length,
+ fin,
+});
+const capture = (events: unknown[], extra: Record = {}) =>
+ JSON.stringify({
+ qlog_format: "JSON",
+ qlog_version: "0.3",
+ ...extra,
+ traces: [
+ {
+ common_fields: { ODCID: "same" },
+ vantage_point: { name: "aioquic", type: "server" },
+ events,
+ },
+ ],
+ });
+const facts = (text: string, sensitive: string[] = []) => {
+ const decoded = decodeQlogCapture(text, sensitive);
+ if (!("qlog" in decoded)) throw new Error("qlog facet required");
+ return { decoded, traces: decoded.qlog.traces };
+};
+
+it("derives interval unions per trace/direction while retaining event source order and extensions", () => {
+ const original = JSON.parse(
+ capture([
+ event([frame(10, 10)]),
+ event([frame(0, 15), frame(30, 5, true)]),
+ event([frame(0, 4)], "transport:packet_received"),
+ {
+ name: "recovery:packet_lost",
+ time: 1,
+ data: { packet_number: 5, type: "1RTT" },
+ },
+ ]),
+ );
+ original.traces.push({
+ ...original.traces[0],
+ events: [event([frame(0, 3)])],
+ });
+ Object.defineProperty(original, "__proto__", {
+ enumerable: true,
+ value: { ordinary: true },
+ });
+ original.extra = "https://example.test/path?token=ordinary";
+ const { decoded, traces } = facts(JSON.stringify(original));
+ expect(traces[0]?.streams[0]).toMatchObject({
+ stream_id: "0",
+ direction: "sent",
+ unique_bytes: "25",
+ frame_length_sum: "30",
+ repeated_bytes: "5",
+ covered_ranges: [
+ { start: "0", end: "20" },
+ { start: "30", end: "35" },
+ ],
+ reported_final_sizes: ["35"],
+ });
+ expect(traces[0]?.streams[1]).toMatchObject({
+ direction: "received",
+ unique_bytes: "4",
+ });
+ expect(traces[1]?.streams[0]).toMatchObject({ unique_bytes: "3" });
+ expect(traces[0]?.loss_declarations[0]).toMatchObject({
+ confidence: "observed",
+ packet_number: { state: "reported", value: "5" },
+ packet_number_space: "application",
+ });
+ expect(decoded.records.at(-1)?.location).toEqual({
+ kind: "json-pointer",
+ pointer: "/traces/1/events/0",
+ });
+ expect(decoded.container.reported).toMatchObject({
+ extra: original.extra,
+ ["__proto__"]: { ordinary: true },
+ });
+ expect(Reflect.get(Object.prototype, "ordinary")).toBeUndefined();
+});
+
+it("keeps exact 62-bit integers and numeric lexemes without allocating exponent expansion", () => {
+ const text = capture([event([frame(0, 0, true)])])
+ .replace('"stream_id":0', '"stream_id":4611686018427387903')
+ .replace('"offset":0', '"offset":9007199254740993')
+ .replace('"length":0', '"length":1e3');
+ const { decoded, traces } = facts(text);
+ expect(traces[0]?.streams[0]).toMatchObject({
+ stream_id: "4611686018427387903",
+ unique_bytes: "1000",
+ reported_final_sizes: ["9007199254741993"],
+ });
+ expect(decoded.records[0]?.numeric_literals).toContainEqual({
+ pointer: "/data/frames/0/length",
+ producer_type: "json-number",
+ literal: "1e3",
+ });
+ expect(decoded.records[0]?.reported).toMatchObject({
+ data: { frames: [{ stream_id: null, offset: null, length: 1000 }] },
+ });
+});
+
+it("keeps incomplete fields unknown and FIN without bytes as metadata", () => {
+ const { traces } = facts(
+ capture([
+ event([{ frame_type: "stream", stream_id: 0, length: 5 }]),
+ event([frame(10, 0, true)]),
+ { name: "recovery:packet_lost", time: 1, data: {} },
+ {
+ name: "extension:custom",
+ time: 0,
+ data: { body: "ordinary evidence" },
+ },
+ ]),
+ );
+ expect(traces[0]?.incomplete_fact_records).toEqual([0]);
+ expect(traces[0]?.streams[0]).toMatchObject({
+ unique_bytes: "0",
+ covered_ranges: [],
+ reported_final_sizes: ["10"],
+ });
+ expect(traces[0]?.loss_declarations[0]?.packet_number.state).toBe("unknown");
+});
+
+it("excludes structural auth material and explicit normalized values without guessing extension secrecy", () => {
+ const text = capture([
+ {
+ name: "http:frame_parsed",
+ time: 1,
+ data: {
+ frame: {
+ frame_type: "headers",
+ headers: [
+ { name: "authorization", value: "Bearer credential" },
+ { name: ":path", value: "/?token=ordinary" },
+ { name: "location", value: "https://user:pass@example.test/a" },
+ ],
+ },
+ },
+ },
+ {
+ name: "transport:parameters_set",
+ time: 2,
+ data: { stateless_reset_token: "reset-credential" },
+ },
+ event([
+ { frame_type: "new_connection_id", reset_token: "reset-frame" },
+ { frame_type: "new_token", token: "new-token" },
+ frame(0, 100),
+ ]),
+ {
+ name: "extension:custom",
+ time: 3,
+ data: {
+ secret: "caller-selected ordinary value",
+ reset_token: "extension ordinary",
+ },
+ },
+ ]).replace('"length":100', '"length":1e2');
+ const { decoded, traces } = facts(text, ["100"]);
+ const serialized = JSON.stringify(decoded);
+ for (const hidden of [
+ "Bearer credential",
+ "reset-credential",
+ "reset-frame",
+ "new-token",
+ "100",
+ "user:pass",
+ ])
+ expect(serialized).not.toContain(hidden);
+ expect(serialized).toContain("token=ordinary");
+ expect(serialized).toContain("caller-selected ordinary value");
+ expect(traces[0]?.streams).toEqual([]);
+ expect(traces[0]?.incomplete_fact_records).toContain(2);
+});
+
+it("hides marked ancestor keys and safe derived sums while still validating hidden subtrees", () => {
+ const text = capture([event([frame(0, 3), frame(3, 4)])]);
+ expect(facts(text, ["7"]).traces[0]?.streams).toEqual([]);
+ const { decoded, traces } = facts(text, ["events"]);
+ expect(decoded.records[0]?.reported).toBe(null);
+ expect(decoded.records[0]?.location.kind).toBe("unknown");
+ expect(traces[0]?.location.kind).toBe("unknown");
+ expect(traces[0]?.streams).toEqual([]);
+ expect(() =>
+ facts(text.replace('"length":3', '"length":-3'), ["events"]),
+ ).toThrow(CaptureFormatError);
+});
+
+it.each([
+ "-1",
+ "0.5",
+ "4611686018427387904",
+ "1e1000000",
+ "1e-1000000",
+ '"3"',
+ "true",
+])("rejects malformed or out-of-range STREAM integer %s", (literal) => {
+ expect(() =>
+ facts(
+ capture([event([frame(0, 3)])]).replace(
+ '"length":3',
+ `"length":${literal}`,
+ ),
+ ),
+ ).toThrow(CaptureFormatError);
+});
+
+it.each([
+ '{"qlog_format":"JSON","qlog_format":"JSON","qlog_version":"0.3","traces":[]}',
+ capture([]).slice(0, -1),
+ capture([]) + " {}",
+ capture([{ name: "invalid", time: "1", data: {} }]),
+ capture([[1, "transport", "packet_sent", {}]]),
+ capture([event([{ ...frame(0, 1), fin: "true" }])]),
+ capture([event([{ ...frame(0, 1), offset: 4611686018427388000 }])]),
+])(
+ "rejects malformed JSON/profile events with no partial success: %s",
+ (text) => {
+ expect(() => facts(text)).toThrow(CaptureFormatError);
+ },
+);
+
+it("rejects other qlog containers/versions and excessive nesting, accepts empty profiles and UTF-8 BOM", () => {
+ for (const extra of [{ qlog_format: "JSON-SEQ" }, { qlog_version: "0.4" }])
+ expect(() => facts(capture([], extra))).toThrow("Only the aioquic");
+ expect(() =>
+ facts(
+ capture([], {
+ extension: JSON.parse("[".repeat(65) + "0" + "]".repeat(65)),
+ }),
+ ),
+ ).toThrow("nesting budget");
+ expect(
+ facts('{"qlog_format":"JSON","qlog_version":"0.3","traces":[]}').traces,
+ ).toEqual([]);
+ const raw = Buffer.from(capture([]));
+ expect(
+ facts(
+ decodeCaptureText(
+ Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), raw]),
+ "qlog",
+ ),
+ ).decoded.total_records,
+ ).toBe(0);
+ expect(() => decodeCaptureText(Buffer.from([0xff]), "qlog")).toThrow("UTF-8");
+});
diff --git a/src/browser/history/QlogCapture.ts b/src/browser/history/QlogCapture.ts
new file mode 100644
index 000000000..2e8a1f790
--- /dev/null
+++ b/src/browser/history/QlogCapture.ts
@@ -0,0 +1,503 @@
+import { isSafeNumber, LosslessNumber } from "lossless-json";
+import type { JsonValue } from "../../domain/jsonValue.js";
+import type { QlogCaptureFacts } from "../../domain/qlogCapture.js";
+import {
+ webNetworkCapturePayloadSchema,
+ type WebNetworkCaptureRecord,
+} from "../../domain/webNetworkCapture.js";
+import type { z } from "zod";
+import { parseCaptureJson } from "./CaptureJson.js";
+import { CaptureFormatError } from "./CaptureFormatError.js";
+import {
+ CaptureRedaction,
+ captureObject,
+ capturePointer,
+} from "./CaptureRedaction.js";
+import { QLOG_CAPTURE_PROVIDER_IDENTITY } from "./CaptureRelease.js";
+
+const MAX_VARINT = (1n << 62n) - 1n;
+const credentials = new Set([
+ "authorization",
+ "proxy-authorization",
+ "cookie",
+ "set-cookie",
+]);
+type Projection = Pick<
+ WebNetworkCaptureRecord,
+ "reported" | "numeric_literals" | "binary_fields" | "redactions"
+>;
+type Trace = QlogCaptureFacts["traces"][number];
+type Stream = Trace["streams"][number];
+
+/** Inspect the selected aioquic JSON profile without authenticating its producer or interpreting other qlog layouts. */
+export const decodeQlogCapture = (
+ text: string,
+ sensitiveValues: readonly string[],
+): z.output => {
+ const raw = parseCaptureJson(text, "qlog");
+ if (!object(raw) || raw.qlog_format !== "JSON" || raw.qlog_version !== "0.3")
+ throw new CaptureFormatError(
+ "unsupported",
+ "Only the aioquic 1.5.0 JSON qlog 0.3 profile is supported.",
+ );
+ if (!Array.isArray(raw.traces))
+ return invalid("qlog must contain a traces array.", "/traces");
+ const traces = raw.traces.map(validateTrace);
+ const container = project(
+ { ...raw, traces: traces.map((trace) => ({ ...trace, events: null })) },
+ sensitiveValues,
+ );
+ const records: WebNetworkCaptureRecord[] = [];
+ const facts: Trace[] = [];
+ const excluded = (value: string): boolean =>
+ sensitiveValues.some((literal) => value.includes(literal));
+ const ancestorsHidden = excluded("traces") || excluded("events");
+ for (const [traceIndex, trace] of traces.entries()) {
+ const tracePointer = `/traces/${traceIndex}`;
+ const fact = createTraceFact(
+ trace,
+ traceIndex,
+ sensitiveValues,
+ ancestorsHidden,
+ );
+ const streams = new Map();
+ for (const [eventIndex, event] of trace.events.entries()) {
+ const ordinal = records.length;
+ const projection: Projection = ancestorsHidden
+ ? {
+ reported: null,
+ numeric_literals: [],
+ binary_fields: [],
+ redactions: [{ pointer: "", reason: "explicit-sensitive-value" }],
+ }
+ : project(event, sensitiveValues);
+ records.push({
+ ordinal,
+ location: ancestorsHidden
+ ? { kind: "unknown", reason: "explicit-sensitive-value" }
+ : {
+ kind: "json-pointer",
+ pointer: `${tracePointer}/events/${eventIndex}`,
+ },
+ ...projection,
+ limitations: [
+ "Record ordinal is derived from trace/event source traversal, not global time order. Qlog lengths and FIN do not expose original HTTP body bytes or establish network completeness.",
+ ],
+ });
+ collectTransportFacts(projection, ordinal, { fact, streams, excluded });
+ }
+ for (const stream of streams.values()) {
+ summarizeStream(stream);
+ const numericValues = [
+ stream.frame_length_sum,
+ stream.unique_bytes,
+ stream.repeated_bytes,
+ ...stream.reported_final_sizes,
+ ...stream.covered_ranges.flatMap((r) => [r.start, r.end]),
+ ];
+ if (numericValues.some(excluded))
+ fact.incomplete_fact_records.push(
+ ...stream.contributions.map((c) => c.record_ordinal),
+ );
+ else fact.streams.push(stream);
+ }
+ fact.incomplete_fact_records = [...new Set(fact.incomplete_fact_records)];
+ facts.push(fact);
+ }
+ return {
+ decoder: { ...QLOG_CAPTURE_PROVIDER_IDENTITY },
+ container: {
+ reported: container.reported,
+ numeric_literals: container.numeric_literals,
+ redactions: container.redactions,
+ records_pointer: null,
+ },
+ total_records: records.length,
+ records,
+ qlog: {
+ profile: "aioquic-1.5.0-json-0.3",
+ scope: "all-recorded-events-before-selection",
+ body_bytes: "not-reported",
+ clock: {
+ unit: "milliseconds",
+ domain: "unix-epoch",
+ basis: "selected-aioquic-profile-not-producer-authentication",
+ },
+ traces: facts,
+ },
+ };
+};
+
+const createTraceFact = (
+ trace: Record & { events: unknown[] },
+ traceIndex: number,
+ sensitiveValues: readonly string[],
+ ancestorsHidden: boolean,
+): Trace => {
+ const tracePointer = `/traces/${traceIndex}`;
+ const metadata = project(
+ { ...trace, events: null },
+ sensitiveValues,
+ ).reported;
+ const meta = !ancestorsHidden && object(metadata) ? metadata : null;
+ const common =
+ meta !== null && object(meta.common_fields) ? meta.common_fields : null;
+ const vantage =
+ meta !== null && object(meta.vantage_point)
+ ? meta.vantage_point.type
+ : null;
+ return {
+ trace_index: traceIndex,
+ location: ancestorsHidden
+ ? { kind: "unknown", reason: "explicit-sensitive-value" }
+ : { kind: "json-pointer", pointer: tracePointer },
+ event_count: trace.events.length,
+ odcid: typeof common?.ODCID === "string" ? common.ODCID : null,
+ vantage_point:
+ vantage === "client" || vantage === "server" ? vantage : null,
+ loss_declarations: [],
+ streams: [],
+ incomplete_fact_records: [],
+ };
+};
+
+const validateTrace = (
+ trace: unknown,
+ index: number,
+): Record & { events: unknown[] } => {
+ const pointer = `/traces/${index}`;
+ if (!object(trace) || !Array.isArray(trace.events))
+ return invalid("qlog trace must contain an events array.", pointer);
+ for (const [eventIndex, event] of trace.events.entries()) {
+ const eventPointer = `${pointer}/events/${eventIndex}`;
+ if (
+ !object(event) ||
+ typeof event.name !== "string" ||
+ !(event.time instanceof LosslessNumber) ||
+ !Number.isFinite(Number(event.time.value)) ||
+ !object(event.data)
+ )
+ invalid(
+ "qlog events must be objects with name, finite numeric time and object data.",
+ eventPointer,
+ );
+ // Validate interpretable fields on ORIGINAL values even when a declaration
+ // subsequently hides them. Missing fields remain meaningful unknowns.
+ if (event.name === "recovery:packet_lost")
+ unsigned(event.data.packet_number, `${eventPointer}/data/packet_number`);
+ if (packetEvent(event.name) && Array.isArray(event.data.frames)) {
+ for (const [frameIndex, frame] of event.data.frames.entries()) {
+ if (object(frame) && frame.frame_type === "stream") {
+ validateStreamFrame(
+ frame,
+ `${eventPointer}/data/frames/${frameIndex}`,
+ );
+ }
+ }
+ }
+ }
+ return { ...trace, events: trace.events };
+};
+
+const validateStreamFrame = (
+ frame: Record,
+ p: string,
+): void => {
+ unsigned(frame.stream_id, `${p}/stream_id`);
+ const offset = unsigned(frame.offset, `${p}/offset`);
+ const length = unsigned(frame.length, `${p}/length`);
+ if (offset !== null && length !== null && offset + length > MAX_VARINT)
+ invalid("qlog stream final offset exceeds the QUIC varint range.", p);
+ if (
+ frame.fin !== undefined &&
+ frame.fin !== null &&
+ typeof frame.fin !== "boolean"
+ )
+ invalid("qlog STREAM fin must be boolean when reported.", `${p}/fin`);
+};
+
+const collectTransportFacts = (
+ projection: Projection,
+ ordinal: number,
+ state: {
+ fact: Trace;
+ streams: Map;
+ excluded: (value: string) => boolean;
+ },
+): void => {
+ const { fact, streams, excluded } = state;
+ if (!object(projection.reported) || !object(projection.reported.data)) {
+ fact.incomplete_fact_records.push(ordinal);
+ return;
+ }
+ const visible = projection.reported;
+ const data = visible.data;
+ if (!object(data)) return;
+ const literals = new Map(
+ projection.numeric_literals.map((n) => [n.pointer, n.literal]),
+ );
+ const read = (value: unknown, pointer: string): bigint | null => {
+ const literal = literals.get(pointer);
+ const number = unsigned(
+ literal === undefined ? value : new LosslessNumber(literal),
+ pointer,
+ );
+ return number !== null && excluded(number.toString()) ? null : number;
+ };
+ if (visible.name === "recovery:packet_lost") {
+ fact.loss_declarations.push(lossDeclaration(data, ordinal, read));
+ }
+ if (!packetEvent(visible.name)) return;
+ if (!Array.isArray(data.frames)) {
+ fact.incomplete_fact_records.push(ordinal);
+ return;
+ }
+ for (const [frameIndex, frame] of data.frames.entries()) {
+ if (
+ !object(frame) ||
+ frame.frame_type === undefined ||
+ frame.frame_type === null
+ ) {
+ fact.incomplete_fact_records.push(ordinal);
+ continue;
+ }
+ if (frame.frame_type !== "stream") continue;
+ const p = `/data/frames/${frameIndex}`;
+ const id = read(frame.stream_id, `${p}/stream_id`),
+ offset = read(frame.offset, `${p}/offset`),
+ length = read(frame.length, `${p}/length`);
+ if (id === null || offset === null || length === null) {
+ fact.incomplete_fact_records.push(ordinal);
+ continue;
+ }
+ const direction =
+ visible.name === "transport:packet_sent" ? "sent" : "received";
+ const key = `${direction}/${id}`;
+ let stream = streams.get(key);
+ if (stream === undefined) {
+ stream = {
+ stream_id: id.toString(),
+ direction,
+ confidence: "derived",
+ scope: "observed-stream-frame-ranges",
+ contributions: [],
+ covered_ranges: [],
+ unique_bytes: "0",
+ frame_length_sum: "0",
+ repeated_bytes: "0",
+ reported_final_sizes: [],
+ };
+ streams.set(key, stream);
+ }
+ stream.contributions.push({
+ record_ordinal: ordinal,
+ frame_index: frameIndex,
+ offset: offset.toString(),
+ length: length.toString(),
+ fin: typeof frame.fin === "boolean" ? frame.fin : null,
+ });
+ }
+};
+
+const lossDeclaration = (
+ data: Record,
+ ordinal: number,
+ read: (value: unknown, pointer: string) => bigint | null,
+): Trace["loss_declarations"][number] => {
+ const pn = read(data.packet_number, "/data/packet_number");
+ const type = typeof data.type === "string" ? data.type : null;
+ return {
+ record_ordinal: ordinal,
+ confidence: "observed",
+ packet_number:
+ pn === null
+ ? {
+ state: "unknown",
+ reason: "omitted-null-or-redacted",
+ }
+ : { state: "reported", value: pn.toString() },
+ packet_type: type,
+ packet_number_space:
+ type === "initial"
+ ? "initial"
+ : type === "handshake"
+ ? "handshake"
+ : type === "0RTT" || type === "1RTT"
+ ? "application"
+ : null,
+ };
+};
+
+const summarizeStream = (stream: Stream): void => {
+ const spans = stream.contributions
+ .map(
+ (c) => [BigInt(c.offset), BigInt(c.offset) + BigInt(c.length)] as const,
+ )
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
+ const merged: { start: bigint; end: bigint }[] = [];
+ for (const [start, end] of spans) {
+ if (start === end) continue;
+ const last = merged.at(-1);
+ if (last !== undefined && start <= last.end)
+ last.end = end > last.end ? end : last.end;
+ else merged.push({ start, end });
+ }
+ const sum = stream.contributions.reduce((s, c) => s + BigInt(c.length), 0n);
+ const unique = merged.reduce((s, r) => s + r.end - r.start, 0n);
+ stream.covered_ranges = merged.map((r) => ({
+ start: r.start.toString(),
+ end: r.end.toString(),
+ }));
+ stream.frame_length_sum = sum.toString();
+ stream.unique_bytes = unique.toString();
+ stream.repeated_bytes = (sum - unique).toString();
+ stream.reported_final_sizes = [
+ ...new Set(
+ stream.contributions
+ .filter((c) => c.fin === true)
+ .map((c) => (BigInt(c.offset) + BigInt(c.length)).toString()),
+ ),
+ ];
+};
+
+const packetEvent = (name: unknown): boolean =>
+ name === "transport:packet_sent" || name === "transport:packet_received";
+const object = (value: unknown): value is Record =>
+ captureObject(value) && !(value instanceof LosslessNumber);
+const invalid = (message: string, pointer: string): never => {
+ throw new CaptureFormatError("format", message, pointer);
+};
+
+/** Canonicalize JSON integer notation exactly, checking expansion before allocation. */
+const unsigned = (value: unknown, pointer: string): bigint | null => {
+ if (value === undefined || value === null) return null;
+ const literal =
+ value instanceof LosslessNumber
+ ? value.value
+ : typeof value === "number"
+ ? String(value)
+ : "";
+ const m = /^(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/u.exec(
+ literal,
+ );
+ if (m === null)
+ return invalid("qlog QUIC integer must be a JSON number.", pointer);
+ const digits = `${m[2]}${m[3] ?? ""}`.replace(/^0+/u, "");
+ if (digits === "") return 0n;
+ const exponent = m[4] === undefined ? 0 : Number(m[4]);
+ const shift = exponent - (m[3]?.length ?? 0);
+ const count = digits.length + shift;
+ if (m[1] === "-" || !Number.isSafeInteger(shift) || count > 19 || count <= 0)
+ return invalid(
+ "qlog QUIC integer is negative, fractional or outside the 62-bit range.",
+ pointer,
+ );
+ if (shift < 0 && /[1-9]/u.test(digits.slice(count)))
+ return invalid("qlog QUIC integer is fractional.", pointer);
+ const canonical =
+ shift < 0 ? digits.slice(0, count) : digits + "0".repeat(shift);
+ const result = BigInt(canonical);
+ if (result > MAX_VARINT)
+ return invalid("qlog QUIC integer exceeds the 62-bit range.", pointer);
+ return result;
+};
+
+/** Preserve JSON values/lexemes and structurally exclude only transport credentials and declared values. */
+const project = (
+ value: unknown,
+ sensitiveValues: readonly string[],
+): Projection => {
+ const redactor = new CaptureRedaction(sensitiveValues);
+ const numeric_literals: Projection["numeric_literals"] = [];
+ const eventName = object(value) ? value.name : null;
+ const visit = (
+ item: unknown,
+ pointer: string,
+ parent?: Record,
+ ): JsonValue => {
+ const header =
+ (eventName === "http:frame_created" ||
+ eventName === "http:frame_parsed") &&
+ /^\/data\/frame\/headers\/\d+\/value$/u.test(pointer) &&
+ typeof parent?.name === "string";
+ if (
+ (header && credentials.has(String(parent?.name).toLowerCase())) ||
+ qlogToken(eventName, pointer, parent)
+ )
+ return redactor.credential(pointer);
+ if (item instanceof LosslessNumber) {
+ if (
+ redactor.text(item.value, pointer) === null ||
+ (isSafeNumber(item.value) &&
+ redactor.text(String(Number(item.value)), pointer) === null)
+ )
+ return null;
+ numeric_literals.push({
+ pointer,
+ producer_type: "json-number",
+ literal: item.value,
+ });
+ return isSafeNumber(item.value) ? Number(item.value) : null;
+ }
+ if (typeof item === "string")
+ return redactor.text(
+ item,
+ pointer,
+ header &&
+ ["location", "referer", "origin"].includes(
+ String(parent?.name).toLowerCase(),
+ ),
+ );
+ if (item === null || typeof item === "boolean") return item;
+ if (Array.isArray(item))
+ return item.map((child, index) =>
+ visit(child, capturePointer(pointer, String(index))),
+ );
+ if (object(item))
+ return Object.fromEntries(
+ Object.entries(item).flatMap(([key, child]) => {
+ if (sensitiveValues.some((literal) => key.includes(literal))) {
+ redactor.redactions.push({
+ pointer,
+ reason: "explicit-sensitive-value",
+ scope: "property-name",
+ });
+ return [];
+ }
+ return [[key, visit(child, capturePointer(pointer, key), item)]];
+ }),
+ );
+ return invalid("qlog parser returned a non-JSON value.", pointer);
+ };
+ return {
+ reported: visit(value, ""),
+ numeric_literals,
+ binary_fields: [],
+ redactions: redactor.redactions,
+ };
+};
+
+const qlogToken = (
+ eventName: unknown,
+ pointer: string,
+ parent?: Record,
+): boolean => {
+ if (
+ packetEvent(eventName) &&
+ /^\/data\/frames\/\d+\/reset_token$/u.test(pointer) &&
+ parent?.frame_type === "new_connection_id"
+ )
+ return true;
+ if (
+ packetEvent(eventName) &&
+ /^\/data\/frames\/\d+\/token$/u.test(pointer) &&
+ parent?.frame_type === "new_token"
+ )
+ return true;
+ if (
+ eventName === "transport:parameters_set" &&
+ pointer === "/data/stateless_reset_token"
+ )
+ return true;
+ return false;
+};
diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts
index 336c79364..655d34bc1 100644
--- a/src/cli/webNetworkCaptureCommands.ts
+++ b/src/cli/webNetworkCaptureCommands.ts
@@ -17,7 +17,7 @@ export const registerWebNetworkCaptureCommands = (
const service = createWebNetworkCaptureService(environment);
cli.command(CLI_COMMANDS.inspectWebNetworkCapture, {
description:
- "Inspect retained HAR, native mitmproxy, or Fiddler SAZ records without fetching recorded URLs",
+ "Inspect retained HAR, native mitmproxy, Fiddler SAZ or aioquic qlog records without fetching recorded URLs",
args: z.object({
capturePath: z.string().describe("Absolute local capture path"),
format: z
diff --git a/src/composition/webNetworkCaptures.ts b/src/composition/webNetworkCaptures.ts
index 017182500..9d9e8c6cc 100644
--- a/src/composition/webNetworkCaptures.ts
+++ b/src/composition/webNetworkCaptures.ts
@@ -12,6 +12,7 @@ export const createWebNetworkCaptureService = (
[
new NodeCaptureAdapter("har"),
new NodeCaptureAdapter("saz"),
+ new NodeCaptureAdapter("qlog"),
new MitmproxyCaptureAdapter(environment),
],
environment,
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index 161d9d7b4..06506c83e 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -16,7 +16,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_evm_interface, inspect_managed_artifact, inspect_android_package 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
+ 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
diff --git a/src/contracts/webNetworkCaptureToolContracts.ts b/src/contracts/webNetworkCaptureToolContracts.ts
index 06420f960..592dd9b54 100644
--- a/src/contracts/webNetworkCaptureToolContracts.ts
+++ b/src/contracts/webNetworkCaptureToolContracts.ts
@@ -16,7 +16,7 @@ export const WEB_NETWORK_CAPTURE_TOOL_CONTRACTS = [
name: "inspect_web_network_capture",
...toolContractMetadata("inspect_web_network_capture"),
description:
- "Request detail summary for capture identity and counts; then page retained records with inspect_analysis_view kind capture (preview or complete), avoiding repeated decoding. Default detail complete preserves existing inline delivery. Inspect retained local HAR 1.2, native mitmproxy, or Fiddler SAZ capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges/archive member names, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named members such as __proto__ are preserved as ordinary own JSON members without prototype mutation. Uses pinned upstream parsers, owned temporary files and bounded offline processes. SAZ preserves HTTP fields, XML metadata and encoded body/message bytes without replay or HAR conversion; encrypted archives are unsupported. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.",
+ "Request detail summary for capture identity and counts; then page retained records with inspect_analysis_view kind capture (preview or complete), avoiding repeated decoding. Default detail complete preserves existing inline delivery. Inspect retained local HAR 1.2, native mitmproxy, Fiddler SAZ or aioquic 1.5.0 JSON qlog 0.3 events without contacting recorded URLs. Returns original ordinals, pointers/byte ranges/archive member names, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate JSON members are invalid; prototype-named members such as __proto__ are preserved as ordinary own JSON members without prototype mutation. Qlog reports trace-scoped STREAM interval unions and endpoint loss declarations; it does not recover bodies, prove network loss or correlate PCAP. Other qlog layouts are unsupported. Uses pinned upstream parsers, owned temporary files and bounded offline processes. SAZ preserves HTTP fields, XML metadata and encoded body/message bytes without replay or HAR conversion; encrypted archives are unsupported. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.",
kind: "browser-provider",
inputSchema: inspectWebNetworkCaptureInputSchema.extend({
detail: analysisDetailSchema,
@@ -26,6 +26,14 @@ export const WEB_NETWORK_CAPTURE_TOOL_CONTRACTS = [
webNetworkCaptureSummaryViewResultSchema,
),
examples: [
+ {
+ title: "Inspect retained aioquic qlog events",
+ input: {
+ capture_path: "/captures/origin.qlog",
+ format: "qlog",
+ record_ordinals: [0, 2],
+ },
+ },
{
title: "Inspect historical HAR entries",
input: {
diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts
index 6b3b6bef8..05f879024 100644
--- a/src/domain/analysisView/analysisView.ts
+++ b/src/domain/analysisView/analysisView.ts
@@ -62,6 +62,11 @@ import {
captureSummarySchema,
} from "./webNetworkCaptureView.js";
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
+import {
+ qlogViewRequestSchema,
+ qlogViewPageSchema,
+ qlogViewContextSchema,
+} from "./qlogCaptureView.js";
import {
projectAndroidPackageView,
androidPackageSummarySchema,
@@ -184,6 +189,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
androidViewRequestSchema,
apiSurfaceViewSchema,
captureViewRequestSchema,
+ qlogViewRequestSchema,
]);
// The application owns admission and authenticated Evidence parsing. SDK and
@@ -403,6 +409,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_web_network_capture"),
}),
+ z.strictObject({
+ kind: z.literal("qlog"),
+ view: qlogViewRequestSchema,
+ page: qlogViewPageSchema,
+ context: qlogViewContextSchema,
+ ...viewResultBase,
+ parent_operation: z.literal("inspect_web_network_capture"),
+ }),
z.strictObject({
kind: z.literal("facet"),
view: facetViewSchema,
@@ -523,6 +537,7 @@ export type UnsignedAnalysisView =
>
| Omit, "view_digest">
| Omit, "view_digest">
+ | Omit, "view_digest">
| Omit<
Extract,
"view_digest"
diff --git a/src/domain/analysisView/qlogCaptureView.ts b/src/domain/analysisView/qlogCaptureView.ts
new file mode 100644
index 000000000..a3bdf88c3
--- /dev/null
+++ b/src/domain/analysisView/qlogCaptureView.ts
@@ -0,0 +1,248 @@
+import { z } from "zod";
+import {
+ qlogCaptureFactsSchema,
+ type QlogCaptureFacts,
+} from "../qlogCapture.js";
+import { err, ok, type Result } from "../result.js";
+import type { AnalysisError } from "../analysisErrorBase.js";
+import { analysisViewInputError } from "./analysisView.js";
+import { pageViewCoverage } from "./analysisViewCoverage.js";
+
+const traceSchema = qlogCaptureFactsSchema.shape.traces.element;
+const streamSchema = traceSchema.shape.streams.element;
+export const qlogViewContextSchema = qlogCaptureFactsSchema.omit({
+ traces: true,
+});
+export const qlogSummarySchema = qlogViewContextSchema.extend({
+ trace_count: z.number().int().nonnegative(),
+ retained_stream_count: z.number().int().nonnegative(),
+ loss_declaration_count: z.number().int().nonnegative(),
+ incomplete_fact_record_count: z.number().int().nonnegative(),
+});
+
+/** Select retained trace facts without copying event inventories or every contribution. */
+const pageRequestShape = {
+ kind: z.literal("qlog"),
+ offset: z.number().int().nonnegative().default(0),
+ limit: z.number().int().positive().default(64),
+};
+const traceSelectorShape = { trace_index: z.number().int().nonnegative() };
+const streamSelectorShape = {
+ ...traceSelectorShape,
+ stream_id: streamSchema.shape.stream_id,
+ direction: streamSchema.shape.direction,
+};
+export const qlogViewRequestSchema = z.discriminatedUnion("facet", [
+ z.strictObject({ ...pageRequestShape, facet: z.literal("traces") }),
+ z.strictObject({
+ ...pageRequestShape,
+ ...traceSelectorShape,
+ facet: z.literal("streams"),
+ }),
+ z.strictObject({
+ ...pageRequestShape,
+ ...traceSelectorShape,
+ facet: z.literal("loss-declarations"),
+ }),
+ z.strictObject({
+ ...pageRequestShape,
+ ...traceSelectorShape,
+ facet: z.literal("incomplete-records"),
+ }),
+ z.strictObject({
+ ...pageRequestShape,
+ ...streamSelectorShape,
+ facet: z.literal("contributions"),
+ }),
+ z.strictObject({
+ ...pageRequestShape,
+ ...streamSelectorShape,
+ facet: z.literal("ranges"),
+ }),
+ z.strictObject({
+ ...pageRequestShape,
+ ...streamSelectorShape,
+ facet: z.literal("final-sizes"),
+ }),
+]);
+export type QlogViewRequest = z.output;
+
+const traceSummarySchema = traceSchema
+ .omit({
+ streams: true,
+ loss_declarations: true,
+ incomplete_fact_records: true,
+ })
+ .extend({
+ stream_count: z.number().int().nonnegative(),
+ loss_declaration_count: z.number().int().nonnegative(),
+ incomplete_fact_record_count: z.number().int().nonnegative(),
+ });
+const streamSummarySchema = streamSchema
+ .omit({
+ contributions: true,
+ covered_ranges: true,
+ reported_final_sizes: true,
+ })
+ .extend({
+ contribution_count: z.number().int().nonnegative(),
+ covered_range_count: z.number().int().nonnegative(),
+ reported_final_size_count: z.number().int().nonnegative(),
+ });
+/** Each page advertises exactly the retained item contract for its facet. */
+export const qlogViewPageSchema = z.discriminatedUnion("facet", [
+ z.strictObject({
+ facet: z.literal("traces"),
+ items: z.array(traceSummarySchema),
+ }),
+ z.strictObject({
+ facet: z.literal("streams"),
+ items: z.array(streamSummarySchema),
+ }),
+ z.strictObject({
+ facet: z.literal("loss-declarations"),
+ items: traceSchema.shape.loss_declarations,
+ }),
+ z.strictObject({
+ facet: z.literal("incomplete-records"),
+ items: traceSchema.shape.incomplete_fact_records,
+ }),
+ z.strictObject({
+ facet: z.literal("contributions"),
+ items: streamSchema.shape.contributions,
+ }),
+ z.strictObject({
+ facet: z.literal("ranges"),
+ items: streamSchema.shape.covered_ranges,
+ }),
+ z.strictObject({
+ facet: z.literal("final-sizes"),
+ items: streamSchema.shape.reported_final_sizes,
+ }),
+]);
+type QlogViewPage = z.output;
+
+export const summarizeQlogCapture = (qlog: QlogCaptureFacts) => {
+ const { traces, ...context } = qlog;
+ return {
+ ...context,
+ trace_count: traces.length,
+ retained_stream_count: traces.reduce(
+ (sum, trace) => sum + trace.streams.length,
+ 0,
+ ),
+ loss_declaration_count: traces.reduce(
+ (sum, trace) => sum + trace.loss_declarations.length,
+ 0,
+ ),
+ incomplete_fact_record_count: traces.reduce(
+ (sum, trace) => sum + trace.incomplete_fact_records.length,
+ 0,
+ ),
+ };
+};
+
+const summarizeTrace = ({
+ streams,
+ loss_declarations,
+ incomplete_fact_records,
+ ...trace
+}: QlogCaptureFacts["traces"][number]) => ({
+ ...trace,
+ stream_count: streams.length,
+ loss_declaration_count: loss_declarations.length,
+ incomplete_fact_record_count: incomplete_fact_records.length,
+});
+const summarizeStream = ({
+ contributions,
+ covered_ranges,
+ reported_final_sizes,
+ ...stream
+}: QlogCaptureFacts["traces"][number]["streams"][number]) => ({
+ ...stream,
+ contribution_count: contributions.length,
+ covered_range_count: covered_ranges.length,
+ reported_final_size_count: reported_final_sizes.length,
+});
+
+/** Array offsets refer to retained facts, whose scope remains all recorded events. */
+export const projectQlogCapturePage = (
+ qlog: QlogCaptureFacts,
+ view: QlogViewRequest,
+): Result<
+ {
+ context: z.output;
+ page: QlogViewPage;
+ coverage: ReturnType;
+ },
+ AnalysisError
+> => {
+ const invalid = (field: string, message: string) =>
+ err(
+ analysisViewInputError([
+ { path: ["view", field], reason: "invalid_value", message },
+ ]),
+ );
+ const slice = (items: readonly T[]) =>
+ items.slice(view.offset, view.offset + view.limit);
+ let page: QlogViewPage;
+ let total: number;
+ if (view.facet === "traces") {
+ total = qlog.traces.length;
+ page = {
+ facet: "traces",
+ items: slice(qlog.traces).map(summarizeTrace),
+ };
+ } else {
+ const matches = qlog.traces.filter(
+ (trace) => trace.trace_index === view.trace_index,
+ );
+ if (matches.length !== 1)
+ return invalid(
+ "trace_index",
+ "Select one unambiguous retained trace index.",
+ );
+ const trace = matches[0]!;
+ if (view.facet === "streams") {
+ total = trace.streams.length;
+ page = {
+ facet: "streams",
+ items: slice(trace.streams).map(summarizeStream),
+ };
+ } else if (view.facet === "loss-declarations") {
+ total = trace.loss_declarations.length;
+ page = { facet: view.facet, items: slice(trace.loss_declarations) };
+ } else if (view.facet === "incomplete-records") {
+ total = trace.incomplete_fact_records.length;
+ page = { facet: view.facet, items: slice(trace.incomplete_fact_records) };
+ } else {
+ const matches = trace.streams.filter(
+ (stream) =>
+ stream.stream_id === view.stream_id &&
+ stream.direction === view.direction,
+ );
+ if (matches.length !== 1)
+ return invalid(
+ "stream_id",
+ "Select one unambiguous retained stream ID and direction within the trace.",
+ );
+ const stream = matches[0]!;
+ if (view.facet === "contributions") {
+ total = stream.contributions.length;
+ page = { facet: view.facet, items: slice(stream.contributions) };
+ } else if (view.facet === "ranges") {
+ total = stream.covered_ranges.length;
+ page = { facet: view.facet, items: slice(stream.covered_ranges) };
+ } else {
+ total = stream.reported_final_sizes.length;
+ page = { facet: view.facet, items: slice(stream.reported_final_sizes) };
+ }
+ }
+ }
+ const { traces: _traces, ...context } = qlog;
+ return ok({
+ context,
+ page,
+ coverage: pageViewCoverage(view.offset, page.items.length, total),
+ });
+};
diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts
index 07495466c..b335e9241 100644
--- a/src/domain/analysisView/webNetworkCaptureView.ts
+++ b/src/domain/analysisView/webNetworkCaptureView.ts
@@ -7,7 +7,11 @@ import {
webNetworkCaptureRecordSchema,
webNetworkCaptureBinaryDescriptorSchema,
webNetworkCaptureSchema,
+ webNetworkCaptureArtifactSchema,
+ webNetworkCaptureDecoderSchema,
+ WEB_NETWORK_CAPTURE_FORMATS,
type WebNetworkCaptureRecord,
+ type WebNetworkCapture,
} from "../webNetworkCapture.js";
import {
analysisViewInputError,
@@ -19,6 +23,11 @@ import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
+import {
+ qlogSummarySchema,
+ summarizeQlogCapture,
+ projectQlogCapturePage,
+} from "./qlogCaptureView.js";
/** Offsets page the parent's retained record order, preserving producer ordinals. */
export const captureViewRequestSchema = z.strictObject({
@@ -37,12 +46,13 @@ export const captureViewItemsSchema = z.array(
/** Count/identity overview; bodies and the archive member inventory stay in the parent. */
export const captureSummarySchema = z.strictObject({
- artifact: webNetworkCaptureSchema.shape.artifact,
- decoder: webNetworkCaptureSchema.shape.decoder,
- format: webNetworkCaptureSchema.shape.format,
+ artifact: webNetworkCaptureArtifactSchema,
+ decoder: webNetworkCaptureDecoderSchema,
+ format: z.enum(WEB_NETWORK_CAPTURE_FORMATS),
total_records: z.number().int().nonnegative(),
retained_records: z.number().int().nonnegative(),
runtime_attribution: z.literal("unknown"),
+ qlog: qlogSummarySchema.optional(),
});
const object = (
@@ -104,22 +114,10 @@ const preview = (record: WebNetworkCaptureRecord) => {
};
};
-/** Page immutable retained facts; no capture file, provider or network is consulted. */
-export const projectWebNetworkCaptureView = (
+/** Authenticate the retained report and its subject at one owning boundary. */
+const parseCaptureParent = (
parent: AnalysisViewParent,
- view: AnalysisViewRequest,
-): Result => {
- if (view.kind !== "summary" && view.kind !== "capture")
- return err(
- analysisViewInputError([
- {
- path: ["view"],
- reason: "invalid_value",
- message:
- "Historical captures support summary, capture record pages, and admitted api-surface views.",
- },
- ]),
- );
+): Result => {
const parsed = webNetworkCaptureSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
@@ -150,6 +148,32 @@ export const projectWebNetworkCaptureView = (
},
]),
);
+ return ok(report);
+};
+
+/** Page immutable retained facts; no capture file, provider or network is consulted. */
+export const projectWebNetworkCaptureView = (
+ parent: AnalysisViewParent,
+ view: AnalysisViewRequest,
+): Result => {
+ if (
+ view.kind !== "summary" &&
+ view.kind !== "capture" &&
+ view.kind !== "qlog"
+ )
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view"],
+ reason: "invalid_value",
+ message:
+ "Historical captures support summary, capture record pages, qlog fact pages and admitted api-surface views.",
+ },
+ ]),
+ );
+ const parsed = parseCaptureParent(parent);
+ if (!parsed.ok) return parsed;
+ const report = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_web_network_capture" as const,
@@ -159,7 +183,7 @@ export const projectWebNetworkCaptureView = (
...new Set([
...parent.limitations,
...report.limitations,
- "Pages cover the records selected into the parent Evidence, in that selected order; total_records counts the original decoded capture.",
+ "Capture record pages cover the records selected into the parent Evidence, in that selected order; total_records counts the original decoded capture.",
]),
],
unknowns: [
@@ -181,12 +205,40 @@ export const projectWebNetworkCaptureView = (
total_records: report.total_records,
retained_records: report.records.length,
runtime_attribution: report.runtime_attribution,
+ ...(report.format === "qlog"
+ ? { qlog: summarizeQlogCapture(report.qlog) }
+ : {}),
},
coverage: completeWithinViewCoverage(
report.records.length,
report.records.length,
),
});
+ if (view.kind === "qlog") {
+ if (report.format !== "qlog")
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view", "kind"],
+ reason: "invalid_value",
+ message: "Qlog fact views require retained qlog Evidence.",
+ },
+ ]),
+ );
+ const projected = projectQlogCapturePage(report.qlog, view);
+ return projected.ok
+ ? ok({
+ kind: "qlog",
+ view,
+ ...shared,
+ ...projected.value,
+ limitations: [
+ ...shared.limitations,
+ "Qlog fact pages retain all-recorded-events-before-selection scope. Exhausting a page does not establish network completeness, body recovery, retransmission or actual loss.",
+ ],
+ })
+ : projected;
+ }
const records = report.records.slice(view.offset, view.offset + view.limit);
return ok({
kind: "capture",
diff --git a/src/domain/qlogCapture.ts b/src/domain/qlogCapture.ts
new file mode 100644
index 000000000..575cf2a60
--- /dev/null
+++ b/src/domain/qlogCapture.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+const decimal = z.string().regex(/^(0|[1-9][0-9]*)$/u);
+const unsignedFact = z.discriminatedUnion("state", [
+ z.strictObject({ state: z.literal("reported"), value: decimal }),
+ z.strictObject({
+ state: z.literal("unknown"),
+ reason: z.literal("omitted-null-or-redacted"),
+ }),
+]);
+const traceLocation = z.discriminatedUnion("kind", [
+ z.strictObject({ kind: z.literal("json-pointer"), pointer: z.string() }),
+ z.strictObject({
+ kind: z.literal("unknown"),
+ reason: z.literal("explicit-sensitive-value"),
+ }),
+]);
+
+/** Exact, source-bound transport facts; coverage never establishes network completeness. */
+export const qlogCaptureFactsSchema = z.strictObject({
+ profile: z.literal("aioquic-1.5.0-json-0.3"),
+ scope: z.literal("all-recorded-events-before-selection"),
+ body_bytes: z.literal("not-reported"),
+ clock: z.strictObject({
+ unit: z.literal("milliseconds"),
+ domain: z.literal("unix-epoch"),
+ basis: z.literal("selected-aioquic-profile-not-producer-authentication"),
+ }),
+ traces: z.array(
+ z.strictObject({
+ trace_index: z.number().int().nonnegative(),
+ location: traceLocation,
+ event_count: z.number().int().nonnegative(),
+ odcid: z.string().nullable(),
+ vantage_point: z.enum(["client", "server"]).nullable(),
+ incomplete_fact_records: z.array(z.number().int().nonnegative()),
+ loss_declarations: z.array(
+ z.strictObject({
+ record_ordinal: z.number().int().nonnegative(),
+ confidence: z.literal("observed"),
+ packet_number: unsignedFact,
+ packet_type: z.string().nullable(),
+ packet_number_space: z
+ .enum(["initial", "handshake", "application"])
+ .nullable(),
+ }),
+ ),
+ streams: z.array(
+ z.strictObject({
+ stream_id: decimal,
+ direction: z.enum(["sent", "received"]),
+ confidence: z.literal("derived"),
+ scope: z.literal("observed-stream-frame-ranges"),
+ contributions: z.array(
+ z.strictObject({
+ record_ordinal: z.number().int().nonnegative(),
+ frame_index: z.number().int().nonnegative(),
+ offset: decimal,
+ length: decimal,
+ fin: z.boolean().nullable(),
+ }),
+ ),
+ covered_ranges: z.array(
+ z.strictObject({ start: decimal, end: decimal }),
+ ),
+ unique_bytes: decimal,
+ frame_length_sum: decimal,
+ repeated_bytes: decimal,
+ reported_final_sizes: z.array(decimal),
+ }),
+ ),
+ }),
+ ),
+});
+
+export type QlogCaptureFacts = z.output;
diff --git a/src/domain/webNetworkCapture.ts b/src/domain/webNetworkCapture.ts
index ec98ca496..77752f8e5 100644
--- a/src/domain/webNetworkCapture.ts
+++ b/src/domain/webNetworkCapture.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { jsonValueSchema } from "./jsonValue.js";
import { localPathStringSchema } from "./localPath.js";
+import { qlogCaptureFactsSchema } from "./qlogCapture.js";
/** Complete historical evidence budgets; exceeding a budget returns no partial capture. */
export const WEB_NETWORK_CAPTURE_LIMITS = {
@@ -10,12 +11,17 @@ export const WEB_NETWORK_CAPTURE_LIMITS = {
depth: 64,
} as const;
-export const WEB_NETWORK_CAPTURE_FORMATS = ["har", "mitmproxy", "saz"] as const;
+export const WEB_NETWORK_CAPTURE_FORMATS = [
+ "har",
+ "mitmproxy",
+ "saz",
+ "qlog",
+] as const;
/** Inspect an explicit retained capture without contacting its recorded destinations. */
export const inspectWebNetworkCaptureInputSchema = z.strictObject({
capture_path: localPathStringSchema.describe(
- "Absolute local HAR, mitmproxy, or Fiddler SAZ capture path.",
+ "Absolute local HAR, mitmproxy, Fiddler SAZ or aioquic qlog capture path.",
),
format: z.enum(WEB_NETWORK_CAPTURE_FORMATS),
record_ordinals: z
@@ -123,69 +129,85 @@ export const webNetworkCaptureRecordSchema = z.strictObject({
limitations: z.array(z.string()),
});
-/** Decoded records retain unique producer ordinals within their declared coverage. */
-export const webNetworkCapturePayloadSchema = z
- .strictObject({
- decoder: z.strictObject({
- id: z.string().min(1),
- name: z.string().min(1),
- version: z.string().min(1),
- }),
- container: z.strictObject({
- reported: jsonValueSchema,
- numeric_literals: z.array(numberSchema),
- redactions: z.array(redactionSchema),
- records_pointer: pointerSchema.nullable(),
- }),
- total_records: z.number().int().nonnegative(),
- records: z.array(webNetworkCaptureRecordSchema),
- })
- .superRefine((capture, context) => {
- if (capture.records.length > capture.total_records) {
+/** The selected offline parser profile, independent of authenticated producer identity. */
+export const webNetworkCaptureDecoderSchema = z.strictObject({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ version: z.string().min(1),
+});
+
+/** Source coverage is shared by every offline decoder. */
+const payloadShape = {
+ decoder: webNetworkCaptureDecoderSchema,
+ container: z.strictObject({
+ reported: jsonValueSchema,
+ numeric_literals: z.array(numberSchema),
+ redactions: z.array(redactionSchema),
+ records_pointer: pointerSchema.nullable(),
+ }),
+ total_records: z.number().int().nonnegative(),
+ records: z.array(webNetworkCaptureRecordSchema),
+};
+const refineCoverage = (
+ capture: { total_records: number; records: { ordinal: number }[] },
+ context: z.RefinementCtx,
+): void => {
+ const seen = new Set();
+ for (const [index, record] of capture.records.entries()) {
+ if (record.ordinal >= capture.total_records || seen.has(record.ordinal)) {
context.addIssue({
code: "custom",
- path: ["records"],
- message: "Retained record count exceeds the declared capture total.",
+ path: ["records", index, "ordinal"],
+ message:
+ "Retained ordinals must be unique and within the declared capture total.",
});
return;
}
- const seen = new Set();
- for (const [index, record] of capture.records.entries()) {
- if (record.ordinal >= capture.total_records || seen.has(record.ordinal)) {
- context.addIssue({
- code: "custom",
- path: ["records", index, "ordinal"],
- message:
- "Retained ordinals must be unique and within the declared capture total.",
- });
- return;
- }
- seen.add(record.ordinal);
- }
- });
-
-/** Historical capture inspection has file identity, producer provenance and explicit unknowns. */
-export const webNetworkCaptureSchema =
- webNetworkCapturePayloadSchema.safeExtend({
- artifact: z.strictObject({
- path: z
- .string()
- .describe(
- "Selected artifact path; empty when explicitly excluded from Evidence.",
- ),
- sha256: z
- .string()
- .regex(/^[0-9a-f]{64}$/)
- .nullable()
- .describe(
- "Observed artifact SHA-256; null only when explicitly excluded by a sensitive declaration.",
- ),
- bytes: z.number().int().nonnegative(),
+ seen.add(record.ordinal);
+ }
+};
+/** Strict alternatives prevent undefined-valued facets from entering JSON Evidence. */
+export const webNetworkCapturePayloadSchema = z
+ .union([
+ z.strictObject({ ...payloadShape, qlog: qlogCaptureFactsSchema }),
+ z.strictObject(payloadShape),
+ ])
+ .superRefine(refineCoverage);
+/** Caller-selected file identity; explicit exclusions remain unknown. */
+export const webNetworkCaptureArtifactSchema = z.strictObject({
+ path: z
+ .string()
+ .describe(
+ "Selected artifact path; empty when explicitly excluded from Evidence.",
+ ),
+ sha256: z
+ .string()
+ .regex(/^[0-9a-f]{64}$/)
+ .nullable()
+ .describe("Observed SHA-256; null only when explicitly excluded."),
+ bytes: z.number().int().nonnegative(),
+});
+const artifactShape = {
+ artifact: webNetworkCaptureArtifactSchema,
+ runtime_attribution: z.literal("unknown"),
+ limitations: z.array(z.string()),
+};
+/** Qlog transport facts occur exactly on the qlog alternative. */
+export const webNetworkCaptureSchema = z
+ .discriminatedUnion("format", [
+ z.strictObject({
+ ...payloadShape,
+ ...artifactShape,
+ format: z.enum(["har", "mitmproxy", "saz"]),
}),
- format: z.enum(WEB_NETWORK_CAPTURE_FORMATS),
- runtime_attribution: z.literal("unknown"),
- limitations: z.array(z.string()),
- });
+ z.strictObject({
+ ...payloadShape,
+ ...artifactShape,
+ format: z.literal("qlog"),
+ qlog: qlogCaptureFactsSchema,
+ }),
+ ])
+ .superRefine(refineCoverage);
export type InspectWebNetworkCaptureInput = z.output<
typeof inspectWebNetworkCaptureInputSchema
diff --git a/src/domain/webNetworkCaptureCoordinates.ts b/src/domain/webNetworkCaptureCoordinates.ts
index 617dd7607..a975c6b4a 100644
--- a/src/domain/webNetworkCaptureCoordinates.ts
+++ b/src/domain/webNetworkCaptureCoordinates.ts
@@ -53,8 +53,30 @@ export const excludeCaptureCoordinates = (
numeric_literals: sidecars(value.container.numeric_literals),
redactions: sidecars(value.container.redactions),
};
+ const qlog =
+ value.format === "qlog"
+ ? {
+ ...value.qlog,
+ traces: value.qlog.traces.map((trace) => {
+ if (
+ trace.location.kind !== "json-pointer" ||
+ !sensitive(trace.location.pointer)
+ )
+ return trace;
+ excluded = true;
+ return {
+ ...trace,
+ location: {
+ kind: "unknown" as const,
+ reason: "explicit-sensitive-value" as const,
+ },
+ };
+ }),
+ }
+ : undefined;
return {
...value,
+ ...(qlog === undefined ? {} : { qlog }),
container,
records,
limitations: excluded
diff --git a/src/server/registerAnalysisViewTool.ts b/src/server/registerAnalysisViewTool.ts
index ebd204edd..d427c0cb4 100644
--- a/src/server/registerAnalysisViewTool.ts
+++ b/src/server/registerAnalysisViewTool.ts
@@ -7,7 +7,10 @@ import { toolContract } from "../contracts/toolContracts.js";
import type { Logger } from "pino";
import { createEvidenceToolHandler } from "./contractToolHandler.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
-import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
+import {
+ toolRegistrationOptions,
+ applicationValidatedInputSchema,
+} from "./toolRegistrationOptions.js";
/** Bind selected-view projection to its named contract and session Evidence owner. */
export const registerAnalysisViewTool = (
@@ -18,6 +21,7 @@ export const registerAnalysisViewTool = (
withAdmittedAnalysis?: WithAdmittedAnalysis,
): void => {
const contract = toolContract("inspect_analysis_view");
+ const registration = toolRegistrationOptions(contract);
const handler = createEvidenceToolHandler(
server,
(input) => Promise.resolve(inspectAnalysisView(input, evidenceById)),
@@ -27,7 +31,10 @@ export const registerAnalysisViewTool = (
);
server.registerTool(
contract.name,
- toolRegistrationOptions(contract),
+ {
+ ...registration,
+ inputSchema: applicationValidatedInputSchema(registration.inputSchema),
+ },
handler(contract),
);
};
diff --git a/src/server/registerWebNetworkCaptureTool.ts b/src/server/registerWebNetworkCaptureTool.ts
index 8b1203745..3e9759504 100644
--- a/src/server/registerWebNetworkCaptureTool.ts
+++ b/src/server/registerWebNetworkCaptureTool.ts
@@ -1,5 +1,4 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
-import type { StandardSchemaWithJSON } from "@modelcontextprotocol/server";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
@@ -15,7 +14,10 @@ import { toolContract } from "../contracts/toolContracts.js";
import type { Logger } from "pino";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { createEvidenceToolHandler } from "./contractToolHandler.js";
-import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
+import {
+ toolRegistrationOptions,
+ applicationValidatedInputSchema,
+} from "./toolRegistrationOptions.js";
/** Bind historical inspection to its named contract and caller-owned Evidence writer. */
export const registerWebNetworkCaptureTool = (
@@ -31,13 +33,7 @@ export const registerWebNetworkCaptureTool = (
// Keep the exact advertised contract; the shared service validates raw input
// before effects so caller-declared sensitivity also covers invalid arguments.
// SDK pre-validation otherwise emits unprojected issue paths as plain text.
- const inputSchema: StandardSchemaWithJSON = {
- "~standard": {
- ...registration.inputSchema["~standard"],
- vendor: "rea-application",
- validate: (value: unknown) => ({ value }),
- },
- };
+ const inputSchema = applicationValidatedInputSchema(registration.inputSchema);
const handler = createEvidenceToolHandler(
server,
async (rawInput, options) => {
diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts
index 4615971d4..c943be3f2 100644
--- a/src/server/toolRegistrationOptions.ts
+++ b/src/server/toolRegistrationOptions.ts
@@ -1,9 +1,21 @@
import type { ToolContract } from "../contracts/toolContractTypes.js";
+import type { StandardSchemaWithJSON } from "@modelcontextprotocol/server";
import {
toolInputSchemaWithMetadata,
toolOutputSchemaWithMetadata,
} from "../contracts/toolSchemaMetadata.js";
+/** Preserve advertised contracts while the application owns structured input failures. */
+export const applicationValidatedInputSchema = (
+ schema: StandardSchemaWithJSON,
+): StandardSchemaWithJSON => ({
+ "~standard": {
+ ...schema["~standard"],
+ vendor: "rea-application",
+ validate: (value: unknown) => ({ value }),
+ },
+});
+
/** Project the canonical Zod contracts directly into SDK registration. */
export const toolRegistrationOptions = (
contract: Contract,
diff --git a/tests/acceptance/tools/qlogCapture.test.ts b/tests/acceptance/tools/qlogCapture.test.ts
new file mode 100644
index 000000000..be67c3a34
--- /dev/null
+++ b/tests/acceptance/tools/qlogCapture.test.ts
@@ -0,0 +1,457 @@
+import { execFile } from "node:child_process";
+import { writeFile, truncate, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { expect, it as runtimeTest } from "vitest";
+import {
+ NODE_CAPTURE_HEAP_ENFORCED,
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES,
+} from "../../../src/browser/history/CaptureRelease.js";
+import { webNetworkCaptureSummaryViewResultSchema } from "../../../src/domain/analysisView/analysisView.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import {
+ webNetworkCaptureSchema,
+ WEB_NETWORK_CAPTURE_LIMITS,
+} from "../../../src/domain/webNetworkCapture.js";
+import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
+import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+const execute = promisify(execFile);
+const hasMemoryBound =
+ NODE_CAPTURE_HEAP_ENFORCED ||
+ NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES !== null;
+const it = runtimeTest.skipIf(!hasMemoryBound);
+const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url));
+const event = {
+ name: "transport:packet_sent",
+ time: 1791819895000.25,
+ data: {
+ header: { packet_type: "1RTT", packet_number: 5 },
+ frames: [
+ { frame_type: "stream", stream_id: 0, offset: 10, length: 5, fin: true },
+ ],
+ },
+};
+const qlog = (events: unknown[]) =>
+ JSON.stringify({
+ qlog_format: "JSON",
+ qlog_version: "0.3",
+ traces: [
+ {
+ vantage_point: { name: "aioquic", type: "server" },
+ common_fields: { ODCID: "trace-local" },
+ events,
+ },
+ ],
+ });
+
+const assertAdditionalQlogFacets = async (
+ call: Awaited>["call"],
+ retained: { kind: string; evidence_id: string },
+): Promise => {
+ for (const [view, expected] of [
+ [
+ { kind: "qlog", facet: "traces", offset: 1, limit: 1 },
+ [{ trace_index: 1, event_count: 1, stream_count: 1 }],
+ ],
+ [
+ { kind: "qlog", facet: "loss-declarations", trace_index: 0 },
+ [{ record_ordinal: 1, packet_number: { state: "reported", value: "5" } }],
+ ],
+ [{ kind: "qlog", facet: "incomplete-records", trace_index: 0 }, []],
+ [
+ {
+ kind: "qlog",
+ facet: "ranges",
+ trace_index: 0,
+ stream_id: "0",
+ direction: "sent",
+ },
+ [{ start: "10", end: "15" }],
+ ],
+ [
+ {
+ kind: "qlog",
+ facet: "final-sizes",
+ trace_index: 0,
+ stream_id: "0",
+ direction: "sent",
+ },
+ ["15"],
+ ],
+ [{ kind: "qlog", facet: "traces", offset: 99 }, []],
+ ] as const) {
+ const response = await call("inspect_analysis_view", {
+ source: retained,
+ view,
+ });
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ expect(
+ parseEvidence(response.structuredContent).normalized_result,
+ ).toMatchObject({
+ page: { items: expected },
+ coverage: { exhausted: true },
+ });
+ }
+};
+
+it("pages original trace-scoped facts after source deletion without conflating selected events or identical stream IDs", async () => {
+ const root = await createTestTempDirectory("rea-qlog-pages-");
+ const path = join(root, "input.qlog");
+ const source = JSON.parse(
+ qlog([
+ event,
+ {
+ name: "recovery:packet_lost",
+ time: 1791819895001,
+ data: { packet_number: 5, type: "1RTT" },
+ },
+ ]),
+ );
+ source.traces.push({
+ ...source.traces[0],
+ events: [
+ {
+ ...event,
+ data: {
+ frames: [
+ {
+ frame_type: "stream",
+ stream_id: 0,
+ offset: 100,
+ length: 3,
+ fin: true,
+ },
+ ],
+ },
+ },
+ ],
+ });
+ await writeFile(path, JSON.stringify(source));
+ const { call } = await connectLocalToolsMcp();
+ const response = await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ detail: "summary",
+ record_ordinals: [2],
+ });
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ const summary = webNetworkCaptureSummaryViewResultSchema.parse(
+ parseEvidence(response.structuredContent).normalized_result,
+ );
+ expect(summary).toMatchObject({
+ summary: {
+ total_records: 3,
+ retained_records: 1,
+ qlog: {
+ trace_count: 2,
+ retained_stream_count: 2,
+ loss_declaration_count: 1,
+ },
+ },
+ });
+ expect(JSON.stringify(summary)).not.toContain('"contributions"');
+ if (
+ !("parent_evidence_id" in summary) ||
+ typeof summary.parent_evidence_id !== "string"
+ )
+ throw new Error("Missing retained parent");
+ const retained = {
+ kind: "retained-evidence",
+ evidence_id: summary.parent_evidence_id,
+ };
+ const parent = parseEvidence(
+ (
+ await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ record_ordinals: [2],
+ })
+ ).structuredContent,
+ );
+ expect(parent.evidence_id).toBe(summary.parent_evidence_id);
+ const portablePath = join(root, "parent.json");
+ await rm(path);
+ const pages = [];
+ for (const trace_index of [0, 1]) {
+ const page = await call("inspect_analysis_view", {
+ source: retained,
+ view: { kind: "qlog", facet: "streams", trace_index, limit: 1 },
+ });
+ expect(page.isError, JSON.stringify(page)).not.toBe(true);
+ pages.push(parseEvidence(page.structuredContent).normalized_result);
+ }
+ expect(pages[0]).toMatchObject({
+ page: { items: [{ stream_id: "0", unique_bytes: "5" }] },
+ context: { scope: "all-recorded-events-before-selection" },
+ });
+ expect(pages[1]).toMatchObject({
+ page: { items: [{ stream_id: "0", unique_bytes: "3" }] },
+ });
+ await assertAdditionalQlogFacets(call, retained);
+ const view = {
+ kind: "qlog",
+ facet: "contributions",
+ trace_index: 0,
+ stream_id: "0",
+ direction: "sent",
+ offset: 0,
+ limit: 1,
+ };
+ const contribution = parseEvidence(
+ (await call("inspect_analysis_view", { source: retained, view }))
+ .structuredContent,
+ );
+ expect(contribution.normalized_result).toMatchObject({
+ page: {
+ items: [{ record_ordinal: 0, frame_index: 0, offset: "10", length: "5" }],
+ },
+ coverage: { total: 1, exhausted: true },
+ });
+ await writeFile(
+ portablePath,
+ JSON.stringify({ source: { kind: "inline", evidence: parent }, view }),
+ );
+ const cliResult = await execute(process.execPath, [
+ cli,
+ "inspect-analysis-view",
+ portablePath,
+ "--json",
+ ]);
+ expect(parseEvidence(JSON.parse(cliResult.stdout)).normalized_result).toEqual(
+ contribution.normalized_result,
+ );
+ for (const invalid of [
+ { kind: "qlog", facet: "streams" },
+ { kind: "qlog", facet: "streams", trace_index: 99 },
+ { ...view, stream_id: "99" },
+ { kind: "qlog", facet: "traces", trace_index: 0 },
+ ])
+ expect(
+ parseMcpToolError(
+ await call("inspect_analysis_view", {
+ source: retained,
+ view: invalid,
+ }),
+ ),
+ ).toMatchObject({ error: { code: "invalid_request" } });
+}, 60_000);
+
+runtimeTest.runIf(!hasMemoryBound)(
+ "refuses qlog when this runtime cannot enforce a decoder memory bound",
+ async () => {
+ const { call } = await connectLocalToolsMcp();
+ const root = await createTestTempDirectory("rea-qlog-unbounded-");
+ const path = join(root, "input.qlog");
+ await writeFile(path, qlog([event]));
+ expect(
+ parseMcpToolError(
+ await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ }),
+ ),
+ ).toMatchObject({ error: { code: "capability_unavailable" } });
+ },
+);
+
+it("runs built CLI and real stdio SDK with exact evidence parity, selection, retained references and strict schemas", async () => {
+ const root = await createTestTempDirectory("rea-qlog-public-");
+ const path = join(root, "input.qlog");
+ await writeFile(
+ path,
+ qlog([
+ event,
+ {
+ name: "recovery:packet_lost",
+ time: 1791819895001.125,
+ data: { packet_number: 5, type: "1RTT" },
+ },
+ ]),
+ );
+ const { call, client } = await connectLocalToolsMcp();
+ const input = { capture_path: path, format: "qlog", record_ordinals: [1, 0] };
+ const response = await call("inspect_web_network_capture", input);
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ const mcp = parseEvidence(response.structuredContent);
+ const result = await execute(process.execPath, [
+ cli,
+ "inspect-web-network-capture",
+ path,
+ "qlog",
+ "--record",
+ "1",
+ "--record",
+ "0",
+ "--json",
+ ]);
+ const cliEvidence = parseEvidence(JSON.parse(result.stdout));
+ expect(cliEvidence.evidence_id).toBe(mcp.evidence_id);
+ expect(cliEvidence.normalized_result).toEqual(mcp.normalized_result);
+ expect(mcp.analysis_profile?.parameters).toMatchObject({
+ qlog_profile: "aioquic-1.5.0-json-0.3",
+ });
+ expect(mcp.authority).toBe("historical-reference");
+ const decoded = webNetworkCaptureSchema.parse(mcp.normalized_result);
+ expect(decoded.records.map((record) => record.ordinal)).toEqual([1, 0]);
+ if (decoded.format !== "qlog") throw new Error("qlog format required");
+ expect(decoded.qlog.traces[0]?.streams[0]).toMatchObject({
+ unique_bytes: "5",
+ covered_ranges: [{ start: "10", end: "15" }],
+ });
+ const retained = await call("inspect_analysis_view", {
+ source: { kind: "retained-evidence", evidence_id: mcp.evidence_id },
+ view: { kind: "api-surface" },
+ });
+ // An unsupported view over an existing retained parent proves lookup without pretending qlog is HAR.
+ expect(parseMcpToolError(retained)).toMatchObject({
+ error: { code: "unsupported_target" },
+ });
+ await client.ping();
+ for (const record_ordinals of [[2], [0, 0]]) {
+ expect(
+ parseMcpToolError(
+ await call("inspect_web_network_capture", {
+ ...input,
+ record_ordinals,
+ }),
+ ),
+ ).toMatchObject({ error: { code: "invalid_request" } });
+ }
+ const hidden = await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ sensitive_values: ["trace-local", "/traces/0", "input.qlog"],
+ });
+ expect(JSON.stringify(hidden)).not.toContain("trace-local");
+ expect(JSON.stringify(hidden)).not.toContain("/traces/0");
+ expect(JSON.stringify(hidden)).not.toContain("input.qlog");
+ const hiddenEvidence = parseEvidence(hidden.structuredContent);
+ expect(
+ webNetworkCaptureSchema.parse(hiddenEvidence.normalized_result).artifact
+ .path,
+ ).toBe("");
+ const invalid = await call("inspect_web_network_capture", {
+ ...input,
+ mysecret: true,
+ sensitive_values: ["mysecret"],
+ });
+ expect(parseMcpToolError(invalid)).toMatchObject({
+ error: { code: "invalid_request" },
+ });
+ expect(JSON.stringify(invalid)).not.toContain("mysecret");
+}, 60_000);
+
+it("fails whole-capture validation before selection and survives malformed, unsupported and oversized qlog", async () => {
+ const root = await createTestTempDirectory("rea-qlog-errors-");
+ const path = join(root, "input.qlog");
+ const { call, client } = await connectLocalToolsMcp();
+ for (const [text, code] of [
+ [qlog([event, { ...event, time: "invalid" }]), "invalid_request"],
+ [qlog([event]).slice(0, -1), "invalid_request"],
+ [
+ qlog([event]).replace('"qlog_version":"0.3"', '"qlog_version":"0.4"'),
+ "capability_unavailable",
+ ],
+ [
+ qlog([
+ {
+ ...event,
+ extension: JSON.parse("[".repeat(65) + "0" + "]".repeat(65)),
+ },
+ ]),
+ "invalid_request",
+ ],
+ ] as const) {
+ await writeFile(path, text);
+ expect(
+ parseMcpToolError(
+ await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ record_ordinals: [0],
+ }),
+ ),
+ ).toMatchObject({ error: { code } });
+ const failed: unknown = await execute(process.execPath, [
+ cli,
+ "inspect-web-network-capture",
+ path,
+ "qlog",
+ "--json",
+ ]).catch((cause: unknown) => cause);
+ expect(failed).toMatchObject({
+ code: 1,
+ stdout: expect.stringContaining(`"code":"${code}"`),
+ });
+ }
+ await truncate(path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes + 1);
+ expect(
+ parseMcpToolError(
+ await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ }),
+ ),
+ ).toMatchObject({ error: { code: "invalid_request" } });
+ await writeFile(path, qlog([event]));
+ await client.ping();
+ expect(
+ (
+ await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ })
+ ).isError,
+ ).not.toBe(true);
+}, 60_000);
+
+runtimeTest.skipIf(process.versions.bun !== undefined || !hasMemoryBound)(
+ "contains actual qlog decoder heap exhaustion and keeps MCP usable",
+ async () => {
+ const root = await createTestTempDirectory("rea-qlog-capacity-");
+ const path = join(root, "dense.qlog");
+ const dense = qlog(Array(100_000).fill(event));
+ expect(Buffer.byteLength(dense)).toBeLessThan(
+ WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
+ );
+ await writeFile(path, dense);
+ const { call, client } = await connectLocalToolsMcp();
+ const response = await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ record_ordinals: [0],
+ });
+ expect(parseMcpToolError(response)).toMatchObject({
+ error: {
+ code: "resource_constraint",
+ details: {
+ resource: "memory",
+ reported_limits: { old_generation_heap_mib: 192 },
+ },
+ },
+ });
+ const failed: unknown = await execute(
+ process.execPath,
+ [cli, "inspect-web-network-capture", path, "qlog", "--json"],
+ { timeout: 40_000, maxBuffer: 1024 * 1024 },
+ ).catch((cause: unknown) => cause);
+ expect(failed).toMatchObject({
+ code: 1,
+ stdout: expect.stringContaining('"code":"resource_constraint"'),
+ });
+ await writeFile(path, qlog([event]));
+ await client.ping();
+ expect(
+ (
+ await call("inspect_web_network_capture", {
+ capture_path: path,
+ format: "qlog",
+ })
+ ).isError,
+ ).not.toBe(true);
+ },
+ 60_000,
+);
From 23de4f77c74dd299d45cab880c36321d802b21fe Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 11:39:30 +0800
Subject: [PATCH 004/159] perf(flutter): add opt-in retained AOT summary and
string pages
Add explicit MCP Dart AOT summary delivery and exact retained string-pool pages while preserving complete defaults, CLI behavior, ABI selection, original Evidence identity, partial coverage and lower bounds.
Real LocalSend SDK replies shrink 92.05% with approximately unchanged SDK time. Two actual Codex pairs preserve all selected facts and reduce total input 48.75% / 42.38%, uncached input 33.63% / 42.52%, and task time 23.95% / 30.30%; all client errors/repeated calls remain counted. Preserve old portable Evidence and the real CLI complete result after rebasing onto SAZ/qlog main. Curated results keep measured and integration runtime pins separate; raw artifacts stay outside Git.
Refs #2016.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---
docs/flutter-analysis.md | 20 ++
experiments/analysis-efficiency/README.md | 8 +
experiments/analysis-efficiency/corpus.json | 15 +
.../flutter-aot-summary.md | 160 ++++++++++
.../analysis-efficiency/latest-main-audit.md | 75 +++++
experiments/analysis-efficiency/measure.mjs | 48 +++
.../questions/flutter-aot.txt | 1 +
.../analysis-efficiency/review-model.py | 5 +-
src/application/flutter/DartAotInspection.ts | 40 +++
src/contracts/analysisViewToolContracts.ts | 2 +-
src/contracts/flutter/flutterToolContracts.ts | 18 +-
src/domain/analysisView/analysisView.ts | 31 +-
src/domain/analysisView/dartAotView.ts | 123 ++++++++
src/domain/flutter/flutterBuildAnalysis.ts | 6 +
src/server/createServer.ts | 1 +
src/server/registerFlutterTools.ts | 37 ++-
tests/acceptance/flutterAnalysis.test.ts | 11 +-
tests/boundary/mcp/dartAotSummaryMcp.test.ts | 285 ++++++++++++++++++
18 files changed, 867 insertions(+), 19 deletions(-)
create mode 100644 experiments/analysis-efficiency/flutter-aot-summary.md
create mode 100644 experiments/analysis-efficiency/latest-main-audit.md
create mode 100644 experiments/analysis-efficiency/questions/flutter-aot.txt
create mode 100644 src/application/flutter/DartAotInspection.ts
create mode 100644 src/domain/analysisView/dartAotView.ts
create mode 100644 tests/boundary/mcp/dartAotSummaryMcp.test.ts
diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md
index 75c23dd66..ebf0ccd84 100644
--- a/docs/flutter-analysis.md
+++ b/docs/flutter-analysis.md
@@ -25,6 +25,26 @@ The equivalent MCP methods are `identify_flutter_build` (with `path`) and
arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining
ABIs alphabetically).
+## Optional retained AOT delivery
+
+MCP `inspect_dart_aot` keeps complete output when `detail` is omitted or
+`"complete"`. Explicit `detail: "summary"` returns the selected target/ABI/library
+identity, all snapshot headers, structural coverage, string-pool coverage and
+original lower-bound counts without the string lists. It retains complete source
+Evidence and returns a distinct linked `inspect_analysis_view` summary. Analysis
+and the caller's ABI choice are unchanged; one-shot CLI output stays complete.
+
+Use `normalized_result.parent_evidence_id` as the retained source for
+`inspect_analysis_view`, with `view: {kind: "dart-aot", collection: "package_uris",
+offset: 0, limit: 64}`. Collections are `package_uris`, `dart_uris` and
+`class_like_tokens`. Pages preserve the original strings and order, selected ABI,
+library identity and source coverage. `returned_counts` describe producer-returned
+rows; counts ending in `_at_least` remain lower bounds. Exhausting a page sequence
+does not make a partial pool complete or turn byte-pattern tokens into typed
+classes/functions. Views do not reopen the APK. Original Evidence remains
+exportable under its existing session lifetime. Summary requires a retaining
+server; older servers keep the complete request after schema discovery.
+
## What the AOT inspection returns
`inspect_dart_aot` reads one ABI's `libapp.so` in full and reports:
diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md
index 8afef3a66..b028953ce 100644
--- a/experiments/analysis-efficiency/README.md
+++ b/experiments/analysis-efficiency/README.md
@@ -7,6 +7,14 @@ JSON measurements belong outside the checkout. This document keeps the measured
findings, verification scope and revision references; the scripts and `corpus.json`
keep the reproduction inputs. Generated `results/` is ignored.
+Follow-up: [latest-main capability audit](latest-main-audit.md) records the
+Flutter, SQLite, TShark and sigrok priorities and the first shipped Dart AOT
+baseline, preserving existing controls before introducing another selector.
+
+Follow-up: [Dart AOT summary and retained string pools](flutter-aot-summary.md)
+records real LocalSend delivery pairs and old Evidence continuation, preserving
+ABI selection, snapshot identity, original lower bounds and partial coverage.
+
Follow-up: [native PE/DOS entry views and real model verification](native-entry-views.md)
records the post-merge native continuation, including a small-function case where
four selected replies cost more bytes than the complete dossier.
diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json
index 540e88cdd..3b0f5c574 100644
--- a/experiments/analysis-efficiency/corpus.json
+++ b/experiments/analysis-efficiency/corpus.json
@@ -235,6 +235,21 @@
},
"immutable_scope": "Published Hardhat artifact does not record an immutable-reference map; absence is unknown, not proof of no immutables. Runtime template bytes remain exact."
}
+ },
+ {
+ "id": "localsend-arm64.apk",
+ "file": "LocalSend-1.18.2-android-arm64v8.apk",
+ "bytes": 46558706,
+ "sha256": "82ec3568fba2aa5295b9aae8b76f701d7a4703d86b9f8bad749472038fbaeab3",
+ "provenance": {
+ "project": "LocalSend",
+ "version": "1.18.2",
+ "upstreamFile": "LocalSend-1.18.2-android-arm64v8.apk",
+ "url": "https://github.com/localsend/localsend/releases/download/v1.18.2/LocalSend-1.18.2-android-arm64v8.apk",
+ "transport_sha256": "82ec3568fba2aa5295b9aae8b76f701d7a4703d86b9f8bad749472038fbaeab3",
+ "transport_bytes": 46558706,
+ "derivation": "Official arm64 release APK, compared with GitHub release asset digest; byte-only analysis without execution"
+ }
}
]
}
diff --git a/experiments/analysis-efficiency/flutter-aot-summary.md b/experiments/analysis-efficiency/flutter-aot-summary.md
new file mode 100644
index 000000000..8893cd917
--- /dev/null
+++ b/experiments/analysis-efficiency/flutter-aot-summary.md
@@ -0,0 +1,160 @@
+# Dart AOT summary and retained string pools
+
+Continues [#2016](https://github.com/morluto/rea/issues/2016) and
+[#2455](https://github.com/morluto/rea/pull/2455). The selected analyst question
+asks for shipped LocalSend target/ABI/library identity, every nullable snapshot
+observation, string-pool lower bounds and returned counts, and original coverage
+and limitations. It does not require individual observed strings.
+
+## Compatibility and ownership
+
+MCP `inspect_dart_aot` accepts explicit `detail: "summary"`. Omitted and complete
+requests preserve the complete result and original provider inputs. The CLI
+remains complete. ABI omission and explicit ABI selection retain their original
+meaning; the provider never receives the delivery field. Missing retention,
+invalid detail and pre-cancellation fail before provider work.
+
+The existing application workflow retains the complete original Evidence, then
+returns a distinct derived summary linked to its parent ID. It preserves target
+and libapp digests, every snapshot section and nullable fact, original pool and
+overall coverage, lower-bound counts and limitations. `returned_counts` reports
+the lengths of the producer-returned arrays; it does not upgrade lower bounds
+to a complete snapshot inventory. The analysis and original source retention
+are unchanged. No new engine, persistent cache or plugin SDK is introduced.
+
+Read exact retained strings using `inspect_analysis_view`, the parent ID, and
+`view: {kind: "dart-aot", collection: "package_uris", offset: 0, limit: 64}`.
+Other collections are `dart_uris` and `class_like_tokens`. Follow the returned
+next offset. Pages preserve source order, ABI/library identity and partial pool
+coverage without opening the APK again. Page exhaustion describes retained
+strings, not the entire snapshot. Strings remain byte-pattern observations,
+rather than recovered typed class/function semantics. Missing historical
+subjects remain explicitly unknown; conflicting recorded target digests fail.
+
+## Frozen runtime and real SDK pairs
+
+The official LocalSend 1.18.2 arm64 APK acquisition, size and SHA-256 are pinned
+in `corpus.json`. No APK installation, target execution or version-dataset
+lookup occurred. Source runtime: `811034a7283c9751a3ecbff33e8b2f28bec55793`;
+compiled dist/bridge file-table SHA-256:
+`dabbb63536c5862be839ecdc0f3eaad05b29c2a3816f57041528d30f754311be`.
+The previous runtime is `28c84e78cf2fbfde4aceed345b773c59eb889f68`, digest
+`5332dda13981f043e146b3601ebec1f380d9f698280ca53d75786b638a912e11`.
+Its production sources were verified byte-equal to main f254 at acquisition.
+Subsequent test-only changes and main's SAZ addition do not relabel these pins.
+
+Node is 24.18.0 and the SDK client/server are 2.3.1. Each workflow starts a fresh
+server and validates the selected live advertised schema. Artifact bytes,
+provider environment/search-path fingerprints, driver and locked dependencies
+stay equal. Host page caches are not flushed. Heavy tasks run serially under
+the unchanged CPU, memory, task and host guards.
+
+| Trial | AOT reply bytes | SDK workflow seconds | Payload CPU seconds | Kernel peak bytes |
+| -------------- | --------------: | -------------------: | ------------------: | ----------------: |
+| sdk-complete-1 | 151,341 | 5.139 | 6.819 | 691,441,664 |
+| sdk-summary-1 | 12,037 | 5.219 | 6.608 | 639,430,656 |
+| sdk-summary-2 | 12,037 | 5.064 | 6.405 | 640,868,352 |
+| sdk-complete-2 | 151,341 | 5.128 | 6.468 | 633,614,336 |
+
+Both pairs use one successful public tool call and pass settings/fact comparison,
+including all snapshot fields, coverage, original limitations and all counts.
+Reply bytes fall 92.05%. Whole SDK time stays approximately 5.1 seconds; these
+trials do not establish engine speed, CPU or memory savings. Payload CPU includes
+driver/catalog work beyond the SDK workflow timer. Catalog JSON grows from
+2,506,213 bytes on the previous runtime to 2,514,896 (about 0.35%).
+
+A fresh old-runtime complete request passes comparison against candidate
+complete, including identical 151,341-byte replies. This is scoped compatibility
+evidence, not a guarantee for every historical release or third-party client.
+
+## Real old Evidence continuation
+
+Fifteen successful public MCP calls import genuine old-runtime AOT Evidence,
+project retained summary, reconstruct all 500 package URIs, 216 Dart URIs and
+2,000 class-like tokens, compare inline portable summary, and export the original
+record. The APK is absent during all these operations. Original string order,
+lower bounds, partial coverage, nullable snapshot facts, subject and parent ID
+remain intact; the original exported record is deeply equal to its source.
+
+The first private verification attempt used a nonexistent receipt filename and
+failed before import or artifact relocation. That driver error is retained;
+the corrected attempt selects the fresh old-runtime record. It is not a REA
+defect or a successful migration trial.
+
+## Actual Codex task outcomes
+
+Four serial trials use the same frozen candidate in complete/summary/summary/
+complete order. Requested configuration is Codex CLI 0.161.0, `gpt-5.6-terra`
+high, one exact enabled tool (`inspect_dart_aot`), and identical optional owned
+context capture/discovery guidance. This requested alias does not certify the
+server-resolved model. Temporary configuration points to the modified frozen
+REA MCP; all copied credential profiles are removed and target bytes remain
+unchanged. The source question is fixed; equal treatment instructions choose
+detail and explain the summary carrier and parent reference.
+
+| Trial | Input | Cached | Uncached | Output | Seconds | MCP attempts | Failed attempts | Facts |
+| ---------------- | -----: | -----: | -------: | -----: | ------: | -----------: | --------------: | ----- |
+| model-complete-1 | 74,641 | 50,176 | 24,465 | 2,765 | 116.898 | 3 | 1 | pass |
+| model-summary-1 | 38,253 | 22,016 | 16,237 | 1,449 | 88.904 | 1 | 0 | pass |
+| model-summary-2 | 38,363 | 22,016 | 16,347 | 1,515 | 55.190 | 1 | 0 | pass |
+| model-complete-2 | 66,585 | 38,144 | 28,441 | 2,217 | 79.180 | 2 | 0 | pass |
+
+Both pairs pass checks for every selected producer fact, exact nullable
+observations, target digest, observed response Evidence IDs, original limitations,
+artifact preservation and profile cleanup. The grader compares the reported
+facts with producer observations; it is not an independent Dart deserializer.
+Total input falls 48.75% / 42.38%, uncached input 33.63% / 42.52%, reported output
+47.59% / 31.66%, and whole-task time 23.95% / 30.30%. Usage is the final aggregate
+reported by the actual CLI, one usage record per trial; cached tokens are not
+subtracted from total input. Reported output includes reasoning output (1,262,
+321, 383 and 709 tokens respectively).
+
+The first complete trial initially supplied `artifact_path` instead of the
+advertised `path`, corrected it, then repeated a successful complete call.
+The second complete trial made two successful calls. Both summary trials made
+one successful call. All attempts, discovery and correction costs remain
+included. The paired whole-task improvement therefore includes actual model
+decisions and the convenience of explicit returned counts; it does not isolate
+serialization as the sole cause or establish engine acceleration. Two pairs on
+one app/question/model configuration support this scope, not a universal gain.
+
+Payload CPU seconds / kernel peak bytes are respectively 12.556 / 876,937,216;
+9.213 / 785,272,832; 9.108 / 793,939,968; and 13.164 / 803,209,216. Model payload
+observations verify owned cleanup and zero phase limit events, with no guard
+aborts. Whole-scope final counters remain unknown, so every strict all-selected
+resource grade remains false even though the separate factual grade passes.
+
+## Resource and verification scope
+
+SDK and migration payload-exit observations verify owned cleanup and zero
+observed memory/pids limit events, with peaks below 692 MB. Missing whole-scope
+terminal counters remain unknown; the strict whole-resource grader stays false.
+One-CPU quotas are experiment controls rather than CPU-efficiency claims.
+
+Production-provider fixture tests cover explicit ABI, old complete defaults,
+actual SDK-converted schemas, exact pages after APK removal, portable records,
+nullable/partial facts, malformed inputs, target digest conflicts and early
+refusals. CLI/MCP acceptance covers original report equality, duplicate-member
+refusals and unsupported snapshot kinds. Fixture successes are distinguished
+from the real LocalSend SDK/migration workflows. Other Flutter apps, architectures,
+SDK layouts, typed class recovery and runtime behavior remain unverified here.
+Raw replies, binaries, model traces and temporary credentials stay outside Git.
+
+## Integration with the newer main
+
+The rebased verification runtime is `63984ce0898bd7997e6d9b138e6d96d0a24f2b09`
+on main `a2d8b94dd50dab8d966239dd63ab84718aa8b4b4`, with compiled file-table
+SHA-256 `19c8ccdd3e99bb46a5c54f4edd8c6b2d2eb4f3a4870b3c129061d38145382b68`.
+Targeted Flutter/SAZ/shared-view integration passes 35 tests; one opposite
+runtime-capacity branch is skipped because this host enforces decoder memory.
+Documentation checks and two README conformance tests pass.
+
+Fresh real complete/summary SDK checks on this runtime match the earlier selected
+facts and settings, with unchanged 151,341 / 12,037-byte AOT replies. Fifteen
+old-Evidence migration calls again preserve every original string and record
+with the source APK absent. The real one-shot CLI complete result also matches
+the previous-runtime normalized report, subject, parameters and original ID.
+An initial CLI-only controller check lacked an owned resource receipt; it is
+retained and excluded from resource/performance claims, then repeated in the
+bounded owned scope. These checks establish integration, rather than relabeling
+the earlier paired model/SDK measurements as the rebased runtime.
diff --git a/experiments/analysis-efficiency/latest-main-audit.md b/experiments/analysis-efficiency/latest-main-audit.md
new file mode 100644
index 000000000..8f767fb64
--- /dev/null
+++ b/experiments/analysis-efficiency/latest-main-audit.md
@@ -0,0 +1,75 @@
+# Latest-main efficiency continuation
+
+Continues [#2016](https://github.com/morluto/rea/issues/2016). The 2026-10-13
+fetch resolved main to `f254f5ac2b804b353a711264bb178f43fdf07867`, the same
+revision as the preceding Android conclusion. Newer capabilities below were
+added during that earlier cycle; they are additional measurement scopes, not
+new commits after that conclusion.
+
+| Capability | Existing controls | Next evidence needed |
+| ----------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| Flutter / Dart AOT (#1582) | Explicit ABI, bounded string observations, partial coverage | Real shipped snapshot/count question; opt-in delivery only if unrelated strings dominate |
+| SQLite (#2271) | Compact inventory, selected table/columns, row limit | Real application database and model workflow before adding another selector |
+| TShark (#2318) | Fields, filter, decode-as, pass, default 50 matching rows, paging, explicit null for all | Real public capture and repeated task cost; paging repeats dissection |
+| sigrok (#2339) | Decoder/channels/annotation selection, default 50 display lines, paging | Real public capture; display-line count is not annotation count and paging repeats decoding |
+| Retained unknown head results | Existing retained paging | Reuse the existing view rather than duplicate its implementation |
+
+Attached IDA, Frida and other live runtime workflows involve separate authority
+and lifecycle questions and are outside this static parsing continuation.
+Plugin SDKs and cross-session engine caches remain deferred until an observed
+need and benefit justify them.
+
+## First selected scope: Dart AOT
+
+The official LocalSend 1.18.2 arm64 APK is pinned in `corpus.json`. The initial
+real public SDK baseline inspected actual bytes without executing/installing
+the APK. Its complete default AOT reply was 151,341 compact client-result JSON
+bytes (70,487 normalized bytes), with 500 package URIs, 216 Dart URIs and 2,000
+class-like tokens. String-pool and overall coverage were partial. A second
+explicit arm64 request returned the same normalized facts.
+
+These are exploratory first/warm calls, not an optimization pair. The frozen
+runtime source is `28c84e78cf2fbfde4aceed345b773c59eb889f68`, with production
+source bytes verified equal to main f254 at baseline acquisition. Its compiled
+file-table digest is
+`5332dda13981f043e146b3601ebec1f380d9f698280ca53d75786b638a912e11`;
+the receipt is not relabeled as a newer build.
+
+The candidate adds explicit MCP `detail: "summary"` and retained pool pages.
+Omitted/complete requests and the CLI preserve the old complete path. Analysis,
+ABI selection and original Evidence stay intact. Summary retains target/libapp
+identity, exact nullable snapshot observations, lower bounds, returned counts,
+coverage and limitations. Pages preserve original string order and source
+coverage without rescanning. No engine cache, provider protocol or third-party
+plugin API is introduced.
+
+Follow-up measurements must compare identical selected facts, portable old
+Evidence and actual model tasks. Reply bytes do not establish token or latency
+improvements. Whole-scope final counters remain unknown if their cgroup has
+disappeared, even when the narrower payload-exit observation is verified.
+Raw records, binaries and model traces stay in the private lab outside Git.
+Heavy work remains serial under the unchanged one-CPU, 3 GiB early-stop / 4 GiB
+hard memory, no-swap and host-availability guards.
+
+## Main advanced during the experiment
+
+Main subsequently advanced to `a2d8b94dd50dab8d966239dd63ab84718aa8b4b4`,
+adding native Fiddler SAZ parsing. Its web-network capture contract already
+offers opt-in summary and retained capture pages, archive-member coordinates
+and exact exposed encoded bytes. It decodes all records before ordinal
+selection and rejects malformed/oversized inputs without partial success.
+Measure real public SAZ artifacts using these controls before adding another
+API or changing decoding or credential-redaction semantics. SAZ performance
+remains unverified in this Flutter continuation.
+
+The Flutter cohorts keep their original frozen runtime; subsequent integration
+onto this newer main does not relabel earlier measurement receipts.
+
+Main advanced again to `bb154d106` during CI, adding retained aioquic 1.5.0
+qlog 0.3 JSON parsing and qlog views. The existing capture summary/page path
+already applies. Qlog's trace-scoped STREAM interval unions and endpoint loss
+declarations have separate semantics: they do not recover bodies, prove network
+loss or establish PCAP correlation. Measure genuine supported recordings and
+existing retained views before adding delivery or caching. Other qlog layouts
+and qlog performance remain unverified in the Flutter experiment. Shared-view
+integration is checked after rebasing; earlier runtime receipts remain pinned.
diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs
index bfac3adb6..8774ed30b 100644
--- a/experiments/analysis-efficiency/measure.mjs
+++ b/experiments/analysis-efficiency/measure.mjs
@@ -726,6 +726,54 @@ const workflows = {
},
"sharex.dll": inspectManagedTarget,
"sharex.helpers.dll": inspectManagedTarget,
+ "localsend-arm64.apk": async () => {
+ const input = {
+ path: values.path,
+ ...(values.delivery === "summary" ? { detail: "summary" } : {}),
+ };
+ const definition = catalogDefinitions.get("inspect_dart_aot");
+ if (
+ definition === undefined ||
+ !new Ajv2020({ strict: false, validateFormats: false }).validate(
+ definition.inputSchema,
+ input,
+ )
+ )
+ throw new Error(
+ "Selected Dart AOT delivery is not advertised by this runtime",
+ );
+ const inspected = await call("inspect_dart_aot", input);
+ const result = normalized(inspected);
+ if (
+ values.delivery === "summary" &&
+ (result.kind !== "summary" ||
+ result.parent_operation !== "inspect_dart_aot")
+ )
+ throw new Error("Runtime returned no retained Dart AOT summary");
+ const report = values.delivery === "summary" ? result.summary : result;
+ const { package_uris, dart_uris, class_like_tokens, ...pool } =
+ report.string_pool;
+ return {
+ artifact_sha256: targetSha(inspected),
+ target: report.target,
+ abi: report.abi,
+ libapp: report.libapp,
+ string_pool:
+ values.delivery === "summary"
+ ? pool
+ : {
+ ...pool,
+ returned_counts: {
+ package_uris: package_uris.length,
+ dart_uris: dart_uris.length,
+ class_like_tokens: class_like_tokens.length,
+ },
+ },
+ coverage: report.coverage,
+ limitations: inspected.limitations,
+ evidence_ids: [inspected.evidence_id],
+ };
+ },
"openwrt.bin": async () => {
const info = await call("inspect_firmware_regions", { path: values.path });
return {
diff --git a/experiments/analysis-efficiency/questions/flutter-aot.txt b/experiments/analysis-efficiency/questions/flutter-aot.txt
new file mode 100644
index 000000000..c4c3c3897
--- /dev/null
+++ b/experiments/analysis-efficiency/questions/flutter-aot.txt
@@ -0,0 +1 @@
+Statically inspect the shipped Flutter APK at {{artifact_path}} using only the registered REA inspect_dart_aot tool. Use its default ABI selection. Report target identity, chosen ABI, every libapp ELF/snapshot field exactly as reported including null, original string-pool coverage, all three counts ending in _at_least, printable_run_count, and returned_counts for package_uris, dart_uris and class_like_tokens. Compute returned_counts from the returned arrays if needed. Preserve original overall coverage and producer limitations exactly, including order. Counts ending in _at_least are lower bounds and class-like tokens are byte-pattern observations, not recovered typed classes. Do not return individual strings or infer Dart SDK versions, class semantics, runtime behavior or complete inventory. Final answer must be JSON with artifact_sha256, target, abi, libapp, string_pool, coverage, limitations, evidence_ids. Use actual response Evidence IDs. Do not execute the APK, use shell or host file tools, access the network, or call other agents. Stop when these facts are supported.
diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py
index a61bb0984..b8fe16bb3 100644
--- a/experiments/analysis-efficiency/review-model.py
+++ b/experiments/analysis-efficiency/review-model.py
@@ -69,7 +69,7 @@ def scope_with_verified_counts(value, expected_scope, returned_counts):
def main():
parser = argparse.ArgumentParser()
- parser.add_argument('--domain', choices=['wasm', 'evm', 'android', 'managed', 'managed-nonempty', 'firmware', 'inventory', 'javascript', 'native', 'browser'], required=True)
+ parser.add_argument('--domain', choices=['wasm', 'evm', 'android', 'flutter-aot', 'managed', 'managed-nonempty', 'firmware', 'inventory', 'javascript', 'native', 'browser'], required=True)
parser.add_argument('--trial', type=Path, required=True)
parser.add_argument('--reference', type=Path)
parser.add_argument('--resource-log', type=Path, required=True)
@@ -133,6 +133,9 @@ def main():
for key, expected in {'artifact_bytes': n['artifact']['bytes'], 'bytecode_sha256': n['bytecode']['sha256'], 'bytecode_bytes': n['bytecode']['bytes'], 'functions': n['functions'], 'evidence_kind': n['evidence_kind'], 'discovery_completeness': n['discovery_completeness'], 'runtime_execution': n['runtime_execution'], 'bytecode_kind': n['bytecode']['kind'], 'hardfork': n['bytecode']['hardfork'], 'deployment_authenticity': n['bytecode']['deployment_authenticity']}.items():
check(key, answer.get(key), expected)
check('evm_original_limitations', answer.get('limitations'), n['limitations'])
+ elif domain == 'flutter-aot':
+ for key in ['target', 'abi', 'libapp', 'string_pool', 'coverage', 'limitations']:
+ check(key, answer.get(key), reference[key])
elif domain == 'android':
for key in ['package_name', 'version_name', 'version_code', 'matches']:
check(key, answer.get(key), reference[key])
diff --git a/src/application/flutter/DartAotInspection.ts b/src/application/flutter/DartAotInspection.ts
new file mode 100644
index 000000000..c11bbe1f4
--- /dev/null
+++ b/src/application/flutter/DartAotInspection.ts
@@ -0,0 +1,40 @@
+import type { ExecutionOptions } from "../AnalysisProvider.js";
+import type { FlutterBuildAnalysisService } from "./FlutterBuildAnalysisService.js";
+import {
+ requireAnalysisViewRetention,
+ summarizeRetainedAnalysis,
+ type AnalysisViewRetention,
+} from "../analysisView/AnalysisViewService.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
+import { dartAotInspectionInputSchema } from "../../domain/flutter/flutterBuildAnalysis.js";
+import type { Evidence } from "../../domain/evidence.js";
+import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
+import { err, type Result } from "../../domain/result.js";
+
+/** Keep the caller's ABI choice and complete analysis; select only its delivery. */
+export const inspectDartAot = async (
+ service: FlutterBuildAnalysisService,
+ rawInput: unknown,
+ retention: AnalysisViewRetention,
+ options?: ExecutionOptions,
+): Promise> => {
+ const operation = "inspect_dart_aot";
+ if (options?.signal?.aborted === true)
+ return err(new AnalysisCancelledError(operation));
+ const parsed = dartAotInspectionInputSchema.safeParse(rawInput);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, {
+ cause: parsed.error,
+ }),
+ );
+ if (parsed.data.detail === "summary") {
+ const ready = requireAnalysisViewRetention(operation, retention);
+ if (!ready.ok) return ready;
+ }
+ const { detail, ...providerInput } = parsed.data;
+ const executed = await service.execute(operation, providerInput, options);
+ if (!executed.ok || detail !== "summary") return executed;
+ return summarizeRetainedAnalysis(executed.value, retention);
+};
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index 06506c83e..6011d544b 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -16,7 +16,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_evm_interface, inspect_managed_artifact, inspect_android_package 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, EVM, managed artifact and Android package 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
+ 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
diff --git a/src/contracts/flutter/flutterToolContracts.ts b/src/contracts/flutter/flutterToolContracts.ts
index 9a663d492..9ade1057b 100644
--- a/src/contracts/flutter/flutterToolContracts.ts
+++ b/src/contracts/flutter/flutterToolContracts.ts
@@ -1,7 +1,10 @@
+import { z } from "zod";
import {
flutterInputSchemas,
+ dartAotInspectionInputSchema,
flutterResultSchemas,
} from "../../domain/flutter/flutterBuildAnalysis.js";
+import { dartAotSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
import type { ToolContract } from "../toolContractTypes.js";
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "../toolEffects.js";
@@ -27,10 +30,15 @@ export const FLUTTER_TOOL_CONTRACTS = [
name: "inspect_dart_aot",
...toolContractMetadata("inspect_dart_aot"),
description:
- "Inspect one ABI's Dart AOT snapshot inside an APK: the four snapshot symbols located through the ELF dynamic symbol table with their offsets, sizes, and section headers (magic, kind, length), the snapshot hash, and a bounded string-pool projection from the isolate data section (package: source URIs, dart: SDK URIs, identifier-like tokens) exposing the app's dependency structure. String-pool entries are byte-pattern observations, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser. No engine, no execution.",
+ "Inspect one ABI's Dart AOT snapshot inside an APK: the four snapshot symbols located through the ELF dynamic symbol table with their offsets, sizes, and section headers (magic, kind, length), the snapshot hash, and a bounded string-pool projection from the isolate data section (package: source URIs, dart: SDK URIs, identifier-like tokens) exposing the app's dependency structure. String-pool entries are byte-pattern observations, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser. Omitted or complete detail returns the full observation. Select detail: \"summary\" for target/ABI/library identity, all snapshot headers, coverage and original lower-bound counts without string lists. The complete source stays retained; read returned strings via inspect_analysis_view with parent_evidence_id and kind dart-aot, collection package_uris/dart_uris/class_like_tokens, offset and limit. Exhausting pages does not imply complete pool coverage. No engine, no execution.",
kind: "flutter-provider",
- inputSchema: flutterInputSchemas.inspect_dart_aot,
- outputSchema: evidenceResultOf(flutterResultSchemas.inspect_dart_aot),
+ inputSchema: dartAotInspectionInputSchema,
+ outputSchema: evidenceResultOf(
+ z.union([
+ flutterResultSchemas.inspect_dart_aot,
+ dartAotSummaryViewResultSchema,
+ ]),
+ ),
examples: [
{
title: "Inspect the preferred ABI's AOT snapshot",
@@ -40,6 +48,10 @@ export const FLUTTER_TOOL_CONTRACTS = [
title: "Inspect one ABI explicitly",
input: { path: "/targets/Example.apk", abi: "arm64-v8a" },
},
+ {
+ title: "Inspect snapshot identity with retained string lists",
+ input: { path: "/targets/Example.apk", detail: "summary" },
+ },
],
},
] as const satisfies readonly ToolContract[];
diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts
index 05f879024..48eee73f0 100644
--- a/src/domain/analysisView/analysisView.ts
+++ b/src/domain/analysisView/analysisView.ts
@@ -73,6 +73,12 @@ import {
androidViewRequestSchema,
androidManifestViewItemSchema,
} from "./androidPackageView.js";
+import {
+ projectDartAotView,
+ dartAotSummarySchema,
+ dartAotViewRequestSchema,
+ dartAotViewContextSchema,
+} from "./dartAotView.js";
/** Public name of the selected-view workflow. */
export const INSPECT_ANALYSIS_VIEW_OPERATION = "inspect_analysis_view" as const;
@@ -190,6 +196,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
apiSurfaceViewSchema,
captureViewRequestSchema,
qlogViewRequestSchema,
+ dartAotViewRequestSchema,
]);
// The application owns admission and authenticated Evidence parsing. SDK and
@@ -305,6 +312,7 @@ const viewResultBase = {
"inspect_managed_artifact",
"inspect_web_network_capture",
"inspect_android_package",
+ "inspect_dart_aot",
]),
parent_digest: digestSchema,
view_digest: digestSchema,
@@ -376,6 +384,13 @@ export const androidPackageSummaryViewResultSchema = z.strictObject({
summary: androidPackageSummarySchema,
});
+/** Exact snapshot and pool-coverage carrier for selected AOT delivery. */
+export const dartAotSummaryViewResultSchema = z.strictObject({
+ ...summaryViewResultBase,
+ parent_operation: z.literal("inspect_dart_aot"),
+ summary: dartAotSummarySchema,
+});
+
/** Projected facts for one selected view of retained analysis Evidence. */
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
z.strictObject({
@@ -400,6 +415,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
managedSummarySchema,
captureSummarySchema,
androidPackageSummarySchema,
+ dartAotSummarySchema,
]),
}),
z.strictObject({
@@ -470,6 +486,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_android_package"),
}),
+ z.strictObject({
+ kind: z.literal("dart-aot"),
+ view: dartAotViewRequestSchema,
+ items: z.array(z.string()),
+ ...viewResultBase,
+ ...dartAotViewContextSchema.shape,
+ parent_operation: z.literal("inspect_dart_aot"),
+ }),
z.strictObject({
kind: z.literal("inventory"),
view: inventoryViewSchema,
@@ -549,6 +573,10 @@ export type UnsignedAnalysisView =
| Omit<
Extract,
"view_digest"
+ >
+ | Omit<
+ Extract,
+ "view_digest"
>;
/** Attach the content digest that must change when projected bytes change. */
@@ -594,7 +622,7 @@ 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, EVM, managed artifact, Android package, or web network capture Evidence.`,
+ `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -624,6 +652,7 @@ const retainedViewProjectors = new Map([
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_android_package", projectAndroidPackageView],
+ ["inspect_dart_aot", projectDartAotView],
["inspect_artifact", projectArtifactInventoryView],
["inventory_artifact", projectArtifactInventoryView],
]);
diff --git a/src/domain/analysisView/dartAotView.ts b/src/domain/analysisView/dartAotView.ts
new file mode 100644
index 000000000..4d66aa190
--- /dev/null
+++ b/src/domain/analysisView/dartAotView.ts
@@ -0,0 +1,123 @@
+import { z } from "zod";
+import type { AnalysisError } from "../analysisErrorBase.js";
+import { flutterResultSchemas } from "../flutter/flutterBuildAnalysis.js";
+import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
+import { err, ok, type Result } from "../result.js";
+import {
+ analysisViewInputError,
+ type AnalysisViewParent,
+ type AnalysisViewRequest,
+ type UnsignedAnalysisView,
+} from "./analysisView.js";
+import {
+ completeWithinViewCoverage,
+ pageViewCoverage,
+} from "./analysisViewCoverage.js";
+
+const aotSchema = flutterResultSchemas.inspect_dart_aot;
+
+/** Snapshot identity and original coverage, without unrelated string inventories. */
+export const dartAotSummarySchema = aotSchema.extend({
+ string_pool: aotSchema.shape.string_pool
+ .omit({ package_uris: true, dart_uris: true, class_like_tokens: true })
+ .extend({
+ returned_counts: z.strictObject({
+ package_uris: z.number().int().nonnegative(),
+ dart_uris: z.number().int().nonnegative(),
+ class_like_tokens: z.number().int().nonnegative(),
+ }),
+ }),
+});
+
+/** Pages of the producer-returned strings, preserving original order and scope. */
+export const dartAotViewRequestSchema = z.strictObject({
+ kind: z.literal("dart-aot"),
+ collection: z.enum(["package_uris", "dart_uris", "class_like_tokens"]),
+ offset: z.number().int().nonnegative().default(0),
+ limit: z.number().int().positive().default(64),
+});
+
+export const dartAotViewContextSchema = z.strictObject({
+ abi: aotSchema.shape.abi,
+ libapp: aotSchema.shape.libapp,
+ source_coverage: aotSchema.shape.coverage,
+ string_pool: dartAotSummarySchema.shape.string_pool,
+});
+
+/** Reuse authenticated AOT Evidence without opening the APK or rescanning its ELF. */
+export const projectDartAotView = (
+ parent: AnalysisViewParent,
+ view: AnalysisViewRequest,
+): Result => {
+ if (view.kind !== "summary" && view.kind !== "dart-aot")
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view"],
+ reason: "invalid_value",
+ message: "Dart AOT Evidence supports summary and string-pool pages.",
+ },
+ ]),
+ );
+ const parsed = aotSchema.safeParse(parent.normalizedResult);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(
+ "inspect_analysis_view",
+ parsed.error.issues.map((issue) => ({
+ ...issue,
+ path: ["source", "normalized_result", ...issue.path],
+ })),
+ { source: { normalized_result: parent.normalizedResult } },
+ { cause: parsed.error },
+ ),
+ );
+ const report = parsed.data;
+ const { package_uris, dart_uris, class_like_tokens, ...pool } =
+ report.string_pool;
+ const string_pool = {
+ ...pool,
+ returned_counts: {
+ package_uris: package_uris.length,
+ dart_uris: dart_uris.length,
+ class_like_tokens: class_like_tokens.length,
+ },
+ };
+ const shared = {
+ parent_evidence_id: parent.evidenceId,
+ parent_operation: "inspect_dart_aot" as const,
+ parent_digest: parent.evidenceId.slice(3),
+ artifact: { path: report.target.path, sha256: report.target.sha256 },
+ limitations: [...parent.limitations],
+ unknowns: [
+ "String-pool entries are byte-pattern observations, not recovered typed classes or functions.",
+ "Counts ending in _at_least remain lower bounds; exhausting returned strings does not establish a complete snapshot inventory.",
+ ...(parent.subject == null
+ ? [
+ "The parent has no recorded subject; its normalized target identity cannot be cross-checked against a subject digest.",
+ ]
+ : []),
+ ],
+ };
+ if (view.kind === "summary")
+ return ok({
+ kind: "summary",
+ view,
+ ...shared,
+ summary: { ...report, string_pool },
+ coverage: completeWithinViewCoverage(1, 1),
+ });
+ const collection = report.string_pool[view.collection];
+ const items = collection.slice(view.offset, view.offset + view.limit);
+ return ok({
+ kind: "dart-aot",
+ view,
+ ...shared,
+ items,
+ abi: report.abi,
+ libapp: report.libapp,
+ source_coverage: report.coverage,
+ string_pool,
+ coverage: pageViewCoverage(view.offset, items.length, collection.length),
+ });
+};
diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts
index 068149f68..10c5b7b00 100644
--- a/src/domain/flutter/flutterBuildAnalysis.ts
+++ b/src/domain/flutter/flutterBuildAnalysis.ts
@@ -30,6 +30,12 @@ export const flutterInputSchemas = {
}),
} as const;
+/** Retained delivery is selected independently of the provider's ABI request. */
+export const dartAotInspectionInputSchema =
+ flutterInputSchemas.inspect_dart_aot.extend({
+ detail: z.enum(["complete", "summary"]).optional(),
+ });
+
/** Supported Flutter-backed operations. */
export type FlutterOperation = keyof typeof flutterInputSchemas;
/** Validated Flutter requests carry their selected operation. */
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index c213efb0b..4929142c4 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -444,6 +444,7 @@ export const createServer = (
toolLogger,
recordEvidence,
analysisAdmission,
+ evidenceById,
);
registerApktoolTools(
server,
diff --git a/src/server/registerFlutterTools.ts b/src/server/registerFlutterTools.ts
index 5b601c5f7..cd57e12a9 100644
--- a/src/server/registerFlutterTools.ts
+++ b/src/server/registerFlutterTools.ts
@@ -1,10 +1,15 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js";
+import { inspectDartAot } from "../application/flutter/DartAotInspection.js";
+import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import type { Logger } from "pino";
-import { createContractToolHandler } from "./contractToolHandler.js";
+import {
+ createContractToolHandler,
+ type ContractToolService,
+} from "./contractToolHandler.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
/** Bind Flutter handlers to their exact named contracts through admission. */
@@ -14,6 +19,7 @@ export const registerFlutterTools = (
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
+ evidenceById?: EvidenceLookup,
): void => {
const handler = createContractToolHandler(
server,
@@ -22,14 +28,23 @@ export const registerFlutterTools = (
recordEvidence,
withAdmittedAnalysis,
);
- for (const contract of [
- toolContract("identify_flutter_build"),
- toolContract("inspect_dart_aot"),
- ] as const) {
- server.registerTool(
- contract.name,
- toolRegistrationOptions(contract),
- handler(contract),
- );
- }
+ const identify = toolContract("identify_flutter_build");
+ server.registerTool(
+ identify.name,
+ toolRegistrationOptions(identify),
+ handler(identify),
+ );
+ const aotService: ContractToolService<"inspect_dart_aot"> = {
+ execute: (_operation, input, options) =>
+ inspectDartAot(service, input, { recordEvidence, evidenceById }, options),
+ };
+ const aotHandler = createContractToolHandler(
+ server,
+ aotService,
+ logger,
+ recordEvidence,
+ withAdmittedAnalysis,
+ );
+ const aot = toolContract("inspect_dart_aot");
+ server.registerTool(aot.name, toolRegistrationOptions(aot), aotHandler(aot));
};
diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts
index 457dce96b..11671785e 100644
--- a/tests/acceptance/flutterAnalysis.test.ts
+++ b/tests/acceptance/flutterAnalysis.test.ts
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../src/domain/evidence.js";
+import { flutterResultSchemas } from "../../src/domain/flutter/flutterBuildAnalysis.js";
import {
aotFixtureImage,
engineFixtureImage,
@@ -59,6 +60,9 @@ cliTest(
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract(name).outputSchema.parse(result.json);
+ const complete = flutterResultSchemas[name].parse(
+ evidence.normalized_result,
+ );
const response = await call(name, {
path,
...(abi === undefined ? {} : { abi }),
@@ -70,7 +74,7 @@ cliTest(
expect(mcp.subject).toMatchObject({
local_path: path,
format: "apk",
- digest: { sha256: evidence.normalized_result.target.sha256 },
+ digest: { sha256: complete.target.sha256 },
});
expect(mcp.evidence_id).toBe(evidence.evidence_id);
if (name === "inspect_dart_aot") {
@@ -134,6 +138,9 @@ cliTest(
const evidence = toolContract("inspect_dart_aot").outputSchema.parse(
result.json,
);
+ const complete = flutterResultSchemas.inspect_dart_aot.parse(
+ evidence.normalized_result,
+ );
const { call } = await connectLocalToolsMcp();
const response = await call("inspect_dart_aot", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
@@ -146,7 +153,7 @@ cliTest(
string_pool: { coverage: "complete" },
});
expect(
- evidence.normalized_result.libapp.sections
+ complete.libapp.sections
.filter((section) => section.name.endsWith("Data"))
.map((section) => section.kind),
).toEqual([4, 4]);
diff --git a/tests/boundary/mcp/dartAotSummaryMcp.test.ts b/tests/boundary/mcp/dartAotSummaryMcp.test.ts
new file mode 100644
index 000000000..be6a970d3
--- /dev/null
+++ b/tests/boundary/mcp/dartAotSummaryMcp.test.ts
@@ -0,0 +1,285 @@
+import { unlink } from "node:fs/promises";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { expect, it, onTestFinished, vi } from "vitest";
+import { FlutterBuildAnalysisService } from "../../../src/application/flutter/FlutterBuildAnalysisService.js";
+import { inspectDartAot } from "../../../src/application/flutter/DartAotInspection.js";
+import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
+import { createFlutterBuildAnalysisProvider } from "../../../src/composition/flutter.js";
+import {
+ analysisViewResultSchema,
+ dartAotSummaryViewResultSchema,
+} from "../../../src/domain/analysisView/analysisView.js";
+import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
+import { flutterResultSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js";
+import type { JsonValue } from "../../../src/domain/jsonValue.js";
+import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js";
+import { createFlutterApk } from "../../fixtures/flutterApk.js";
+import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
+
+it("honors explicit ABI and old full requests, validates SDK schemas and reuses complete Evidence after APK removal", async () => {
+ const path = await createFlutterApk("views.apk", [
+ { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() },
+ { path: "lib/x86_64/libapp.so", bytes: aotFixtureImage("b".repeat(32)) },
+ ]);
+ const provider = createFlutterBuildAnalysisProvider();
+ const execute = vi.spyOn(provider, "execute");
+ const harness = await createApplicationMcpHarness({
+ serverOptions: { flutterAnalysis: provider },
+ });
+ onTestFinished(harness.close);
+ const { client, session } = harness;
+ const catalog = (await client.listTools()).tools;
+ const definition = catalog.find((tool) => tool.name === "inspect_dart_aot");
+ if (definition?.outputSchema === undefined)
+ throw new Error("Missing AOT output schema");
+ const outputSchema: Record = definition.outputSchema;
+ const ajv = new Ajv2020({ strict: false, validateFormats: false });
+ expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
+ expect(ajv.validateSchema(outputSchema)).toBe(true);
+ expect(
+ ajv.validate(
+ catalog.find((tool) => tool.name === "identify_flutter_build")!
+ .inputSchema,
+ { path, detail: "summary" },
+ ),
+ ).toBe(false);
+ const reply = await client.callTool({
+ name: "inspect_dart_aot",
+ arguments: { path, abi: "x86_64", detail: "summary" },
+ });
+ expect(reply.isError).not.toBe(true);
+ expect(
+ ajv.validate(outputSchema, reply.structuredContent),
+ JSON.stringify(ajv.errors),
+ ).toBe(true);
+ const receipt = parseEvidence(reply.structuredContent);
+ const view = dartAotSummaryViewResultSchema.parse(receipt.normalized_result);
+ const parent = session.evidenceById(view.parent_evidence_id);
+ if (parent === undefined)
+ throw new Error("Complete AOT source was not retained");
+ const report = flutterResultSchemas.inspect_dart_aot.parse(
+ parent.normalized_result,
+ );
+ expect(report.abi).toBe("x86_64");
+ expect(report.libapp.snapshot_hash).toBe("b".repeat(32));
+ expect(parent.parameters).toEqual({ path, abi: "x86_64" });
+ expect(execute).toHaveBeenLastCalledWith(
+ { operation: "inspect_dart_aot", input: { path, abi: "x86_64" } },
+ expect.anything(),
+ );
+ expect(view.summary.libapp).toEqual(report.libapp);
+ expect(view.summary.target).toEqual(report.target);
+ expect(view.summary.coverage).toBe(report.coverage);
+ expect(receipt.evidence_id).not.toBe(parent.evidence_id);
+ expect(receipt.evidence_links).toEqual([parent.evidence_id]);
+ for (const detail of [undefined, "complete"]) {
+ const complete = parseEvidence(
+ (
+ await client.callTool({
+ name: "inspect_dart_aot",
+ arguments: {
+ path,
+ abi: "x86_64",
+ ...(detail === undefined ? {} : { detail }),
+ },
+ })
+ ).structuredContent,
+ );
+ expect(complete.operation).toBe("inspect_dart_aot");
+ expect(complete.normalized_result).toEqual(parent.normalized_result);
+ expect(complete.parameters).toEqual(parent.parameters);
+ }
+ const preferred = parseEvidence(
+ (await client.callTool({ name: "inspect_dart_aot", arguments: { path } }))
+ .structuredContent,
+ );
+ expect(
+ flutterResultSchemas.inspect_dart_aot.parse(preferred.normalized_result)
+ .abi,
+ ).toBe("arm64-v8a");
+ const original = JSON.stringify(parent);
+ await unlink(path);
+ const calls = execute.mock.calls.length;
+ for (const collection of [
+ "package_uris",
+ "dart_uris",
+ "class_like_tokens",
+ ] as const) {
+ const strings: string[] = [];
+ let offset = 0;
+ do {
+ const reply = await client.callTool({
+ name: "inspect_analysis_view",
+ arguments: {
+ source: {
+ kind: "retained-evidence",
+ evidence_id: parent.evidence_id,
+ },
+ view: { kind: "dart-aot", collection, offset, limit: 1 },
+ },
+ });
+ expect(reply.isError).not.toBe(true);
+ const page = analysisViewResultSchema.parse(
+ parseEvidence(reply.structuredContent).normalized_result,
+ );
+ if (page.kind !== "dart-aot") throw new Error("Wrong pool page kind");
+ strings.push(...page.items);
+ expect(page.libapp).toEqual(report.libapp);
+ expect(page.source_coverage).toBe(report.coverage);
+ expect(page.string_pool.coverage).toBe(report.string_pool.coverage);
+ offset =
+ page.coverage.next_offset ?? report.string_pool[collection].length;
+ } while (offset < report.string_pool[collection].length);
+ expect(strings).toEqual(report.string_pool[collection]);
+ }
+ expect(execute).toHaveBeenCalledTimes(calls);
+ expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe(
+ original,
+ );
+ expect(
+ session
+ .exportEvidenceBundle()
+ .records.find((record) => record.evidence_id === parent.evidence_id),
+ ).toEqual(parent);
+ const portable = inspectAnalysisView({
+ source: { kind: "inline", evidence: parent },
+ view: { kind: "summary" },
+ });
+ if (!portable.ok) throw portable.error;
+ expect(portable.value.normalized_result).toEqual(view);
+}, 30000);
+
+it("preserves null snapshot facts and pool lower bounds after exhaustion, rejecting malformed and conflicting identities", () => {
+ const report = {
+ target: { path: "/source.apk", bytes: 10, sha256: "a".repeat(64) },
+ abi: "arm64-v8a",
+ libapp: {
+ coverage: "partial",
+ bytes: 1,
+ sha256: "b".repeat(64),
+ snapshot_hash: null,
+ snapshot_hash_candidates: [],
+ snapshot_hash_sources: 0,
+ sections: [],
+ },
+ string_pool: {
+ coverage: "partial",
+ package_uris: ["package:opaque/a.dart"],
+ package_uri_count_at_least: 1000,
+ dart_uris: [],
+ dart_uri_count_at_least: 0,
+ class_like_tokens: ["incidental"],
+ class_like_token_count_at_least: 4000,
+ printable_run_count: 12,
+ },
+ coverage: "partial",
+ };
+ const evidence = (
+ result: JsonValue = report,
+ sha: string | null = "a".repeat(64),
+ ) =>
+ createEvidence(
+ sha === null
+ ? undefined
+ : { path: "/source.apk", sha256: sha, format: "apk" },
+ { id: "flutter", name: "Flutter", version: null },
+ {
+ operation: "inspect_dart_aot",
+ parameters: {},
+ result,
+ limitations: ["Original uncertainty"],
+ },
+ );
+ for (const parent of [evidence(), evidence(report, null)]) {
+ const summary = inspectAnalysisView({
+ source: { kind: "inline", evidence: parent },
+ view: { kind: "summary" },
+ });
+ if (!summary.ok) throw summary.error;
+ const result = dartAotSummaryViewResultSchema.parse(
+ summary.value.normalized_result,
+ );
+ expect(result.summary.libapp.snapshot_hash).toBeNull();
+ expect(result.summary.string_pool.package_uri_count_at_least).toBe(1000);
+ expect(result.summary.string_pool.returned_counts.package_uris).toBe(1);
+ expect(result.limitations).toEqual(parent.limitations);
+ const page = inspectAnalysisView({
+ source: { kind: "inline", evidence: parent },
+ view: {
+ kind: "dart-aot",
+ collection: "package_uris",
+ offset: 0,
+ limit: 10,
+ },
+ });
+ if (!page.ok) throw page.error;
+ expect(page.value.normalized_result).toMatchObject({
+ kind: "dart-aot",
+ items: report.string_pool.package_uris,
+ source_coverage: "partial",
+ string_pool: { coverage: "partial", package_uri_count_at_least: 1000 },
+ coverage: { total: 1, exhausted: true },
+ });
+ }
+ expect(
+ inspectAnalysisView({
+ source: { kind: "inline", evidence: evidence(report, "c".repeat(64)) },
+ view: { kind: "summary" },
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "EvidenceIntegrityError" } });
+ expect(
+ inspectAnalysisView({
+ source: {
+ kind: "inline",
+ evidence: evidence({
+ ...report,
+ string_pool: {
+ ...report.string_pool,
+ package_uri_count_at_least: -1,
+ },
+ }),
+ },
+ view: { kind: "summary" },
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+ expect(
+ inspectAnalysisView({
+ source: { kind: "inline", evidence: evidence() },
+ view: { kind: "android", facet: "manifest", offset: 0, limit: 1 },
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+});
+
+it("rejects unavailable retention, invalid delivery and cancellation before producer work", async () => {
+ const provider = createFlutterBuildAnalysisProvider();
+ onTestFinished(() => provider.close());
+ const execute = vi.spyOn(provider, "execute");
+ const service = new FlutterBuildAnalysisService(provider);
+ const retention = { recordEvidence: undefined, evidenceById: undefined };
+ expect(
+ await inspectDartAot(
+ service,
+ { path: "/missing.apk", abi: "x86_64", detail: "summary" },
+ retention,
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisCapabilityUnavailableError" },
+ });
+ expect(
+ await inspectDartAot(
+ service,
+ { path: "/missing.apk", detail: "other" },
+ retention,
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+ expect(
+ await inspectDartAot(
+ service,
+ { path: "/missing.apk", detail: "summary" },
+ retention,
+ { signal: AbortSignal.abort() },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+ expect(execute).not.toHaveBeenCalled();
+});
From 172407787f1842dedbcd27b5385177595f786ce4 Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 11:42:54 +0800
Subject: [PATCH 005/159] fix(windows): preserve transient runtime admission
failures (#2467)
Fixes #2257. Thanks to @chqcjq for the native self-test retry diagnosis in #2266.
Preserve retry disposition through analysis workflows. Native warm admission requires exact sharing/lock codes and affirmative pre-allocation evidence; cleanup ownership takes precedence. No automatic retry or new analysis cache.
Verified with real Windows 11 CLI, same-connection SDK MCP normal/conflict/recovery, the Windows native verifier, 99 focused tests and static/docs gates. Official npm 11.16.0 launcher sources were the application input. No IDA or Ghidra engine was used for this change. Win32 5 was not reproduced; no resolution claim for #1701/#2196. Performance measurements and limits are recorded in experiments/analysis-efficiency/windows-runtime-retry.md.
---
.../windows-runtime-retry.md | 107 ++++++++++++++++++
native/windows/README.md | 11 ++
native/windows/src/addon.cc | 2 +
native/windows/src/authority.hpp | 2 +
native/windows/src/filesystem.cc | 7 +-
scripts/verify-windows-native.mjs | 33 ++++++
src/domain/analysisErrorCore.ts | 8 +-
src/domain/analysisErrorPresentation.ts | 7 ++
src/domain/analysisErrorProjection.test.ts | 85 ++++++++++++++
src/domain/analysisErrorProjection.ts | 2 +
.../analysis/JavaScriptAnalysisWorker.ts | 8 +-
src/javascript/sourceMaps/SourceMapCommand.ts | 8 +-
src/process/PrivateRuntimeRoot.ts | 15 +--
.../PrivateRuntimeRootUnavailableError.ts | 14 +++
src/process/WindowsAuthority.ts | 2 +
src/wasm/WabtArtifactProvider.test.ts | 78 +++++++------
src/wasm/WabtFailures.ts | 4 +-
src/windows/WindowsPrivateRuntime.ts | 26 ++++-
.../sourceMapRuntimeAvailability.test.ts | 35 ++++++
.../windowsRuntimeAdmission.test.ts | 99 ++++++++++++++++
20 files changed, 491 insertions(+), 62 deletions(-)
create mode 100644 experiments/analysis-efficiency/windows-runtime-retry.md
create mode 100644 src/process/PrivateRuntimeRootUnavailableError.ts
create mode 100644 tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts
create mode 100644 tests/process-global/windowsRuntimeAdmission.test.ts
diff --git a/experiments/analysis-efficiency/windows-runtime-retry.md b/experiments/analysis-efficiency/windows-runtime-retry.md
new file mode 100644
index 000000000..2286727e8
--- /dev/null
+++ b/experiments/analysis-efficiency/windows-runtime-retry.md
@@ -0,0 +1,107 @@
+# Windows runtime admission retry verification
+
+Scope: [#2257](https://github.com/morluto/rea/issues/2257). Related initial
+diagnosis: [#2266](https://github.com/morluto/rea/pull/2266).
+
+## Revisions and real input
+
+The baseline is `4f4f6cd7b5d7a400b90772a138d7b9f942c2a556`.
+The candidate was developed from
+`4773c152bc5f299fa682575bb8d1efea7587c7bc`; relevant runtime, native and
+dependency-lock sources are identical between those two revisions.
+These measurements precede integration of subsequent main changes.
+
+The host ran Windows 11 10.0.22631.0, official Windows x64 Node 24.18.0,
+and locked production dependencies. The real application input was the official
+npm 11.16.0 launcher distribution's `bin` directory. REA inspected that source;
+it did not execute the application as an analysis target. Runtime copies,
+raw replies and test executables remain outside the checkout. No IDA or Ghidra
+engine was involved in this native-runtime verification.
+
+## Correctness and recovery
+
+A test-owned directory handle requesting `FILE_ADD_FILE` produced an actual
+kernel sharing violation (Win32 32) during native runtime-parent admission.
+Releasing that handle restored admission. Before the fix, public CLI analysis
+reported a permanent `unsupported_provider` failure with `retryable: false`.
+The corrected CLI preserves `capability_unavailable` and the reason, with
+`category: unavailable` and `retryable: true`.
+
+Normal CLI analysis and post-conflict recovery produced the same canonical
+normalized result before and after the correction: 535,733 compact JSON bytes,
+SHA-256 `a24d3c2d9897cccc5ba70e9ee4dce03da88f033a939fe87457136fc5b3a3e2db`.
+The complete successful CLI reply remained 537,124 bytes.
+
+A single real SDK 2.3.1 stdio MCP connection first completed analysis, then
+encountered the same kernel conflict after native authority caching, then
+recovered successfully when the handle was released. Its final receipt passed
+and found no residual runtime directories. The native producer supplies
+`runtimeAllocation: "not-started"` only when parent admission failed before
+directory creation. The adapter requires that fact and exact numeric codes
+32/33; permission failures, opaque error text and unknown allocation state
+remain nonretryable. Incomplete cleanup also prevents retry.
+
+The native verifier additionally reproduces a real sharing conflict using an
+owned runtime's DELETE lease, verifies the original error and allocation fact,
+and confirms no child directory was allocated. This case runs unconditionally
+in the Windows native verifier, without the optional process-boundary fixture.
+The full verifier passed with 20 positive controls. Its optional independently
+built process-boundary fixture was not supplied in this run.
+
+The related focused suite passed 99 tests across seven files, including worker
+ownership, cancellation, cleanup, error projection, loader behavior and adapter
+boundaries. Source-map and WABT acquisition-failure tests use injected failures;
+they establish disposition propagation, not real engine execution.
+
+Thirty baseline native self-tests passed with no residual directories.
+Win32 5 from #1701/#2196 was not reproduced on this host; these results do not
+establish its cause or resolution.
+
+## Delivery and resource measurements
+
+One final same-connection MCP trial used one CPU per Node process, BelowNormal
+priority, a 512 MiB client heap setting and a 768 MiB server heap setting.
+Heap settings do not bound total process or family memory.
+
+| Stage | Observed wall time | Serialized reply bytes |
+| ---------------------------------- | -----------------: | ---------------------: |
+| Connect | 11,351 ms | Not recorded |
+| Complete tool discovery, 187 tools | 744 ms | 2,498,686 |
+| Normal analysis | 12,887 ms | 1,126,797 |
+| Sharing failure | 265 ms | 878 |
+| Recovery on the same connection | 5,578 ms | 1,126,797 |
+
+The earlier warm failure reply was 1,218 bytes. Successful reply sizes and the
+catalogue digest were unchanged within the tested source comparison:
+`7bdadddc909f4b2b093f0b3da9f79fafdac879c9d3c147938b8fbce1b52cd726`.
+These are delivery bytes, not model tokens. Model-visible discovery handling,
+actual model token usage and total model task cost were not measured.
+
+A targeted process-generation sampler collected 343 observations over
+35,630 ms. Its aggregate observed CPU time was a **lower bound** of 26,766 ms:
+process exits can hide the final CPU tail. Its peak sampled working-set sum was
+727,244,800 bytes (about 694 MiB). This is a sampled sum, can double-count shared
+pages and is not an exact physical-memory peak. Only the test-owned Node and
+directory-holder processes were included; the observer itself was excluded.
+
+This is one controlled functional trial, with no paired CPU/memory baseline.
+Cold/warm filesystem caches and host noise were not normalized. No speedup,
+CPU reduction, memory reduction or model-token savings are claimed. The change
+adds no analysis retry loop or cache and performs no additional engine calls.
+Existing successful native authority caching remains intact; failed readiness
+is retried by a later caller attempt. Retry becomes useful advice only after
+the transient condition clears and cleanup permits another attempt.
+
+## Integration replay
+
+After rebasing onto main `a2d8b94dd`, runtime candidate
+`2c7a09eaf3ffe26667b40081f1cf0fd2ce44a146` again passed the 99 related tests,
+static and documentation gates, real Windows CLI and same-connection MCP
+normal/conflict/recovery sequence. Both workflows found no residual directories;
+the CLI normalized-result digest above still matched. The MCP success and error
+reply sizes remained 1,126,797 and 878 bytes respectively. Upstream Android/SAZ
+contract changes increased complete discovery to 2,517,904 bytes, digest
+`d14588eab7404ad129907eb34a45a0db0d43233c4de829f76babd59723bed199`.
+This later catalogue is not evidence of a delivery-size change caused by the
+runtime-admission fix. Resource sampling above belongs to the earlier pinned
+candidate; aggregate resources were not resampled for this integration replay.
diff --git a/native/windows/README.md b/native/windows/README.md
index 82dd5526b..c63540140 100644
--- a/native/windows/README.md
+++ b/native/windows/README.md
@@ -85,6 +85,17 @@ Windows failures retain their constraint, requested coordinate, Win32 code,
and system message. Unsupported filesystems and reparse paths are distinct
from OS access denial and missing packaged controls.
+A failed runtime-parent admission additionally reports
+`runtimeAllocation: "not-started"`: no runtime directory was created. The
+Windows adapter uses that fact with numeric sharing/lock codes 32/33 to retain
+a retryable private-runtime failure, including after successful native
+authority caching. Missing allocation evidence, other codes, and opaque error
+text do not establish a safe retry. Analysis errors propagate the producer's
+retry disposition through JavaScript, source-map and WABT workflows; transient
+failures report `category: unavailable` and retain the original reason.
+Incomplete cleanup blocks an operation retry until its owned resources settle.
+REA adds no automatic retry loop or new analysis cache for this behavior.
+
Ordinary drive-absolute paths accept backslashes, forward slashes, or mixed
separators. File identity retains the original requested spelling. Separator
translation does not resolve dot components, repeated separators, or extend
diff --git a/native/windows/src/addon.cc b/native/windows/src/addon.cc
index 0e39b84c0..bffd4cbd4 100644
--- a/native/windows/src/addon.cc
+++ b/native/windows/src/addon.cc
@@ -87,6 +87,8 @@ napi_value failureValue(napi_env env, const Failure& failure) {
set(env, error, "requestedPath", string(env, failure.path));
set(env, error, "win32Code", number(env, failure.win32));
set(env, error, "win32Message", string(env, detail));
+ if (failure.runtimeDirectoryUnallocated)
+ set(env, error, "runtimeAllocation", string(env, std::string("not-started")));
return error;
}
diff --git a/native/windows/src/authority.hpp b/native/windows/src/authority.hpp
index cc713ce0b..13d7e4d60 100644
--- a/native/windows/src/authority.hpp
+++ b/native/windows/src/authority.hpp
@@ -33,6 +33,8 @@ struct Failure : std::runtime_error {
DWORD win32;
std::wstring path;
std::string constraint;
+ // Affirmative proof for a failed runtime-parent admission; omission is unknown.
+ bool runtimeDirectoryUnallocated = false;
Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError())
: std::runtime_error(constraint), win32(code), path(std::move(path)),
constraint(std::move(constraint)) {}
diff --git a/native/windows/src/filesystem.cc b/native/windows/src/filesystem.cc
index 6cfbc8e97..c8f66e5c1 100644
--- a/native/windows/src/filesystem.cc
+++ b/native/windows/src/filesystem.cc
@@ -212,7 +212,12 @@ static void verifyPrivate(HANDLE handle, const std::wstring& path, bool root) {
}
std::unique_ptr createRuntime(const std::wstring& parentPath, const std::wstring& prefix) {
- auto parent = openFile(parentPath, 0, true);
+ std::unique_ptr parent;
+ try { parent = openFile(parentPath, 0, true); }
+ catch (Failure& failure) {
+ failure.runtimeDirectoryUnallocated = true;
+ throw;
+ }
require(!prefix.empty() && prefix.find_first_of(L"\\/:\"<>|?*") == std::wstring::npos,
"Runtime prefix must be one filename component", parentPath, ERROR_INVALID_PARAMETER);
std::array random;
diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs
index c63b6909f..3db2613ad 100644
--- a/scripts/verify-windows-native.mjs
+++ b/scripts/verify-windows-native.mjs
@@ -11,6 +11,7 @@ import {
mkdtemp,
readFile,
rename,
+ readdir,
rm,
symlink,
writeFile,
@@ -37,6 +38,14 @@ const { WindowsPrivateRuntime, windowsPrivateRuntime } = await import(
const { ghidraSessionRoot } = await import(
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionRoot.js"))
);
+const { PrivateRuntimeRoot } = await import(
+ pathToFileURL(join(packageRoot, "dist/process/PrivateRuntimeRoot.js"))
+);
+const { PrivateRuntimeRootUnavailableError } = await import(
+ pathToFileURL(
+ join(packageRoot, "dist/process/PrivateRuntimeRootUnavailableError.js"),
+ )
+);
const workspace = await mkdtemp(join(tmpdir(), "rea-native-conformance-"));
const environment = Object.entries(process.env)
.filter(([, value]) => value !== undefined)
@@ -140,6 +149,30 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
])
assert.throws(() => native.call("open", [rejected]));
report.controls.ordinaryDriveSeparatorsAndRequestedIdentity = true;
+ // An owned runtime's DELETE lease conflicts with a fresh parent admission.
+ // This produces a genuine sharing failure after successful authority caching,
+ // without another executable, and proves that no child runtime was created.
+ const sharingParent = WindowsPrivateRuntime.create(
+ workspace,
+ "rea-sharing-parent-",
+ );
+ try {
+ await assert.rejects(
+ PrivateRuntimeRoot.create({ parent: sharingParent.observation.path }),
+ (failure) => {
+ assert.ok(failure instanceof PrivateRuntimeRootUnavailableError);
+ assert.equal(failure.retryable, true);
+ assert.ok(failure.cause instanceof Error);
+ assert.equal(failure.cause.win32Code, 32);
+ assert.equal(failure.cause.runtimeAllocation, "not-started");
+ return true;
+ },
+ );
+ assert.deepEqual(await readdir(sharingParent.observation.path), []);
+ report.controls.transientRuntimeAdmissionBeforeAllocation = true;
+ } finally {
+ await sharingParent.close();
+ }
const driveRoot = parse(workspace).root;
const runtimeParent = ghidraSessionRoot({
base: join(driveRoot, ".Tmp", "scratch"),
diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts
index 9e08f29a3..50db91ccd 100644
--- a/src/domain/analysisErrorCore.ts
+++ b/src/domain/analysisErrorCore.ts
@@ -100,18 +100,24 @@ export class AnalysisOutputError extends AnalysisError {
export class AnalysisCapabilityUnavailableError extends AnalysisError {
readonly _tag = "AnalysisCapabilityUnavailableError";
override readonly userMessage: string | undefined;
+ /** Incomplete cleanup must settle before the operation can be retried. */
+ readonly retryable: boolean;
constructor(
readonly providerId: string,
readonly operation: string,
readonly reason: string,
- options?: AnalysisErrorOptions & { readonly userMessage?: string },
+ options?: AnalysisErrorOptions & {
+ readonly userMessage?: string;
+ readonly retryable?: boolean;
+ },
) {
super(
`Provider ${providerId} cannot execute ${operation}: ${reason}`,
options,
);
this.userMessage = options?.userMessage;
+ this.retryable = options?.retryable === true && !this.cleanupIncomplete;
}
}
diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts
index 215ed40fc..19c3a9002 100644
--- a/src/domain/analysisErrorPresentation.ts
+++ b/src/domain/analysisErrorPresentation.ts
@@ -1,6 +1,7 @@
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
+ AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisUnsupportedTargetError,
AnalysisResourceConstraintError,
@@ -33,6 +34,8 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
export const analysisErrorRemediationAction = (
error: AnalysisError,
): string => {
+ if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
+ return "Review details.reason, then retry the same analysis after the condition clears.";
if (error instanceof EvidenceFileError && error.published !== undefined)
return "Use the published file at details.published_path and resolve the reported staging cleanup failure.";
if (error instanceof AnalysisSnapshotMismatchError)
@@ -112,6 +115,8 @@ export const analysisErrorRemediationAction = (
export const analysisErrorCategory = (
error: AnalysisError,
): AnalysisErrorProjection["category"] => {
+ if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
+ return "unavailable";
if (
error instanceof BinaryTargetError &&
error.constraint === "load_selection_rejected"
@@ -220,6 +225,8 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
const hopperMessage = hopperErrorUserMessage(error);
if (hopperMessage !== undefined) return hopperMessage;
if (error.userMessage !== undefined) return error.userMessage;
+ if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
+ return "Required runtime controls are unavailable for this attempt.";
const standardMessage = standardErrorMessage(error._tag);
if (standardMessage !== undefined) return standardMessage;
if (error instanceof ArtifactOperationError) return artifactMessage(error);
diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts
index 467704b98..0766ca6e0 100644
--- a/src/domain/analysisErrorProjection.test.ts
+++ b/src/domain/analysisErrorProjection.test.ts
@@ -25,6 +25,91 @@ import { ProviderSelectionError } from "./providerSelectionError.js";
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
import { nativeCallPartialObservationSchema } from "./native/nativeCallPartialObservation.js";
+describe("capability failure retry disposition", () => {
+ it.each([
+ { options: {}, retryable: false, category: "unsupported_provider" },
+ {
+ options: { retryable: false },
+ retryable: false,
+ category: "unsupported_provider",
+ },
+ { options: { retryable: true }, retryable: true, category: "unavailable" },
+ ] as const)(
+ "preserves the producer's $retryable disposition",
+ ({ options, retryable, category }) => {
+ const reason = "Native self-test failed with a sharing violation";
+ const projected = projectAnalysisError(
+ new AnalysisCapabilityUnavailableError(
+ "fixture-provider",
+ "inspect",
+ reason,
+ options,
+ ),
+ );
+ expect(projected).toMatchObject({
+ code: "capability_unavailable",
+ category,
+ retryable,
+ details: { reason },
+ });
+ if (retryable) {
+ expect(projected.message).toBe(
+ "Required runtime controls are unavailable for this attempt.",
+ );
+ expect(projected.remediation.action).toContain(
+ "retry the same analysis",
+ );
+ }
+ expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
+ true,
+ );
+ },
+ );
+
+ it("preserves a caller-selected message for a retryable failure", () => {
+ expect(
+ projectAnalysisError(
+ new AnalysisCapabilityUnavailableError(
+ "fixture-provider",
+ "inspect",
+ "temporary failure",
+ {
+ retryable: true,
+ userMessage: "Selected runtime diagnostic",
+ },
+ ),
+ ).message,
+ ).toBe("Selected runtime diagnostic");
+ });
+
+ it("retains cleanup ownership before allowing a transient operation to retry", () => {
+ expect(
+ projectAnalysisError(
+ new AnalysisCapabilityUnavailableError(
+ "fixture-provider",
+ "inspect",
+ "temporary failure",
+ {
+ retryable: true,
+ cleanup: {
+ reason: "owned handle is still live",
+ resources: ["native-owner"],
+ },
+ },
+ ),
+ ),
+ ).toMatchObject({
+ code: "cleanup_incomplete",
+ retryable: false,
+ details: {
+ cleanup: "incomplete",
+ resources: ["native-owner"],
+ execution_failure: "capability_unavailable",
+ },
+ });
+ });
+});
+
it("projects a published file receipt alongside retained cleanup failure", () => {
const projected = projectAnalysisError(
new EvidenceFileError("write", "io", {
diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts
index 723200e18..bfdf7eb42 100644
--- a/src/domain/analysisErrorProjection.ts
+++ b/src/domain/analysisErrorProjection.ts
@@ -83,6 +83,8 @@ export const projectAnalysisError = (
message: analysisErrorUserMessage(error),
retryable:
RETRYABLE_CODES.has(code) ||
+ (error instanceof AnalysisCapabilityUnavailableError &&
+ error.retryable) ||
(code === "resource_constraint" &&
error instanceof AnalysisResourceConstraintError &&
error.resource === "capacity"),
diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts
index c02ddaa3e..6a587915b 100644
--- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts
+++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts
@@ -27,10 +27,8 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ok, err, type Result } from "../../domain/result.js";
import { javaScriptRuntime } from "../../domain/runtimeVersion.js";
-import {
- PrivateRuntimeRoot,
- PrivateRuntimeRootUnavailableError,
-} from "../../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRootUnavailableError } from "../../process/PrivateRuntimeRootUnavailableError.js";
import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js";
import {
ProviderProcessSupervisor,
@@ -735,7 +733,7 @@ export class JavaScriptAnalysisWorker {
PROVIDER,
OPERATION,
cause.message,
- { cause },
+ { cause, retryable: cause.retryable },
)
: new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diff --git a/src/javascript/sourceMaps/SourceMapCommand.ts b/src/javascript/sourceMaps/SourceMapCommand.ts
index e86954106..9e8837952 100644
--- a/src/javascript/sourceMaps/SourceMapCommand.ts
+++ b/src/javascript/sourceMaps/SourceMapCommand.ts
@@ -16,10 +16,8 @@ import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { WEB_SOURCE_MAP_LIMITS } from "../../domain/webSourceLocation.js";
-import {
- PrivateRuntimeRoot,
- PrivateRuntimeRootUnavailableError,
-} from "../../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRootUnavailableError } from "../../process/PrivateRuntimeRootUnavailableError.js";
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
import { javaScriptWorkerMemoryLimitBytes } from "../../process/JavaScriptWorkerMemoryLimit.js";
import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js";
@@ -261,7 +259,7 @@ class OwnedCodec implements SourceMapCleanupOwner {
PROVIDER,
OPERATION,
cause.message,
- { cause },
+ { cause, retryable: cause.retryable },
)
: new ProviderAdapterError(PROVIDER, OPERATION, {
cause,
diff --git a/src/process/PrivateRuntimeRoot.ts b/src/process/PrivateRuntimeRoot.ts
index 7002e2f65..c319df0de 100644
--- a/src/process/PrivateRuntimeRoot.ts
+++ b/src/process/PrivateRuntimeRoot.ts
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { WindowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
+import { PrivateRuntimeRootUnavailableError } from "./PrivateRuntimeRootUnavailableError.js";
import {
windowsNativeCapabilities,
@@ -34,16 +35,6 @@ export const privateRuntimeRootCapability = (
? windowsNativeCapabilities(platform).private_runtime_dacl
: { available: true, reason: null, proof: "posix-mkdtemp-owner-only" };
-/** Expected failure when a runtime root's privacy boundary is unavailable. */
-export class PrivateRuntimeRootUnavailableError extends Error {
- readonly code = "private-runtime-root-authority-unavailable";
-
- constructor(message: string) {
- super(message);
- this.name = "PrivateRuntimeRootUnavailableError";
- }
-}
-
/**
* Owns a private temporary runtime root and removes it idempotently.
*
@@ -66,7 +57,9 @@ export class PrivateRuntimeRoot {
const platform = options.platform ?? process.platform;
const capability = privateRuntimeRootCapability(platform);
if (!capability.available)
- throw new PrivateRuntimeRootUnavailableError(capability.reason);
+ throw new PrivateRuntimeRootUnavailableError(capability.reason, {
+ retryable: capability.retryable === true,
+ });
if (platform === "win32") {
const runtime = WindowsPrivateRuntime.create(
options.parent ?? tmpdir(),
diff --git a/src/process/PrivateRuntimeRootUnavailableError.ts b/src/process/PrivateRuntimeRootUnavailableError.ts
new file mode 100644
index 000000000..27e7ae49d
--- /dev/null
+++ b/src/process/PrivateRuntimeRootUnavailableError.ts
@@ -0,0 +1,14 @@
+/** Expected failure when a runtime root's privacy boundary is unavailable. */
+export class PrivateRuntimeRootUnavailableError extends Error {
+ readonly code = "private-runtime-root-authority-unavailable";
+ readonly retryable: boolean;
+
+ constructor(
+ message: string,
+ options?: ErrorOptions & { readonly retryable?: boolean },
+ ) {
+ super(message, options);
+ this.name = "PrivateRuntimeRootUnavailableError";
+ this.retryable = options?.retryable ?? false;
+ }
+}
diff --git a/src/process/WindowsAuthority.ts b/src/process/WindowsAuthority.ts
index ccd041312..b25a22863 100644
--- a/src/process/WindowsAuthority.ts
+++ b/src/process/WindowsAuthority.ts
@@ -11,6 +11,7 @@ export type WindowsNativeCapability =
readonly available: false;
readonly reason: string;
readonly proof: "not-proven" | "not-applicable";
+ readonly retryable?: true;
};
/** Security controls required before REA may claim Windows isolation. */
@@ -52,6 +53,7 @@ export const windowsNativeCapabilities = (
available: false,
reason: loaded.reason,
proof: "not-proven",
+ ...(loaded.retryable === true ? { retryable: true as const } : {}),
} as const);
return {
job_object_process_ownership: capability,
diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts
index 74d481de3..94c4e0c95 100644
--- a/src/wasm/WabtArtifactProvider.test.ts
+++ b/src/wasm/WabtArtifactProvider.test.ts
@@ -3,12 +3,11 @@ import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { WabtArtifactProvider } from "./WabtArtifactProvider.js";
-import {
- PrivateRuntimeRoot,
- PrivateRuntimeRootUnavailableError,
-} from "../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
const failureTags: Readonly> = {
invalid: "AnalysisInputError",
@@ -40,40 +39,51 @@ it("returns actionable unavailable without creating a workspace when tools are a
});
});
-it("reports an unavailable private runtime root without a misleading WABT remediation", async () => {
- const toolRoot = await createTestTempDirectory("rea-wabt-tool-root-");
- for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"])
- await writeFile(
- join(toolRoot, tool + (process.platform === "win32" ? ".exe" : "")),
- "source-owned executable seam",
- { mode: 0o700 },
- );
- const provider = new WabtArtifactProvider(
- { REA_WABT_BIN_DIRECTORY: toolRoot },
- undefined,
- () => {
- throw new PrivateRuntimeRootUnavailableError(
- "REA Windows native controls are unavailable: probe failure",
+it.each([false, true])(
+ "reports an unavailable private runtime root with retryable=%s without a misleading WABT remediation",
+ async (retryable) => {
+ const toolRoot = await createTestTempDirectory("rea-wabt-tool-root-");
+ for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"])
+ await writeFile(
+ join(toolRoot, tool + (process.platform === "win32" ? ".exe" : "")),
+ "source-owned executable seam",
+ { mode: 0o700 },
);
- },
- );
- expect(
- await provider.inspect({
+ const provider = new WabtArtifactProvider(
+ { REA_WABT_BIN_DIRECTORY: toolRoot },
+ undefined,
+ () => {
+ throw new PrivateRuntimeRootUnavailableError(
+ "REA Windows native controls are unavailable: probe failure",
+ { retryable },
+ );
+ },
+ );
+ const result = await provider.inspect({
path: "/module.wasm",
glue_paths: [],
candidate_paths: [],
- }),
- ).toMatchObject({
- ok: false,
- error: {
- _tag: "AnalysisCapabilityUnavailableError",
- userMessage: undefined,
- message: expect.stringContaining(
- "REA Windows native controls are unavailable",
- ),
- },
- });
-});
+ });
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "AnalysisCapabilityUnavailableError",
+ retryable,
+ userMessage: undefined,
+ message: expect.stringContaining(
+ "REA Windows native controls are unavailable",
+ ),
+ },
+ });
+ if (result.ok)
+ throw new Error("Unavailable runtime must refuse inspection");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "capability_unavailable",
+ retryable,
+ category: retryable ? "unavailable" : "unsupported_provider",
+ });
+ },
+);
it.each([
"success",
"invalid",
diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts
index ef5692f1b..4f386998f 100644
--- a/src/wasm/WabtFailures.ts
+++ b/src/wasm/WabtFailures.ts
@@ -11,7 +11,7 @@ import {
} from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
-import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import { WABT_LIMITS, WABT_PROVIDER_IDENTITY } from "./WabtRelease.js";
/** Preserve lifecycle failures and diagnostic bytes instead of returning partial success. */
export const wabtFailure = (
@@ -76,7 +76,7 @@ export const wabtFailure = (
WABT_PROVIDER_IDENTITY.id,
"inspect_wasm_artifact",
reason,
- { cause },
+ { cause, retryable: cause.retryable },
);
if (phase === "configuration")
return new AnalysisCapabilityUnavailableError(
diff --git a/src/windows/WindowsPrivateRuntime.ts b/src/windows/WindowsPrivateRuntime.ts
index e17544a49..2c361a6d0 100644
--- a/src/windows/WindowsPrivateRuntime.ts
+++ b/src/windows/WindowsPrivateRuntime.ts
@@ -1,6 +1,7 @@
import { win32 } from "node:path";
import { z } from "zod";
+import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import {
requireWindowsNativeAuthority,
@@ -23,6 +24,13 @@ const fileSchema = windowsFileIdentitySchema.extend({
handle: windowsHandleSchema,
});
const runtimes = new Map();
+// Retry only a proven pre-allocation sharing/lock conflict, never opaque text
+// or an operation that may already own an unpublished runtime directory.
+const transientRuntimeAdmissionSchema = z.object({
+ code: z.literal("ERR_REA_WINDOWS_NATIVE"),
+ win32Code: z.union([z.literal(32), z.literal(33)]),
+ runtimeAllocation: z.literal("not-started"),
+});
/** Retain private DACL and path handles until native recursive cleanup settles. */
export class WindowsPrivateRuntime {
@@ -40,9 +48,21 @@ export class WindowsPrivateRuntime {
/** Create a new directory with its private DACL installed at creation. */
static create(parent: string, prefix: string): WindowsPrivateRuntime {
const authority = requireWindowsNativeAuthority();
- const observation = runtimeSchema.parse(
- authority.call("runtime_create", [parent, prefix]),
- );
+ let observed: unknown;
+ try {
+ observed = authority.call("runtime_create", [parent, prefix]);
+ } catch (cause: unknown) {
+ if (
+ cause instanceof Error &&
+ transientRuntimeAdmissionSchema.safeParse(cause).success
+ )
+ throw new PrivateRuntimeRootUnavailableError(cause.message, {
+ cause,
+ retryable: true,
+ });
+ throw cause;
+ }
+ const observation = runtimeSchema.parse(observed);
const runtime = new WindowsPrivateRuntime(observation, authority);
runtimes.set(windowsRuntimeIdentityKey(observation), runtime);
return runtime;
diff --git a/tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts b/tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts
new file mode 100644
index 000000000..753a3bd47
--- /dev/null
+++ b/tests/boundary/javascript/sourceMapRuntimeAvailability.test.ts
@@ -0,0 +1,35 @@
+import { expect, it } from "vitest";
+import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
+import { runSourceMapCommand } from "../../../src/javascript/sourceMaps/SourceMapCommand.js";
+import { PrivateRuntimeRootUnavailableError } from "../../../src/process/PrivateRuntimeRootUnavailableError.js";
+
+it.each([false, true])(
+ "preserves private-runtime retryable=%s through the source-map operation",
+ async (retryable) => {
+ const reason = "Native runtime self-test failed with a sharing violation";
+ let launches = 0;
+ const result = await runSourceMapCommand("{}", "/selected.map", undefined, {
+ prepareOwnership: async () => {},
+ createRuntime: async () => {
+ throw new PrivateRuntimeRootUnavailableError(reason, { retryable });
+ },
+ launcher: async () => {
+ launches += 1;
+ throw new Error("A refused runtime must not launch a codec");
+ },
+ });
+ expect(launches).toBe(0);
+ if (result.ok) throw new Error("Unavailable runtime must refuse decoding");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "capability_unavailable",
+ category: retryable ? "unavailable" : "unsupported_provider",
+ retryable,
+ details: {
+ provider_id: "source-map-decoder",
+ operation: "trace_web_source_location",
+ reason,
+ },
+ });
+ expect(result.error.cleanupIncomplete).toBe(false);
+ },
+);
diff --git a/tests/process-global/windowsRuntimeAdmission.test.ts b/tests/process-global/windowsRuntimeAdmission.test.ts
new file mode 100644
index 000000000..a9f328a18
--- /dev/null
+++ b/tests/process-global/windowsRuntimeAdmission.test.ts
@@ -0,0 +1,99 @@
+import { beforeEach, expect, it, vi } from "vitest";
+import { WindowsPrivateRuntime } from "../../src/windows/WindowsPrivateRuntime.js";
+import { PrivateRuntimeRootUnavailableError } from "../../src/process/PrivateRuntimeRootUnavailableError.js";
+
+const { nativeCall } = vi.hoisted(() => ({ nativeCall: vi.fn() }));
+vi.mock("../../src/windows/WindowsNativeLoader.js", async (original) => ({
+ ...(await original<
+ typeof import("../../src/windows/WindowsNativeLoader.js")
+ >()),
+ requireWindowsNativeAuthority: () => ({ call: nativeCall }),
+}));
+beforeEach(() => {
+ nativeCall.mockReset();
+});
+
+it.each([
+ {
+ label: "sharing conflict before allocation",
+ win32Code: 32,
+ runtimeAllocation: "not-started",
+ retryable: true,
+ },
+ {
+ label: "lock conflict before allocation",
+ win32Code: 33,
+ runtimeAllocation: "not-started",
+ retryable: true,
+ },
+ { label: "unknown allocation", win32Code: 32, retryable: false },
+ {
+ label: "possible allocation",
+ win32Code: 32,
+ runtimeAllocation: "started",
+ retryable: false,
+ },
+ {
+ label: "permission failure",
+ win32Code: 5,
+ runtimeAllocation: "not-started",
+ retryable: false,
+ },
+ {
+ label: "string code",
+ win32Code: "32",
+ runtimeAllocation: "not-started",
+ retryable: false,
+ },
+ {
+ label: "inconsistent error code",
+ win32Code: 32,
+ code: "EACCES",
+ runtimeAllocation: "not-started",
+ retryable: false,
+ },
+])(
+ "retains $label without guessing runtime ownership",
+ ({ retryable, label: _label, ...facts }) => {
+ const cause = Object.assign(new Error("Selected native diagnostic"), {
+ code: "ERR_REA_WINDOWS_NATIVE",
+ ...facts,
+ });
+ nativeCall.mockImplementation(() => {
+ throw cause;
+ });
+ let observed: unknown;
+ try {
+ WindowsPrivateRuntime.create("D:/selected parent", "selected-prefix-");
+ } catch (failure: unknown) {
+ observed = failure;
+ }
+ expect(nativeCall).toHaveBeenCalledTimes(1);
+ expect(nativeCall).toHaveBeenCalledWith("runtime_create", [
+ "D:/selected parent",
+ "selected-prefix-",
+ ]);
+ if (retryable) {
+ expect(observed).toBeInstanceOf(PrivateRuntimeRootUnavailableError);
+ expect(observed).toMatchObject({
+ message: cause.message,
+ cause,
+ retryable: true,
+ });
+ } else expect(observed).toBe(cause);
+ },
+);
+
+it("does not infer a transient status from a human-readable diagnostic", () => {
+ const cause = new Error("Open Windows path component failed (Win32 32)");
+ nativeCall.mockImplementation(() => {
+ throw cause;
+ });
+ let observed: unknown;
+ try {
+ WindowsPrivateRuntime.create("D:/selected parent", "selected-prefix-");
+ } catch (failure: unknown) {
+ observed = failure;
+ }
+ expect(observed).toBe(cause);
+});
From a6efa4fcefeffcf5ff5310b8154988b4be30c349 Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:00:04 +0800
Subject: [PATCH 006/159] test(mcp): reuse advertised schema in inventory check
(#2469)
Reuse the current advertised binary_session definition for real SDK reply
validation while preserving all inventory assertions and the 10-second deadline.
Avoid initializing validators for unrelated tools in this correctness check.
Closes #2453.
Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
---
tests/boundary/mcp/serverWorkflowMcp.test.ts | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/tests/boundary/mcp/serverWorkflowMcp.test.ts b/tests/boundary/mcp/serverWorkflowMcp.test.ts
index c72fca5cb..f25027b2a 100644
--- a/tests/boundary/mcp/serverWorkflowMcp.test.ts
+++ b/tests/boundary/mcp/serverWorkflowMcp.test.ts
@@ -122,11 +122,18 @@ it("advertises the complete currently available inventory with a session", async
const listed = await client.listTools();
const names = listed.tools.map((t) => t.name);
+ const sessionTool = listed.tools.find(
+ ({ name }) => name === "binary_session",
+ );
+ if (sessionTool === undefined)
+ throw new Error("Missing advertised binary_session tool");
+ // Validate this reply with its current advertised schema. The SDK otherwise
+ // builds output validators for the entire catalog on the first cached call.
const status = structured(
- await client.callTool({
- name: "binary_session",
- arguments: {},
- }),
+ await client.callTool(
+ { name: "binary_session", arguments: {} },
+ { toolDefinition: sessionTool },
+ ),
);
const inventory = expandToolAvailability(
toolContract("binary_session").outputSchema.parse(status).result
From a86deecac5fa8434a4f50482f86b0c6ab486a936 Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:06:41 +0800
Subject: [PATCH 007/159] fix: align MCP archive contracts and documentation
(#2478)
Reuse the domain ZIP format list for session schemas, verify full Harmony
CLI/MCP lifecycles, and correct native view guidance and audited docs drift.
Refresh locked dependency notices and gate inventory/provenance consistency,
including notice inputs in the cached documentation gate.
Fixes #2454
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---
.../references/native-and-artifacts.md | 5 +-
.gitattributes | 3 +
SECURITY.md | 9 +-
THIRD_PARTY_NOTICES.md | 7 +
docs/adb-device-analysis.md | 4 +-
docs/client-configuration.md | 5 +
docs/ghidra-riscv.md | 20 +-
docs/installation.md | 12 +-
docs/javascript-runtime-reconciliation.md | 19 +-
docs/managed-code-analysis.md | 4 +-
docs/raw-binary.md | 2 +-
docs/releasing.md | 9 +
licenses/LGPL-2.0.txt | 480 +++++
licenses/README.md | 40 +-
licenses/frida-17.23.2-COPYING.txt | 50 +
licenses/npm-dependencies.txt | 129 +-
licenses/npm-runtime-notices.txt | 1589 ++++++++++++++++-
licenses/upstream-sources.json | 10 +
package.json | 6 +-
scripts/generate-third-party-inventory.mjs | 120 ++
scripts/verify/harmony/analysis.mjs | 45 +-
src/contracts/analysisViewToolContracts.ts | 2 +-
src/contracts/toolOutputSchemaPrimitives.ts | 7 +-
src/domain/binaryTargetTypes.ts | 15 +-
.../acceptance/harmonyArtifactJourney.test.ts | 135 ++
.../filesystem/thirdPartyInventory.test.ts | 117 ++
tests/boundary/mcp/analysisViewMcp.test.ts | 24 +
turbo.json | 1 +
28 files changed, 2798 insertions(+), 71 deletions(-)
create mode 100644 licenses/LGPL-2.0.txt
create mode 100644 licenses/frida-17.23.2-COPYING.txt
create mode 100644 scripts/generate-third-party-inventory.mjs
create mode 100644 tests/acceptance/harmonyArtifactJourney.test.ts
create mode 100644 tests/boundary/filesystem/thirdPartyInventory.test.ts
diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md
index e43049e36..58c1671b6 100644
--- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md
+++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md
@@ -194,8 +194,9 @@ every executable in the bundle. It also lists missing or weak loads and earlier
`architecture`. System paths stay undetermined because the dyld shared cache
provides them.
-Use `extract_artifact` when materialized files are needed. It takes no arguments
-and materializes all regular files into a fresh temporary directory chosen by
+Use `extract_artifact` when materialized files are needed. It extracts the active
+target and accepts optional `integrity_policy`; see the [MCP contracts](https://github.com/morluto/rea/blob/main/docs/mcp-contracts.md).
+It materializes all regular files into a fresh temporary directory chosen by
REA. Symlinks and encrypted entries are inventory facts, not extractable files.
The result reports the materialized directory; callers do not choose the
destination. No separate permission grant is needed.
diff --git a/.gitattributes b/.gitattributes
index 1774c0113..b8247b27f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,3 +3,6 @@
# Windows command fixtures retain their native line endings.
*.cmd text eol=crlf
+
+# Preserve whitespace in original upstream licensing texts.
+licenses/npm-runtime-notices.txt whitespace=-blank-at-eol
diff --git a/SECURITY.md b/SECURITY.md
index 56d9ca784..1f3d73f8e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -24,9 +24,12 @@ provider-dependent behavior to be reproduced safely.
## Security boundary
-REA authenticates each local provider bridge session with a random capability
-token and a current-user Unix socket. Tokens are transferred through private
-session descriptors rather than process arguments or environment variables.
+REA authenticates its Hopper and Ghidra bridges with per-session random
+capability tokens. POSIX uses current-user Unix sockets; Windows Ghidra uses
+authenticated loopback TCP with a private NTFS runtime whose DACL admits only
+the current user and SYSTEM. See [Windows Ghidra P0](docs/windows-ghidra-p0.md#private-runtime-transport-and-lifecycle).
+Tokens are transferred through private session descriptors rather than process
+arguments or environment variables.
This is not a sandbox and does not protect against malicious processes already
running as the same operating-system user. Opening an untrusted binary
delegates parsing and analysis to the selected local provider with that user's
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 3faad0dba..5fceaaa47 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -28,6 +28,13 @@ entries preserve the declared license and author metadata and include a clearly
labeled standard license text. These are documented upstream notice gaps, not
recovered copyright notices or a claim that attribution has been fully verified.
+The optional `frida@17.23.2` npm archive and tagged Node binding source also
+omit a standalone license/copyright document. Its declared
+`LGPL-2.0 WITH WxWindows-exception-3.1` license is recorded with unchanged
+[Frida licensing text](licenses/frida-17.23.2-COPYING.txt) and the
+[GNU Library GPL v2](licenses/LGPL-2.0.txt). These are licensing supplements,
+not recovered Node binding or prebuild-component copyright notices.
+
## Open-source engine credits and licenses
We thank the maintainers and contributors of the projects below. Their
diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md
index e5a95c536..12fd77cd2 100644
--- a/docs/adb-device-analysis.md
+++ b/docs/adb-device-analysis.md
@@ -136,8 +136,8 @@ existing analysis families directly, for example
## Resource and lifecycle limits
- Caller values reach adb only as positional arguments in fixed vectors;
- no tool accepts an arbitrary shell command line, and UI interaction
- (`input`, `am`, `monkey`) is deliberately not exposed.
+ no tool accepts an arbitrary shell command line, `input`, or `monkey`.
+ The app/activity lifecycle tools use `am start -W` and `am force-stop`.
- Per-invocation budgets: client/devices 10-20 s, shell reads 20-60 s with
64 KiB-8 MiB capture caps (logcat 4 MiB, screen 32 MiB), transfers and
bugreports 600 s. Exceeded budgets report `resource_constraint`; stopped
diff --git a/docs/client-configuration.md b/docs/client-configuration.md
index a6e254488..00e83e179 100644
--- a/docs/client-configuration.md
+++ b/docs/client-configuration.md
@@ -101,6 +101,11 @@ Setup and doctor follow Qwen Code's tilde and working-directory-relative home
overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
where setup installs the bundled workflow.
+## Antigravity
+
+Antigravity 2.0 discovers personal skills in `~/.gemini/config/skills`.
+Setup installs its selected skill copy there.
+
## Gemini CLI
Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override
diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md
index e180122d7..5b3407361 100644
--- a/docs/ghidra-riscv.md
+++ b/docs/ghidra-riscv.md
@@ -22,16 +22,16 @@ header declarations, not proof that every instruction conforms to that ISA.
Provider selection returns the specific unsupported constraint rather than
guessing a compatible profile:
-| Declaration | Outcome |
-| ----------------------------------------------- | --------------------------------------------------------------------------- |
-| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` |
-| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` |
-| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language |
-| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value |
-| `EF_RISCV_RVE` | refused; the embedded 15-register ABI needs its own processor specification |
-| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here |
-| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float |
-| Reserved or newer `e_flags` bits | refused with the offending bits named |
+| Declaration | Outcome |
+| ----------------------------------------------- | ---------------------------------------------------------------------------------------- |
+| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` |
+| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` |
+| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language |
+| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value |
+| `EF_RISCV_RVE` | refused; the embedded 16-register profile (x0–x15) needs its own processor specification |
+| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here |
+| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float |
+| Reserved or newer `e_flags` bits | refused with the offending bits named |
`e_type` is the only load-shape declaration this lane reads. A fixed-address
`ET_EXEC` declaration does **not** establish static linkage: `PT_INTERP` and
diff --git a/docs/installation.md b/docs/installation.md
index 02ae1c0af..b9285a7d4 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -101,13 +101,11 @@ rea setup --yes --all-detected --install-hopper --json
### Skill destinations
-The bundled skill is installed where each selected client discovers personal
-skills: Claude Code uses `~/.claude/skills` (or
-`$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills`
-under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`.
-Other integrations, including Qwen Code, use `~/.agents/skills`.
-A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
-directory. Setup leaves existing skill copies in other locations untouched.
+The bundled skill is installed in each selected client's personal skill directory.
+See [client configuration](client-configuration.md) for client-specific roots,
+profiles, and environment overrides. A mixed selection plans each distinct
+destination. Selecting the skill without a client uses `~/.agents/skills`.
+Setup leaves existing skill copies in other locations untouched.
`rea setup --skill-client claude_code --yes --json` refreshes and verifies the
selected skill destination independently of MCP registration selection. The
diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md
index af59e825b..a0a600574 100644
--- a/docs/javascript-runtime-reconciliation.md
+++ b/docs/javascript-runtime-reconciliation.md
@@ -1,28 +1,31 @@
# JavaScript static/runtime reconciliation
REA can combine an existing static JavaScript Application Graph with one or
-more passive browser, Electron page, or Node/Electron V8 Inspector captures without collapsing their different
-authorities. The `reconcile_javascript_runtime` MCP tool and
+more browser, Electron, or Node/Electron V8 Inspector observations while
+preserving their different authorities. The `reconcile_javascript_runtime` MCP tool and
`rea reconcile-javascript-runtime` CLI command accept Evidence records and
return derived Evidence containing a combined JavaScript Application Graph
and explicit match, ambiguity, mismatch, and unknown classifications.
The operation performs no filesystem, browser, or Electron I/O. It accepts only
semantically verified Evidence produced by `analyze_javascript_application`,
-`inspect_web_page`, `inspect_electron_page`, or
-`observe_javascript_runtime`; matching operation names alone
+`inspect_web_page`, `inspect_electron_page`, `observe_javascript_runtime`, or
+`capture_electron_scenario`; matching operation names alone
is insufficient. Provider identity, predicate type, subject digest, target
parameters, and normalized-result schemas are checked before reconciliation.
+`capture_electron_scenario` supplies target-only partial observations. It does
+not establish loaded-script coverage; see [Electron observation](electron-observation.md).
+
## Workflow
1. Run `analyze_javascript_application` against exactly one application layer.
Optional cache or assets directories can be analyzed independently and
supplied as additional layers.
2. Run `inspect_web_page`, `inspect_electron_page`, or the attach-only
- `observe_javascript_runtime`. Capturing script sources
- is optional, but a source capture provides the strongest byte
- identity.
+ `observe_javascript_runtime`, or supply existing `capture_electron_scenario`
+ Evidence with the partial scope above. Capturing script sources is optional,
+ but a source capture provides the strongest byte identity.
3. Pass the resulting Evidence records to `reconcile_javascript_runtime`.
Browser URLs normally need an explicit URL-prefix mapping. Extracted
Electron and V8 Inspector file locations are mapped automatically when runtime files remain
@@ -104,7 +107,7 @@ nodes. REA evaluates compatible static candidates conservatively:
Every successful cross-layer `observed_as` edge has
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
-artifact/AST observations and passive-CDP observations remain unchanged and
+artifact/AST observations and runtime observations remain unchanged and
retain their own Evidence links.
## Loaded, resident, and not observed
diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md
index 69da49e9f..060b44bc2 100644
--- a/docs/managed-code-analysis.md
+++ b/docs/managed-code-analysis.md
@@ -151,7 +151,7 @@ assembly. Older member Evidence without `selection` remains readable.
Start by identifying the artifact with `inspect_managed_artifact`, then inspect
its types and CIL with `inspect_managed_members`. Use [member comparison](#member-comparison)
-for another build, and [managed/native boundary rules](#managednative-boundary-rules)
+for another build, and [managed/native boundary rules](#managed-native-boundaries)
when matching declared native calls to supplied native Evidence. Static inspection
does not load or execute the assembly. Reconstructed structure and unresolved
native/runtime questions retain their own authority and coverage.
@@ -220,6 +220,8 @@ to native function addresses requires verified
provider evidence; names or vtable similarity alone do not prove that a native
function implements a specific managed method.
+
+
## Managed/native boundary rules
`verify_managed_native_boundaries` compares declared P/Invoke module and entry
diff --git a/docs/raw-binary.md b/docs/raw-binary.md
index 43fcd54f4..8e277568b 100644
--- a/docs/raw-binary.md
+++ b/docs/raw-binary.md
@@ -61,7 +61,7 @@ Independent raw verification streams the complete private snapshot digest and
extent with bounded memory and supports cancellation during the read.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
-PE applications only.
+native PE applications and DLLs only; raw-binary targets are unsupported.
## Limitations
diff --git a/docs/releasing.md b/docs/releasing.md
index 37a28d532..dd3a98be3 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -218,6 +218,15 @@ release baseline and can propose an already-published version again.
After publication, open a PR from the release branch back to main. Preserve
main's later implementation changes and regenerate ignored build outputs from
the combined contracts with the released package version.
+Before merging, check whether GitHub permits merge commits:
+
+```bash
+gh api repos/morluto/rea --jq .allow_merge_commit
+```
+
+If disabled, a maintainer must arrange an approved ancestry-preserving merge
+path before synchronization can finish. Squash and rebase merges do not put the
+released tag in main's ancestry. Do not substitute them for this step.
Review and test this synchronization PR, then use a merge commit so the release
tag remains in main's ancestry. Keep the released tag unchanged.
diff --git a/licenses/LGPL-2.0.txt b/licenses/LGPL-2.0.txt
new file mode 100644
index 000000000..ede5d5441
--- /dev/null
+++ b/licenses/LGPL-2.0.txt
@@ -0,0 +1,480 @@
+ GNU LIBRARY GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1991 Free Software Foundation, Inc.
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the library GPL. It is
+ numbered 2 because it goes with version 2 of the ordinary GPL.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Library General Public License, applies to some
+specially designated Free Software Foundation software, and to any
+other libraries whose authors decide to use it. You can use it for
+your libraries, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if
+you distribute copies of the library, or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link a program with the library, you must provide
+complete object files to the recipients so that they can relink them
+with the library, after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ Our method of protecting your rights has two steps: (1) copyright
+the library, and (2) offer you this license which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ Also, for each distributor's protection, we want to make certain
+that everyone understands that there is no warranty for this free
+library. If the library is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original
+version, so that any problems introduced by others will not reflect on
+the original authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that companies distributing free
+software will individually obtain patent licenses, thus in effect
+transforming the program into proprietary software. To prevent this,
+we have made it clear that any patent must be licensed for everyone's
+free use or not licensed at all.
+
+ Most GNU software, including some libraries, is covered by the ordinary
+GNU General Public License, which was designed for utility programs. This
+license, the GNU Library General Public License, applies to certain
+designated libraries. This license is quite different from the ordinary
+one; be sure to read it in full, and don't assume that anything in it is
+the same as in the ordinary license.
+
+ The reason we have a separate public license for some libraries is that
+they blur the distinction we usually make between modifying or adding to a
+program and simply using it. Linking a program with a library, without
+changing the library, is in some sense simply using the library, and is
+analogous to running a utility program or application program. However, in
+a textual and legal sense, the linked executable is a combined work, a
+derivative of the original library, and the ordinary General Public License
+treats it as such.
+
+ Because of this blurred distinction, using the ordinary General
+Public License for libraries did not effectively promote software
+sharing, because most developers did not use the libraries. We
+concluded that weaker conditions might promote sharing better.
+
+ However, unrestricted linking of non-free programs would deprive the
+users of those programs of all benefit from the free status of the
+libraries themselves. This Library General Public License is intended to
+permit developers of non-free programs to use free libraries, while
+preserving your freedom as a user of such programs to change the free
+libraries that are incorporated in them. (We have not seen how to achieve
+this as regards changes in header files, but we have achieved it as regards
+changes in the actual functions of the Library.) The hope is that this
+will lead to faster development of free libraries.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, while the latter only
+works together with the library.
+
+ Note that it is possible for a library to be covered by the ordinary
+General Public License rather than by this special one.
+
+ GNU LIBRARY GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library which
+contains a notice placed by the copyright holder or other authorized
+party saying it may be distributed under the terms of this Library
+General Public License (also called "this License"). Each licensee is
+addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also compile or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ c) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ d) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the source code distributed need not include anything that is normally
+distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Library General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Library General Public
+ License as published by the Free Software Foundation; either
+ version 2 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Library General Public License for more details.
+
+ You should have received a copy of the GNU Library General Public
+ License along with this library; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Moe Ghoul, President of Vice
+
+That's all there is to it!
diff --git a/licenses/README.md b/licenses/README.md
index db0c9d05e..c811a4f76 100644
--- a/licenses/README.md
+++ b/licenses/README.md
@@ -4,19 +4,20 @@ Start with [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md) for the distincti
between distributed material and separately obtained tools. These files retain
upstream texts; they do not change [REA's MIT license](../LICENSE).
-| Files | Provenance and scope |
-| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `npm-dependencies.txt` | Every package path in the root lockfile, with its exact version, declared license, scope, download URL, and integrity. Includes development and optional platform entries. |
-| `npm-runtime-notices.txt` | Original license, copyright, and NOTICE texts collected from integrity-verified locked runtime npm archives across all platforms; also the license of Rolldown's generated runtime helpers. The archive manifest's name and version are checked. |
-| `nativeaot-parser-MIT.txt` | Original Washi notice for derived NativeAOT parser code; source revision is recorded in the parser and the root notices. |
-| `dotnet-runtime-MIT.txt` | Original .NET Foundation MIT text from the NativeAOT reference revision `a3b2d40328be0be3f8dd43f0e5cc925b8827b044`. |
-| `ghidra-12.1.4-{LICENSE,NOTICE,DISCLAIMER}.txt` | Unchanged top-level documents from `Ghidra_12.1.4_build`. Ghidra is not bundled; these reference copies do not replace its component and GPL support-program notices. The US-government-specific disclaimer applies to Ghidra, not to REA. |
-| `cfworker-MIT.txt`, `xterm-MIT.txt` | Original notices missing from their published runtime npm archives, recovered from the releases' upstream Git revisions. They are included in the corresponding runtime entries. |
-| `Apache-2.0.txt`, `MIT-template.txt` | Standard license texts for explicitly documented upstream notice gaps. The MIT placeholders are not an attribution to an inferred author or year. |
-| `MinGW-w64-runtime.txt` | Upstream's combined runtime copyright and license notices, snapshot from v12.0.0, for the statically linked Windows addon. This is a notice snapshot, not a pin of the compiler used in release CI. |
-| `GPL-3.0.txt`, `GCC-Runtime-Library-Exception-3.1.txt` | Unchanged GPLv3 and GCC runtime exception texts from GCC 14.2.0. The exception permits eligible compiled combinations under its conditions; the runtime keeps its own license. This snapshot does not assert that every release uses GCC 14.2.0. |
-| `Node-22.19.0-LICENSE.txt` | The original Node 22.19.0 license and third-party notices. The Windows addon uses Node-API headers from that release; the complete Node runtime and all components listed in this reference document are not bundled. |
-| `upstream-sources.json` | Source URLs and SHA-256 digests for the new unchanged reference and supplemental texts. These digests identify the preserved local texts. |
+| Files | Provenance and scope |
+| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `npm-dependencies.txt` | Every package path in the root lockfile, with its exact version, declared license, scope, download URL, and integrity. Includes development and optional platform entries. |
+| `npm-runtime-notices.txt` | Original license, copyright, and NOTICE texts collected from integrity-verified locked runtime npm archives across all platforms; also the license of Rolldown's generated runtime helpers. The archive manifest's name and version are checked. |
+| `nativeaot-parser-MIT.txt` | Original Washi notice for derived NativeAOT parser code; source revision is recorded in the parser and the root notices. |
+| `dotnet-runtime-MIT.txt` | Original .NET Foundation MIT text from the NativeAOT reference revision `a3b2d40328be0be3f8dd43f0e5cc925b8827b044`. |
+| `ghidra-12.1.4-{LICENSE,NOTICE,DISCLAIMER}.txt` | Unchanged top-level documents from `Ghidra_12.1.4_build`. Ghidra is not bundled; these reference copies do not replace its component and GPL support-program notices. The US-government-specific disclaimer applies to Ghidra, not to REA. |
+| `cfworker-MIT.txt`, `xterm-MIT.txt` | Original notices missing from their published runtime npm archives, recovered from the releases' upstream Git revisions. They are included in the corresponding runtime entries. |
+| `Apache-2.0.txt`, `MIT-template.txt` | Standard license texts for explicitly documented upstream notice gaps. The MIT placeholders are not an attribution to an inferred author or year. |
+| `frida-17.23.2-COPYING.txt`, `LGPL-2.0.txt` | Unchanged licensing supplements for the optional Frida dependency: tagged Frida wxWindows notice and GNU Library GPL v2 text. The npm archive and tagged Node binding omit a standalone license/copyright document; these texts do not recover binding or prebuild-component attribution. |
+| `MinGW-w64-runtime.txt` | Upstream's combined runtime copyright and license notices, snapshot from v12.0.0, for the statically linked Windows addon. This is a notice snapshot, not a pin of the compiler used in release CI. |
+| `GPL-3.0.txt`, `GCC-Runtime-Library-Exception-3.1.txt` | Unchanged GPLv3 and GCC runtime exception texts from GCC 14.2.0. The exception permits eligible compiled combinations under its conditions; the runtime keeps its own license. This snapshot does not assert that every release uses GCC 14.2.0. |
+| `Node-22.19.0-LICENSE.txt` | The original Node 22.19.0 license and third-party notices. The Windows addon uses Node-API headers from that release; the complete Node runtime and all components listed in this reference document are not bundled. |
+| `upstream-sources.json` | Source URLs and SHA-256 digests for the new unchanged reference and supplemental texts. These digests identify the preserved local texts. |
Additional preserved originals live at
[evmole/LICENSE](../third_party/evmole/LICENSE),
@@ -40,6 +41,10 @@ only license and author metadata: their missing original copyright documents
remain explicitly identified in the runtime texts. Do not describe those
entries as complete recovered attribution.
+`frida@17.23.2` also lacks an original standalone notice in its npm archive and
+tagged Node source. Its declared license and the preserved licensing supplements
+are recorded explicitly without inferring a copyright holder or year.
+
These records do not establish the legal accuracy of upstream metadata or
prove that every upstream source-file attribution has been supplied in an
archive. Review and refresh them when dependencies change; compare reference
@@ -52,3 +57,12 @@ runtime/component notices with the retained snapshots and update them if they
differ. Do not assume that a compiler change leaves redistribution requirements
unchanged. Preserve applicable originals with any additionally bundled engine;
top-level reference copies alone may not cover its entire distribution.
+
+Generate the exact locked inventory with `npm run licenses:generate`. After
+refreshing verified runtime notices, update their header fingerprint to the
+generator's reported `runtime_dependency_fingerprint`, then run
+`npm run licenses:check`; `docs:check` also runs this check. It compares the
+inventory, runtime package identities,
+license declarations, archive URLs/integrity, runtime dependency fingerprint,
+and preserved reference digests. It does not verify legal completeness or
+replace inspection of original upstream notices.
diff --git a/licenses/frida-17.23.2-COPYING.txt b/licenses/frida-17.23.2-COPYING.txt
new file mode 100644
index 000000000..b01d49c67
--- /dev/null
+++ b/licenses/frida-17.23.2-COPYING.txt
@@ -0,0 +1,50 @@
+ wxWindows Library Licence, Version 3.1
+ ======================================
+
+Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al
+
+Everyone is permitted to copy and distribute verbatim copies
+of this licence document, but changing it is not allowed.
+
+ WXWINDOWS LIBRARY LICENCE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+This library is free software; you can redistribute it and/or modify it
+under the terms of the GNU Library General Public Licence as published by
+the Free Software Foundation; either version 2 of the Licence, or (at your
+option) any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public
+Licence for more details.
+
+You should have received a copy of the GNU Library General Public Licence
+along with this software, usually in a file named COPYING.LIB. If not,
+write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth
+Floor, Boston, MA 02110-1301 USA.
+
+EXCEPTION NOTICE
+
+1. As a special exception, the copyright holders of this library give
+permission for additional uses of the text contained in this release of the
+library as licenced under the wxWindows Library Licence, applying either
+version 3.1 of the Licence, or (at your option) any later version of the
+Licence as published by the copyright holders of version 3.1 of the Licence
+document.
+
+2. The exception is that you may use, copy, link, modify and distribute
+under your own terms, binary object code versions of works based on the
+Library.
+
+3. If you copy code from files distributed under the terms of the GNU
+General Public Licence or the GNU Library General Public Licence into a
+copy of this library, as this licence permits, the exception does not apply
+to the code that you add in this way. To avoid misleading anyone as to the
+status of such modified files, you must delete this exception notice from
+such code and/or adjust the licensing conditions notice accordingly.
+
+4. If you write modifications of your own for this library, it is your
+choice whether to permit this exception to apply to your modifications. If
+you do not wish that, you must delete the exception notice from such code
+and/or adjust the licensing conditions notice accordingly.
diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt
index 7ae0aa0ad..ce45f7459 100644
--- a/licenses/npm-dependencies.txt
+++ b/licenses/npm-dependencies.txt
@@ -2,7 +2,7 @@ REA npm dependency inventory
Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.
License identifiers are upstream lockfile declarations, not a legal assessment.
Runtime includes transitive and optional platform packages; development includes build, test and website tooling.
-Locked package entries: 561; runtime: 148.
+Locked package entries: 586; runtime: 174.
Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.
@algolia/abtesting@1.25.0 | development | MIT
@@ -1375,11 +1375,31 @@ base64-js@1.5.1 | runtime | MIT
Package: https://www.npmjs.com/package/base64-js/v/1.5.1
Archive: https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz
Integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+bindings@1.5.0 | runtime, optional | MIT
+ Lockfile path: node_modules/bindings
+ Package: https://www.npmjs.com/package/bindings/v/1.5.0
+ Archive: https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz
+ Integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
birpc@2.9.0 | development | MIT
Lockfile path: node_modules/birpc
Package: https://www.npmjs.com/package/birpc/v/2.9.0
Archive: https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz
Integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==
+bl@4.1.0 | runtime, optional | MIT
+ Lockfile path: node_modules/bl
+ Package: https://www.npmjs.com/package/bl/v/4.1.0
+ Archive: https://registry.npmjs.org/bl/-/bl-4.1.0.tgz
+ Integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+buffer@5.7.1 | runtime, optional | MIT
+ Lockfile path: node_modules/bl/node_modules/buffer
+ Package: https://www.npmjs.com/package/buffer/v/5.7.1
+ Archive: https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz
+ Integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+readable-stream@3.6.2 | runtime, optional | MIT
+ Lockfile path: node_modules/bl/node_modules/readable-stream
+ Package: https://www.npmjs.com/package/readable-stream/v/3.6.2
+ Archive: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz
+ Integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
brace-expansion@5.0.12 | runtime | MIT
Lockfile path: node_modules/brace-expansion
Package: https://www.npmjs.com/package/brace-expansion/v/5.0.12
@@ -1430,6 +1450,11 @@ character-entities-legacy@3.0.0 | development | MIT
Package: https://www.npmjs.com/package/character-entities-legacy/v/3.0.0
Archive: https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz
Integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
+chownr@1.1.4 | runtime, optional | ISC
+ Lockfile path: node_modules/chownr
+ Package: https://www.npmjs.com/package/chownr/v/1.1.4
+ Archive: https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz
+ Integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
clean-git-ref@2.0.1 | runtime | Apache-2.0
Lockfile path: node_modules/clean-git-ref
Package: https://www.npmjs.com/package/clean-git-ref/v/2.0.1
@@ -1485,6 +1510,11 @@ decompress-response@6.0.0 | runtime | MIT
Package: https://www.npmjs.com/package/decompress-response/v/6.0.0
Archive: https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz
Integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==
+deep-extend@0.6.0 | runtime, optional | MIT
+ Lockfile path: node_modules/deep-extend
+ Package: https://www.npmjs.com/package/deep-extend/v/0.6.0
+ Archive: https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz
+ Integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
define-data-property@1.1.4 | runtime | MIT
Lockfile path: node_modules/define-data-property
Package: https://www.npmjs.com/package/define-data-property/v/1.1.4
@@ -1495,7 +1525,7 @@ dequal@2.0.3 | development | MIT
Package: https://www.npmjs.com/package/dequal/v/2.0.3
Archive: https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz
Integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
-detect-libc@2.1.2 | development | Apache-2.0
+detect-libc@2.1.2 | runtime | Apache-2.0
Lockfile path: node_modules/detect-libc
Package: https://www.npmjs.com/package/detect-libc/v/2.1.2
Archive: https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz
@@ -1530,6 +1560,11 @@ emoji-regex-xs@1.0.0 | development | MIT
Package: https://www.npmjs.com/package/emoji-regex-xs/v/1.0.0
Archive: https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz
Integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==
+end-of-stream@1.4.5 | runtime, optional | MIT
+ Lockfile path: node_modules/end-of-stream
+ Package: https://www.npmjs.com/package/end-of-stream/v/1.4.5
+ Archive: https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz
+ Integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
entities@7.0.1 | development | BSD-2-Clause
Lockfile path: node_modules/entities
Package: https://www.npmjs.com/package/entities/v/7.0.1
@@ -1595,6 +1630,11 @@ evmole@0.9.3 | runtime | MIT
Package: https://www.npmjs.com/package/evmole/v/0.9.3
Archive: https://registry.npmjs.org/evmole/-/evmole-0.9.3.tgz
Integrity: sha512-UhO5HMAoEPCupSxvBJPlm/L0Qk/KVJ/OJo3r4EyhXADIEHqU5aTkAzsBfzk4lXGjqxVnaYbh1GPSmZtmSHFnQQ==
+expand-template@2.0.3 | runtime, optional | (MIT OR WTFPL)
+ Lockfile path: node_modules/expand-template
+ Package: https://www.npmjs.com/package/expand-template/v/2.0.3
+ Archive: https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz
+ Integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
expect-type@1.4.0 | development | Apache-2.0
Lockfile path: node_modules/expect-type
Package: https://www.npmjs.com/package/expect-type/v/1.4.0
@@ -1640,6 +1680,11 @@ fdir@6.5.0 | development | MIT
Package: https://www.npmjs.com/package/fdir/v/6.5.0
Archive: https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz
Integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+file-uri-to-path@1.0.0 | runtime, optional | MIT
+ Lockfile path: node_modules/file-uri-to-path
+ Package: https://www.npmjs.com/package/file-uri-to-path/v/1.0.0
+ Archive: https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz
+ Integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
focus-trap@7.8.0 | development | MIT
Lockfile path: node_modules/focus-trap
Package: https://www.npmjs.com/package/focus-trap/v/7.8.0
@@ -1655,6 +1700,16 @@ formatly@0.7.1 | development | MIT
Package: https://www.npmjs.com/package/formatly/v/0.7.1
Archive: https://registry.npmjs.org/formatly/-/formatly-0.7.1.tgz
Integrity: sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ==
+frida@17.23.2 | runtime, optional | LGPL-2.0 WITH WxWindows-exception-3.1
+ Lockfile path: node_modules/frida
+ Package: https://www.npmjs.com/package/frida/v/17.23.2
+ Archive: https://registry.npmjs.org/frida/-/frida-17.23.2.tgz
+ Integrity: sha512-gRCmmTUFdEILyHRN1b66erRoXafwsfWTqXyoBBqkSCFX0Ue7Jhvs/KhA3JO0XrgITNlPd91QAGsFvWzh0mhVgg==
+fs-constants@1.0.0 | runtime, optional | MIT
+ Lockfile path: node_modules/fs-constants
+ Package: https://www.npmjs.com/package/fs-constants/v/1.0.0
+ Archive: https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz
+ Integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fsevents@2.3.3 | development, optional | MIT
Lockfile path: node_modules/fsevents
Package: https://www.npmjs.com/package/fsevents/v/2.3.3
@@ -1685,6 +1740,11 @@ get-tsconfig@4.14.3 | development | MIT
Package: https://www.npmjs.com/package/get-tsconfig/v/4.14.3
Archive: https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz
Integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==
+github-from-package@0.0.0 | runtime, optional | MIT
+ Lockfile path: node_modules/github-from-package
+ Package: https://www.npmjs.com/package/github-from-package/v/0.0.0
+ Archive: https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz
+ Integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
glob@13.0.6 | runtime | BlueOak-1.0.0
Lockfile path: node_modules/glob
Package: https://www.npmjs.com/package/glob/v/13.0.6
@@ -1775,6 +1835,11 @@ inherits@2.0.4 | runtime | ISC
Package: https://www.npmjs.com/package/inherits/v/2.0.4
Archive: https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz
Integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+ini@1.3.8 | runtime, optional | ISC
+ Lockfile path: node_modules/ini
+ Package: https://www.npmjs.com/package/ini/v/1.3.8
+ Archive: https://registry.npmjs.org/ini/-/ini-1.3.8.tgz
+ Integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
is-callable@1.2.7 | runtime | MIT
Lockfile path: node_modules/is-callable
Package: https://www.npmjs.com/package/is-callable/v/1.2.7
@@ -2080,11 +2145,26 @@ mitt@3.0.1 | development | MIT
Package: https://www.npmjs.com/package/mitt/v/3.0.1
Archive: https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz
Integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
+mkdirp-classic@0.5.3 | runtime, optional | MIT
+ Lockfile path: node_modules/mkdirp-classic
+ Package: https://www.npmjs.com/package/mkdirp-classic/v/0.5.3
+ Archive: https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz
+ Integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
nanoid@3.3.20 | development | MIT
Lockfile path: node_modules/nanoid
Package: https://www.npmjs.com/package/nanoid/v/3.3.20
Archive: https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz
Integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==
+napi-build-utils@2.0.0 | runtime, optional | MIT
+ Lockfile path: node_modules/napi-build-utils
+ Package: https://www.npmjs.com/package/napi-build-utils/v/2.0.0
+ Archive: https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz
+ Integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==
+node-abi@3.98.0 | runtime, optional | MIT
+ Lockfile path: node_modules/node-abi
+ Package: https://www.npmjs.com/package/node-abi/v/3.98.0
+ Archive: https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz
+ Integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==
obug@2.2.1 | development | MIT
Lockfile path: node_modules/obug
Package: https://www.npmjs.com/package/obug/v/2.2.1
@@ -2230,6 +2310,11 @@ preact@10.29.8 | development | MIT
Package: https://www.npmjs.com/package/preact/v/10.29.8
Archive: https://registry.npmjs.org/preact/-/preact-10.29.8.tgz
Integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==
+prebuild-install@7.1.3 | runtime, optional | MIT
+ Lockfile path: node_modules/prebuild-install
+ Package: https://www.npmjs.com/package/prebuild-install/v/7.1.3
+ Archive: https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz
+ Integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==
process@0.11.10 | runtime | MIT
Lockfile path: node_modules/process
Package: https://www.npmjs.com/package/process/v/0.11.10
@@ -2245,6 +2330,11 @@ property-information@7.2.0 | development | MIT
Package: https://www.npmjs.com/package/property-information/v/7.2.0
Archive: https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz
Integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==
+pump@3.0.4 | runtime, optional | MIT
+ Lockfile path: node_modules/pump
+ Package: https://www.npmjs.com/package/pump/v/3.0.4
+ Archive: https://registry.npmjs.org/pump/-/pump-3.0.4.tgz
+ Integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==
pure-rand@8.4.2 | development | MIT
Lockfile path: node_modules/pure-rand
Package: https://www.npmjs.com/package/pure-rand/v/8.4.2
@@ -2255,6 +2345,16 @@ quick-format-unescaped@4.0.4 | runtime | MIT
Package: https://www.npmjs.com/package/quick-format-unescaped/v/4.0.4
Archive: https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz
Integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==
+rc@1.2.8 | runtime, optional | (BSD-2-Clause OR MIT OR Apache-2.0)
+ Lockfile path: node_modules/rc
+ Package: https://www.npmjs.com/package/rc/v/1.2.8
+ Archive: https://registry.npmjs.org/rc/-/rc-1.2.8.tgz
+ Integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+strip-json-comments@2.0.1 | runtime, optional | MIT
+ Lockfile path: node_modules/rc/node_modules/strip-json-comments
+ Package: https://www.npmjs.com/package/strip-json-comments/v/2.0.1
+ Archive: https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz
+ Integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
readable-stream@4.7.0 | runtime | MIT
Lockfile path: node_modules/readable-stream
Package: https://www.npmjs.com/package/readable-stream/v/4.7.0
@@ -2465,6 +2565,21 @@ tabbable@6.5.0 | development | MIT
Package: https://www.npmjs.com/package/tabbable/v/6.5.0
Archive: https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz
Integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==
+tar-fs@2.1.5 | runtime, optional | MIT
+ Lockfile path: node_modules/tar-fs
+ Package: https://www.npmjs.com/package/tar-fs/v/2.1.5
+ Archive: https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz
+ Integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==
+tar-stream@2.2.0 | runtime, optional | MIT
+ Lockfile path: node_modules/tar-stream
+ Package: https://www.npmjs.com/package/tar-stream/v/2.2.0
+ Archive: https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz
+ Integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+readable-stream@3.6.2 | runtime, optional | MIT
+ Lockfile path: node_modules/tar-stream/node_modules/readable-stream
+ Package: https://www.npmjs.com/package/readable-stream/v/3.6.2
+ Archive: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz
+ Integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
thread-stream@4.2.0 | runtime | MIT
Lockfile path: node_modules/thread-stream
Package: https://www.npmjs.com/package/thread-stream/v/4.2.0
@@ -2515,6 +2630,11 @@ tslib@2.8.1 | development, optional | 0BSD
Package: https://www.npmjs.com/package/tslib/v/2.8.1
Archive: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz
Integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+tunnel-agent@0.6.0 | runtime, optional | Apache-2.0
+ Lockfile path: node_modules/tunnel-agent
+ Package: https://www.npmjs.com/package/tunnel-agent/v/0.6.0
+ Archive: https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz
+ Integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
turbo@2.11.7 | development | MIT
Lockfile path: node_modules/turbo
Package: https://www.npmjs.com/package/turbo/v/2.11.7
@@ -2570,6 +2690,11 @@ unist-util-visit-parents@6.0.2 | development | MIT
Package: https://www.npmjs.com/package/unist-util-visit-parents/v/6.0.2
Archive: https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz
Integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==
+util-deprecate@1.0.2 | runtime, optional | MIT
+ Lockfile path: node_modules/util-deprecate
+ Package: https://www.npmjs.com/package/util-deprecate/v/1.0.2
+ Archive: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz
+ Integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
vfile@6.0.3 | development | MIT
Lockfile path: node_modules/vfile
Package: https://www.npmjs.com/package/vfile/v/6.0.3
diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt
index f34cd8ee4..d981aa68a 100644
--- a/licenses/npm-runtime-notices.txt
+++ b/licenses/npm-runtime-notices.txt
@@ -1,7 +1,7 @@
REA third-party npm runtime copyright, license and NOTICE texts
Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries.
External engines are separately obtained; see THIRD_PARTY_NOTICES.md.
-Dependency fingerprint: 0b00d9530d00182e3f2b163bf27feea056c24bb6600806cab74061756ffdc36a
+Runtime dependency fingerprint (SHA-256): 5de9cf97e7c327da1892efeff30b40507e4c7f8e496b5d305b8016c162bc15e3
===== @babel/helper-string-parser@8.0.0 =====
Declared license: MIT
@@ -6630,3 +6630,1590 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+===== bindings@1.5.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz
+Integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE.md ---
+
+(The MIT License)
+
+Copyright (c) 2012 Nathan Rajlich <nathan@tootallnate.net>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+===== bl@4.1.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/bl/-/bl-4.1.0.tgz
+Integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE.md ---
+
+The MIT License (MIT)
+=====================
+
+Copyright (c) 2013-2019 bl contributors
+----------------------------------
+
+*bl contributors listed at *
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+===== buffer@5.7.1 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz
+Integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) Feross Aboukhadijeh, and other contributors.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+===== chownr@1.1.4 =====
+Declared license: ISC
+Archive: https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz
+Integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The ISC License
+
+Copyright (c) Isaac Z. Schlueter and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+
+
+===== deep-extend@0.6.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz
+Integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2013-2018, Viacheslav Lotsmanov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+===== detect-libc@2.1.2 =====
+Declared license: Apache-2.0
+Archive: https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz
+Integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+
+===== end-of-stream@1.4.5 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz
+Integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2014 Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+===== expand-template@2.0.3 =====
+Declared license: (MIT OR WTFPL)
+Archive: https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz
+Integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2018 Lars-Magnus Skog
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+===== file-uri-to-path@1.0.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz
+Integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+Copyright (c) 2014 Nathan Rajlich
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+===== frida@17.23.2 =====
+Declared license: LGPL-2.0 WITH WxWindows-exception-3.1
+Archive: https://registry.npmjs.org/frida/-/frida-17.23.2.tgz
+Integrity: sha512-gRCmmTUFdEILyHRN1b66erRoXafwsfWTqXyoBBqkSCFX0Ue7Jhvs/KhA3JO0XrgITNlPd91QAGsFvWzh0mhVgg==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- Declared metadata and supplemental licensing texts ---
+
+The integrity-verified npm archive and tagged frida-node source omit a standalone license/copyright document. The package declares LGPL-2.0 WITH WxWindows-exception-3.1. No Frida copyright holder or year is inferred.
+The following unchanged Frida 17.23.2 COPYING and GNU Library GPL v2 texts are licensing supplements. The copyright in COPYING belongs to that license document; it is not recovered attribution for the Node binding or every component in its downloaded prebuild.
+
+Authors as declared by upstream package metadata: ["Frida Developers"]
+Frida Node source: https://github.com/frida/frida-node/tree/11d56293686c5494f538247ccf89f08ab57912ef
+License text provenance and SHA-256: licenses/upstream-sources.json
+
+--- licenses/frida-17.23.2-COPYING.txt ---
+
+ wxWindows Library Licence, Version 3.1
+ ======================================
+
+Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al
+
+Everyone is permitted to copy and distribute verbatim copies
+of this licence document, but changing it is not allowed.
+
+ WXWINDOWS LIBRARY LICENCE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+This library is free software; you can redistribute it and/or modify it
+under the terms of the GNU Library General Public Licence as published by
+the Free Software Foundation; either version 2 of the Licence, or (at your
+option) any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public
+Licence for more details.
+
+You should have received a copy of the GNU Library General Public Licence
+along with this software, usually in a file named COPYING.LIB. If not,
+write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth
+Floor, Boston, MA 02110-1301 USA.
+
+EXCEPTION NOTICE
+
+1. As a special exception, the copyright holders of this library give
+permission for additional uses of the text contained in this release of the
+library as licenced under the wxWindows Library Licence, applying either
+version 3.1 of the Licence, or (at your option) any later version of the
+Licence as published by the copyright holders of version 3.1 of the Licence
+document.
+
+2. The exception is that you may use, copy, link, modify and distribute
+under your own terms, binary object code versions of works based on the
+Library.
+
+3. If you copy code from files distributed under the terms of the GNU
+General Public Licence or the GNU Library General Public Licence into a
+copy of this library, as this licence permits, the exception does not apply
+to the code that you add in this way. To avoid misleading anyone as to the
+status of such modified files, you must delete this exception notice from
+such code and/or adjust the licensing conditions notice accordingly.
+
+4. If you write modifications of your own for this library, it is your
+choice whether to permit this exception to apply to your modifications. If
+you do not wish that, you must delete the exception notice from such code
+and/or adjust the licensing conditions notice accordingly.
+
+
+--- licenses/LGPL-2.0.txt ---
+
+ GNU LIBRARY GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1991 Free Software Foundation, Inc.
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the library GPL. It is
+ numbered 2 because it goes with version 2 of the ordinary GPL.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Library General Public License, applies to some
+specially designated Free Software Foundation software, and to any
+other libraries whose authors decide to use it. You can use it for
+your libraries, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if
+you distribute copies of the library, or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link a program with the library, you must provide
+complete object files to the recipients so that they can relink them
+with the library, after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ Our method of protecting your rights has two steps: (1) copyright
+the library, and (2) offer you this license which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ Also, for each distributor's protection, we want to make certain
+that everyone understands that there is no warranty for this free
+library. If the library is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original
+version, so that any problems introduced by others will not reflect on
+the original authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that companies distributing free
+software will individually obtain patent licenses, thus in effect
+transforming the program into proprietary software. To prevent this,
+we have made it clear that any patent must be licensed for everyone's
+free use or not licensed at all.
+
+ Most GNU software, including some libraries, is covered by the ordinary
+GNU General Public License, which was designed for utility programs. This
+license, the GNU Library General Public License, applies to certain
+designated libraries. This license is quite different from the ordinary
+one; be sure to read it in full, and don't assume that anything in it is
+the same as in the ordinary license.
+
+ The reason we have a separate public license for some libraries is that
+they blur the distinction we usually make between modifying or adding to a
+program and simply using it. Linking a program with a library, without
+changing the library, is in some sense simply using the library, and is
+analogous to running a utility program or application program. However, in
+a textual and legal sense, the linked executable is a combined work, a
+derivative of the original library, and the ordinary General Public License
+treats it as such.
+
+ Because of this blurred distinction, using the ordinary General
+Public License for libraries did not effectively promote software
+sharing, because most developers did not use the libraries. We
+concluded that weaker conditions might promote sharing better.
+
+ However, unrestricted linking of non-free programs would deprive the
+users of those programs of all benefit from the free status of the
+libraries themselves. This Library General Public License is intended to
+permit developers of non-free programs to use free libraries, while
+preserving your freedom as a user of such programs to change the free
+libraries that are incorporated in them. (We have not seen how to achieve
+this as regards changes in header files, but we have achieved it as regards
+changes in the actual functions of the Library.) The hope is that this
+will lead to faster development of free libraries.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, while the latter only
+works together with the library.
+
+ Note that it is possible for a library to be covered by the ordinary
+General Public License rather than by this special one.
+
+ GNU LIBRARY GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library which
+contains a notice placed by the copyright holder or other authorized
+party saying it may be distributed under the terms of this Library
+General Public License (also called "this License"). Each licensee is
+addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also compile or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ c) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ d) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the source code distributed need not include anything that is normally
+distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Library General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Library General Public
+ License as published by the Free Software Foundation; either
+ version 2 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Library General Public License for more details.
+
+ You should have received a copy of the GNU Library General Public
+ License along with this library; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Moe Ghoul, President of Vice
+
+That's all there is to it!
+
+
+
+===== fs-constants@1.0.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz
+Integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2018 Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+===== github-from-package@0.0.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz
+Integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+This software is released under the MIT license:
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+===== ini@1.3.8 =====
+Declared license: ISC
+Archive: https://registry.npmjs.org/ini/-/ini-1.3.8.tgz
+Integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The ISC License
+
+Copyright (c) Isaac Z. Schlueter and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+
+
+===== mkdirp-classic@0.5.3 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz
+Integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2020 James Halliday (mail@substack.net) and Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+===== napi-build-utils@2.0.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz
+Integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+MIT License
+
+Copyright (c) 2018 inspiredware
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+===== node-abi@3.98.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz
+Integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+MIT License
+
+Copyright (c) 2016 Lukas Geiger
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+===== prebuild-install@7.1.3 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz
+Integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+===== pump@3.0.4 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/pump/-/pump-3.0.4.tgz
+Integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2014 Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+===== rc@1.2.8 =====
+Declared license: (BSD-2-Clause OR MIT OR Apache-2.0)
+Archive: https://registry.npmjs.org/rc/-/rc-1.2.8.tgz
+Integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE.APACHE2 ---
+
+Apache License, Version 2.0
+
+Copyright (c) 2011 Dominic Tarr
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+--- package/LICENSE.BSD ---
+
+Copyright (c) 2013, Dominic Tarr
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those
+of the authors and should not be interpreted as representing official policies,
+either expressed or implied, of the FreeBSD Project.
+
+
+--- package/LICENSE.MIT ---
+
+The MIT License
+
+Copyright (c) 2011 Dominic Tarr
+
+Permission is hereby granted, free of charge,
+to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to
+deal in the Software without restriction, including
+without limitation the rights to use, copy, modify,
+merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom
+the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
+ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+===== readable-stream@3.6.2 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz
+Integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+Node.js is licensed for use as follows:
+
+"""
+Copyright Node.js contributors. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
+
+This license applies to parts of Node.js originating from the
+https://github.com/joyent/node repository:
+
+"""
+Copyright Joyent, Inc. and other Node contributors. All rights reserved.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to
+deal in the Software without restriction, including without limitation the
+rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+sell copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
+
+
+
+===== strip-json-comments@2.0.1 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz
+Integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/license ---
+
+The MIT License (MIT)
+
+Copyright (c) Sindre Sorhus (sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+===== tar-fs@2.1.5 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz
+Integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2014 Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+===== tar-stream@2.2.0 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz
+Integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+The MIT License (MIT)
+
+Copyright (c) 2014 Mathias Buus
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+===== tunnel-agent@0.6.0 =====
+Declared license: Apache-2.0
+Archive: https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz
+Integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+Apache License
+
+Version 2.0, January 2004
+
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+
+===== util-deprecate@1.0.2 =====
+Declared license: MIT
+Archive: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz
+Integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+Scope: npm runtime dependency, including optional platform packages.
+
+--- package/LICENSE ---
+
+(The MIT License)
+
+Copyright (c) 2014 Nathan Rajlich
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+
diff --git a/licenses/upstream-sources.json b/licenses/upstream-sources.json
index 5853460b2..fb1dad975 100644
--- a/licenses/upstream-sources.json
+++ b/licenses/upstream-sources.json
@@ -58,5 +58,15 @@
"file": "licenses/Node-22.19.0-LICENSE.txt",
"source": "https://raw.githubusercontent.com/nodejs/node/v22.19.0/LICENSE",
"sha256": "e991d81497a85bb24fc6bffae0a3637a6accd6c6bc5ce1f2c5698bd555cf9d49"
+ },
+ {
+ "file": "licenses/frida-17.23.2-COPYING.txt",
+ "source": "https://raw.githubusercontent.com/frida/frida/9adf8ed33ab6f1122b17399ee96dcbe101b941c9/COPYING",
+ "sha256": "5ea1544b51a28bc823b03159190d4108f9fb4f4ef912389f5137c6d295e175b2"
+ },
+ {
+ "file": "licenses/LGPL-2.0.txt",
+ "source": "https://www.gnu.org/licenses/old-licenses/lgpl-2.0.txt",
+ "sha256": "cc535c21133c895b56b374c8a1dc1eb948d99003ed2b47372069456b62f42b24"
}
]
diff --git a/package.json b/package.json
index 801f72ebf..bc5c0edc9 100644
--- a/package.json
+++ b/package.json
@@ -236,7 +236,7 @@
"docs:preview": "vitepress preview docs",
"docs:generate": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts npm run docs:prepare",
"docs:check": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run docs:check:built && npm run test:local -- tests/conformance/readmeConsistency.test.ts",
- "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
+ "docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check && npm run licenses:check",
"docs:error-schema": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:error-schema",
"prepack": "node scripts/prepack.mjs",
"prepare": "node scripts/prepare.mjs",
@@ -279,7 +279,9 @@
"test:prepare": "node scripts/check-dependency-install.mjs && node scripts/run-exclusive.mjs artifacts turbo run artifacts:mcp-catalog artifacts:product-catalog artifacts:managed-evidence",
"evidence:check:built": "node scripts/generate-completion-ledger.mjs --check",
"docs:prepare": "node scripts/generate-package-metadata.mjs && turbo run artifacts:product-catalog artifacts:managed-evidence artifacts:error-schema",
- "verify:web:qlog": "npm run build:cached && node scripts/verify-qlog-captures.mjs"
+ "verify:web:qlog": "npm run build:cached && node scripts/verify-qlog-captures.mjs",
+ "licenses:generate": "node scripts/generate-third-party-inventory.mjs",
+ "licenses:check": "node scripts/generate-third-party-inventory.mjs --check"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
diff --git a/scripts/generate-third-party-inventory.mjs b/scripts/generate-third-party-inventory.mjs
new file mode 100644
index 000000000..57934ebda
--- /dev/null
+++ b/scripts/generate-third-party-inventory.mjs
@@ -0,0 +1,120 @@
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { ensureGeneratedFile } from "./lib/generated-file.mjs";
+
+const arguments_ = new Set(process.argv.slice(2));
+for (const argument of arguments_)
+ if (argument !== "--check")
+ throw new Error(`Unknown inventory option: ${argument}`);
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const lock = JSON.parse(
+ await readFile(join(root, "package-lock.json"), "utf8"),
+);
+const entries = Object.entries(lock.packages)
+ .filter(([path]) => path !== "")
+ .map(([path, record]) => ({
+ path,
+ name: record.name ?? path.slice(path.lastIndexOf("node_modules/") + 13),
+ version: record.version,
+ license: record.license ?? "UNDECLARED",
+ resolved: record.resolved,
+ integrity: record.integrity,
+ dev: record.dev === true,
+ optional: record.optional === true,
+ }))
+ .sort((left, right) =>
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
+ );
+for (const entry of entries)
+ for (const field of ["name", "version", "resolved", "integrity"])
+ if (typeof entry[field] !== "string" || entry[field].length === 0)
+ throw new Error(`Missing locked ${field}: ${entry.path}`);
+const runtime = entries.filter(({ dev }) => !dev);
+const runtimeFingerprint = createHash("sha256")
+ .update(JSON.stringify(runtime))
+ .digest("hex");
+const source = [
+ "REA npm dependency inventory",
+ "Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.",
+ "License identifiers are upstream lockfile declarations, not a legal assessment.",
+ "Runtime includes transitive and optional platform packages; development includes build, test and website tooling.",
+ `Locked package entries: ${entries.length}; runtime: ${runtime.length}.`,
+ "Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.",
+ "",
+ ...entries.flatMap((entry) => [
+ `${entry.name}@${entry.version} | ${entry.dev ? "development" : "runtime"}${entry.optional ? ", optional" : ""} | ${entry.license}`,
+ ` Lockfile path: ${entry.path}`,
+ ` Package: https://www.npmjs.com/package/${entry.name}/v/${entry.version}`,
+ ` Archive: ${entry.resolved}`,
+ ` Integrity: ${entry.integrity}`,
+ ]),
+ "",
+].join("\n");
+await ensureGeneratedFile({
+ path: join(root, "licenses/npm-dependencies.txt"),
+ source,
+ check: arguments_.has("--check"),
+ generateCommand: "npm run licenses:generate",
+});
+
+if (arguments_.has("--check")) {
+ const notices = await readFile(
+ join(root, "licenses/npm-runtime-notices.txt"),
+ "utf8",
+ );
+ const headings = [...notices.matchAll(/^===== (.+) =====$/gmu)];
+ const sections = new Map();
+ for (const [index, heading] of headings.entries()) {
+ if (sections.has(heading[1]))
+ throw new Error(`Duplicate runtime notice: ${heading[1]}`);
+ sections.set(
+ heading[1],
+ notices.slice(
+ heading.index + heading[0].length + 1,
+ headings[index + 1]?.index,
+ ),
+ );
+ }
+ for (const entry of runtime) {
+ const identity = `${entry.name}@${entry.version}`;
+ const section = sections.get(identity);
+ if (section === undefined)
+ throw new Error(`Missing runtime notice: ${identity}`);
+ const metadata = [
+ `Declared license: ${entry.license}`,
+ `Archive: ${entry.resolved}`,
+ `Integrity: ${entry.integrity}`,
+ "",
+ ].join("\n");
+ if (!section.startsWith(metadata))
+ throw new Error(`Runtime notice metadata differs for ${identity}`);
+ }
+ if (
+ !notices
+ .slice(0, headings[0]?.index)
+ .includes(
+ `Runtime dependency fingerprint (SHA-256): ${runtimeFingerprint}\n`,
+ )
+ )
+ throw new Error(
+ `Runtime notice fingerprint differs; refresh verified notices for ${runtimeFingerprint}`,
+ );
+ const references = JSON.parse(
+ await readFile(join(root, "licenses/upstream-sources.json"), "utf8"),
+ );
+ for (const reference of references) {
+ const bytes = await readFile(join(root, reference.file));
+ if (createHash("sha256").update(bytes).digest("hex") !== reference.sha256)
+ throw new Error(`Preserved notice digest differs: ${reference.file}`);
+ }
+}
+console.log(
+ JSON.stringify({
+ locked_packages: entries.length,
+ runtime_packages: runtime.length,
+ runtime_dependency_fingerprint: runtimeFingerprint,
+ }),
+);
diff --git a/scripts/verify/harmony/analysis.mjs b/scripts/verify/harmony/analysis.mjs
index 3aa90c9f0..aaa401c75 100644
--- a/scripts/verify/harmony/analysis.mjs
+++ b/scripts/verify/harmony/analysis.mjs
@@ -153,10 +153,36 @@ try {
});
try {
await client.connect(transport, { timeout: 30_000 });
+ await client.listTools();
+ const opened = await client.callTool(
+ { name: "open_binary", arguments: { path } },
+ { timeout: 150_000 },
+ );
+ assert.notEqual(opened.isError, true, JSON.stringify(opened));
+ assert.equal(opened.structuredContent?.result?.format, "hap");
+ assert.equal(opened.structuredContent?.result?.sha256, fixture.sha256);
+ const status = await client.callTool(
+ { name: "binary_session", arguments: {} },
+ { timeout: 150_000 },
+ );
+ assert.notEqual(status.isError, true, JSON.stringify(status));
+ assert.equal(status.structuredContent?.result?.open, true);
+ assert.equal(status.structuredContent?.result?.format, "hap");
+ const inspected = await client.callTool(
+ { name: "inspect_artifact", arguments: {} },
+ { timeout: 150_000 },
+ );
+ assert.notEqual(inspected.isError, true, JSON.stringify(inspected));
+ const mcpInspection = parseEvidence(inspected.structuredContent);
+ assert.deepEqual(
+ mcpInspection.normalized_result,
+ inspection.normalized_result,
+ );
+ console.log("PASS MCP open/status/inspect_artifact parity");
const response = await client.callTool(
{
name: "project_harmony_application_graph",
- arguments: { inventory_evidence: [inspection] },
+ arguments: { inventory_evidence: [mcpInspection] },
},
{ timeout: 150_000 },
);
@@ -168,6 +194,23 @@ try {
projectionEvidence.normalized_result,
);
console.log("PASS MCP/CLI parity project_harmony_application_graph");
+ const closed = await client.callTool(
+ { name: "close_binary", arguments: {} },
+ { timeout: 150_000 },
+ );
+ assert.notEqual(closed.isError, true, JSON.stringify(closed));
+ const empty = await client.callTool(
+ { name: "binary_session", arguments: {} },
+ { timeout: 150_000 },
+ );
+ assert.notEqual(empty.isError, true, JSON.stringify(empty));
+ assert.equal(empty.structuredContent?.result?.open, false);
+ assert.equal(
+ await hashFile(path),
+ fixture.sha256,
+ "MCP workflow must preserve source bytes",
+ );
+ console.log("PASS MCP close and unchanged source bytes");
} catch (cause) {
process.stderr.write(stderr);
throw cause;
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index 6011d544b..1a4dbbb5c 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -16,7 +16,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_evm_interface, inspect_managed_artifact, 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, native, inventory, WASM, 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
+ 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts
index 20c5517ee..e63a79ff5 100644
--- a/src/contracts/toolOutputSchemaPrimitives.ts
+++ b/src/contracts/toolOutputSchemaPrimitives.ts
@@ -24,6 +24,7 @@ import {
} from "../domain/providerOperationHealth.js";
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
+import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js";
/** Specialize the single normalized result inside a complete Evidence record. */
export const evidenceResultOf = (schema: Schema) =>
@@ -59,11 +60,7 @@ export const targetFormatSchema = z.enum([
"dos-mz",
"dos-com",
"raw-binary",
- "zip",
- "ipa",
- "apk",
- "msix",
- "appx",
+ ...ZIP_PACKAGE_FORMATS,
"asar",
"dmg",
"pkg",
diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts
index b4d44c110..ab5046549 100644
--- a/src/domain/binaryTargetTypes.ts
+++ b/src/domain/binaryTargetTypes.ts
@@ -1,3 +1,5 @@
+import type { ZipPackageFormat } from "./zipPackageFormat.js";
+
/** Provider-neutral CPU families detected from supported executable headers. */
export const BINARY_ARCHITECTURES = [
"x86",
@@ -167,18 +169,7 @@ export type BinaryTarget =
| (BinaryTargetIdentity &
NonExecutableMetadata & {
readonly kind: "archive";
- readonly format:
- | "zip"
- | "ipa"
- | "apk"
- | "msix"
- | "appx"
- | "hap"
- | "hsp"
- | "app-pack"
- | "asar"
- | "dmg"
- | "pkg";
+ readonly format: ZipPackageFormat | "asar" | "dmg" | "pkg";
})
| (BinaryTargetIdentity &
NonExecutableMetadata & {
diff --git a/tests/acceptance/harmonyArtifactJourney.test.ts b/tests/acceptance/harmonyArtifactJourney.test.ts
new file mode 100644
index 000000000..51178d744
--- /dev/null
+++ b/tests/acceptance/harmonyArtifactJourney.test.ts
@@ -0,0 +1,135 @@
+import { createHash } from "node:crypto";
+import { readFile, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+import { expect } from "vitest";
+
+import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
+import { parseEvidence } from "../../src/domain/evidence.js";
+import { harmonyApplicationProjectionResultSchema } from "../../src/domain/harmony/harmonyApplication.js";
+import { workspaceCliTest as test } from "../support/cli/workspaceCliFixture.js";
+
+test("preserves Harmony archive formats through the public CLI/MCP lifecycle", async ({
+ cli,
+ workspace,
+}) => {
+ const writer = new ZipWriter(new Uint8ArrayWriter());
+ await writer.add(
+ "module.json",
+ new TextReader('{"module":{"name":"fixture"}}'),
+ );
+ await writer.add(
+ "ets/modules.abc",
+ new TextReader("opaque bytecode fixture"),
+ );
+ const bytes = await writer.close();
+ const environment = {
+ PATH: process.env.PATH ?? "",
+ HOME: workspace.root,
+ USERPROFILE: workspace.root,
+ XDG_CONFIG_HOME: workspace.path("config"),
+ XDG_CACHE_HOME: workspace.path("cache"),
+ XDG_STATE_HOME: workspace.path("state"),
+ NODE_OPTIONS: "--max-old-space-size=512",
+ REA_LOG_LEVEL: "silent",
+ };
+ const client = new Client({ name: "rea-harmony-acceptance", version: "1" });
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [resolve("scripts/rea.mjs"), "mcp"],
+ env: environment,
+ stderr: "pipe",
+ });
+ try {
+ await client.connect(transport);
+ await client.listTools();
+ for (const { suffix, format } of [
+ { suffix: "hap", format: "hap" },
+ { suffix: "hsp", format: "hsp" },
+ { suffix: "app", format: "app-pack" },
+ ]) {
+ const path = workspace.path(`fixture.${suffix}`);
+ await writeFile(path, bytes);
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
+ const cliReply = await cli.run({
+ arguments: ["inspect-artifact", path, "--format", "json"],
+ environment,
+ });
+ expect(cliReply.exitCode, cliReply.stderr).toBe(0);
+ const cliEvidence = parseEvidence(cliReply.json);
+ const cliInventory = artifactInspectionResultSchema.parse(
+ cliEvidence.normalized_result,
+ );
+ expect(cliInventory.subject).toMatchObject({
+ root_format: format,
+ root_sha256: sha256,
+ });
+ const opened = await client.callTool({
+ name: "open_binary",
+ arguments: { path },
+ });
+ expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
+ expect(opened.structuredContent).toMatchObject({
+ result: { kind: "archive", format, sha256 },
+ });
+ const status = await client.callTool({
+ name: "binary_session",
+ arguments: {},
+ });
+ expect(status.isError, JSON.stringify(status)).not.toBe(true);
+ expect(status.structuredContent).toMatchObject({
+ result: { open: true, kind: "archive", format, sha256 },
+ });
+ const inspected = await client.callTool({
+ name: "inspect_artifact",
+ arguments: {},
+ });
+ expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true);
+ const inventoryEvidence = parseEvidence(inspected.structuredContent);
+ expect(
+ artifactInspectionResultSchema.parse(
+ inventoryEvidence.normalized_result,
+ ),
+ ).toEqual(cliInventory);
+
+ const projected = await client.callTool({
+ name: "project_harmony_application_graph",
+ arguments: { inventory_evidence: [inventoryEvidence] },
+ });
+ expect(projected.isError, JSON.stringify(projected)).not.toBe(true);
+ const graph = harmonyApplicationProjectionResultSchema.parse(
+ parseEvidence(projected.structuredContent).normalized_result,
+ );
+ expect(graph).toMatchObject({
+ root_format: format,
+ root_sha256: sha256,
+ coverage: { status: "complete-within-inventory" },
+ });
+ expect(
+ graph.components.manifests.map(({ path: member }) => member),
+ ).toEqual(["module.json"]);
+ expect(
+ graph.components.bytecode.map(({ path: member }) => member),
+ ).toEqual(["ets/modules.abc"]);
+ const closed = await client.callTool({
+ name: "close_binary",
+ arguments: {},
+ });
+ expect(closed.isError, JSON.stringify(closed)).not.toBe(true);
+ const empty = await client.callTool({
+ name: "binary_session",
+ arguments: {},
+ });
+ expect(empty.isError, JSON.stringify(empty)).not.toBe(true);
+ expect(empty.structuredContent).toMatchObject({
+ result: { open: false },
+ });
+ expect(await readFile(path)).toEqual(Buffer.from(bytes));
+ }
+ } finally {
+ await client.close();
+ }
+}, 120_000);
diff --git a/tests/boundary/filesystem/thirdPartyInventory.test.ts b/tests/boundary/filesystem/thirdPartyInventory.test.ts
new file mode 100644
index 000000000..7e61f883b
--- /dev/null
+++ b/tests/boundary/filesystem/thirdPartyInventory.test.ts
@@ -0,0 +1,117 @@
+import { createHash } from "node:crypto";
+import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+import { expect } from "vitest";
+
+import { processTest as test } from "../../support/process/processFixture.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+test("generates locked inventory and rejects stale notice metadata or preserved texts", async ({
+ processes,
+}) => {
+ const root = await createTestTempDirectory("rea-notice-records-");
+ await mkdir(resolve(root, "scripts/lib"), { recursive: true });
+ await mkdir(resolve(root, "licenses"));
+ for (const file of [
+ "scripts/generate-third-party-inventory.mjs",
+ "scripts/lib/generated-file.mjs",
+ ])
+ await copyFile(resolve(file), resolve(root, file));
+ const script = resolve(root, "scripts/generate-third-party-inventory.mjs");
+ const runtime = {
+ version: "1.0.0",
+ license: "MIT",
+ resolved:
+ "https://registry.npmjs.org/fixture-runtime/-/fixture-runtime-1.0.0.tgz",
+ integrity: "sha512-fixture-integrity",
+ optional: true,
+ };
+ await writeFile(
+ resolve(root, "package-lock.json"),
+ JSON.stringify({
+ packages: {
+ "": { name: "fixture", version: "1.0.0" },
+ "node_modules/fixture-runtime": runtime,
+ "node_modules/fixture-dev": { ...runtime, dev: true, optional: false },
+ },
+ }),
+ );
+ const generated = await processes.run(process.execPath, [script]);
+ expect(generated.exitCode, generated.stderr).toBe(0);
+ const inventory = await readFile(
+ resolve(root, "licenses/npm-dependencies.txt"),
+ "utf8",
+ );
+ expect(inventory).toContain("Locked package entries: 2; runtime: 1.");
+ expect(inventory).toContain(
+ "fixture-runtime@1.0.0 | runtime, optional | MIT",
+ );
+ expect(inventory).toContain("fixture-dev@1.0.0 | development | MIT");
+ const fingerprint = (
+ JSON.parse(generated.stdout) as { runtime_dependency_fingerprint: string }
+ ).runtime_dependency_fingerprint;
+ const notice = [
+ `Runtime dependency fingerprint (SHA-256): ${fingerprint}`,
+ "",
+ "===== fixture-runtime@1.0.0 =====",
+ "Declared license: MIT",
+ `Archive: ${runtime.resolved}`,
+ `Integrity: ${runtime.integrity}`,
+ "",
+ "Fixture text; this test does not establish real package attribution.",
+ "",
+ ].join("\n");
+ const noticePath = resolve(root, "licenses/npm-runtime-notices.txt");
+ const referencePath = resolve(root, "licenses/reference.txt");
+ await writeFile(noticePath, notice);
+ await writeFile(referencePath, "preserved fixture text\n");
+ await writeFile(
+ resolve(root, "licenses/upstream-sources.json"),
+ JSON.stringify([
+ {
+ file: "licenses/reference.txt",
+ sha256: createHash("sha256")
+ .update("preserved fixture text\n")
+ .digest("hex"),
+ },
+ ]),
+ );
+ const check = () => processes.run(process.execPath, [script, "--check"]);
+ const valid = await check();
+ expect(valid.exitCode, valid.stderr).toBe(0);
+ for (const [changed, diagnostic] of [
+ [
+ notice.replace(
+ "===== fixture-runtime@1.0.0 =====",
+ "===== another-package@1.0.0 =====",
+ ),
+ "Missing runtime notice",
+ ],
+ [
+ notice.replace(runtime.integrity, "sha512-different"),
+ "Runtime notice metadata differs",
+ ],
+ [
+ notice.replace(fingerprint, "0".repeat(64)),
+ "Runtime notice fingerprint differs",
+ ],
+ [
+ notice + notice.slice(notice.indexOf("===== ")),
+ "Duplicate runtime notice",
+ ],
+ ] as const) {
+ await writeFile(noticePath, changed);
+ const rejected = await check();
+ expect(rejected.exitCode).toBe(1);
+ expect(rejected.stderr).toContain(diagnostic);
+ }
+ await writeFile(noticePath, notice);
+ await writeFile(referencePath, "changed fixture text\n");
+ const tampered = await check();
+ expect(tampered.exitCode).toBe(1);
+ expect(tampered.stderr).toContain("Preserved notice digest differs");
+ expect(
+ await readFile(resolve(root, "licenses/npm-dependencies.txt"), "utf8"),
+ ).toBe(inventory);
+});
diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts
index f23295829..5b2fe98af 100644
--- a/tests/boundary/mcp/analysisViewMcp.test.ts
+++ b/tests/boundary/mcp/analysisViewMcp.test.ts
@@ -69,6 +69,12 @@ const connect = async (
it("discovers an oversized native dossier by exact metadata and reads its retained view", async () => {
const { client, session } = await connect();
+ const advertised = (await client.listTools()).tools.find(
+ (tool) => tool.name === "inspect_analysis_view",
+ );
+ expect(advertised?.description).toContain(
+ 'Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}',
+ );
const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" };
const profile = createAnalysisProfile(provider, { language: "fixture" });
const parent = createEvidence(
@@ -98,6 +104,24 @@ it("discovers an oversized native dossier by exact metadata and reads its retain
const retained = session.recordEvidence(record);
if (!retained.ok) throw retained.error;
}
+ const unsupportedSummary = await client.callTool({
+ name: "inspect_analysis_view",
+ arguments: {
+ source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
+ view: { kind: "summary" },
+ },
+ });
+ expect(parseMcpToolError(unsupportedSummary).error).toMatchObject({
+ code: "invalid_request",
+ details: {
+ issues: [
+ {
+ path: ["view"],
+ message: "analyze_function Evidence requires a native view.",
+ },
+ ],
+ },
+ });
const broad = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
diff --git a/turbo.json b/turbo.json
index e8bfbf06e..758f31e32 100644
--- a/turbo.json
+++ b/turbo.json
@@ -151,6 +151,7 @@
"scripts/fixtures/apple/*.mjs",
"src/generated*.ts",
"src/contracts/**/*.ts",
+ "licenses/**",
"docs/**",
"!docs/public/product-catalog.json",
"!docs/verification/managed-conformance-*.json",
From 4303dcdcf68af4d71d777ffff75cbd4f17a8877d Mon Sep 17 00:00:00 2001
From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Date: Mon, 12 Oct 2026 21:11:53 -0700
Subject: [PATCH 008/159] fix(native): name the retained pwntools process group
in cleanup resources (#2281)
When a pwntools or pwndbg stage cannot confirm that its owned process
group stopped, the cleanup_incomplete result listed only the run ID and
runtime directory, although a descendant can still be live. Add the
retained supervisor's pid and process-group coordinates, as
OwnedCommand reports for a non-transferred cleanup failure.
Fixes #2274
Co-authored-by: Claude Opus 5.5
---
src/native/pwntools/PwntoolsDecoder.ts | 7 +++++++
tests/process-global/pwntoolsStageRetirement.test.ts | 8 ++++++++
2 files changed, 15 insertions(+)
diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts
index 3a28edbfa..d8cad3d2e 100644
--- a/src/native/pwntools/PwntoolsDecoder.ts
+++ b/src/native/pwntools/PwntoolsDecoder.ts
@@ -518,6 +518,13 @@ export class PwntoolsDecoder<
[
pending.process.launch.ownership?.runId ?? "owned-decoder",
...(pending.root === undefined ? [] : [pending.root.path]),
+ // The retained group may still be live; name it as OwnedCommand does.
+ ...(pending.process.launch.ownership === undefined
+ ? []
+ : [
+ `pid:${pending.process.launch.ownership.leaderPid}`,
+ `process-group:${pending.process.launch.ownership.processGroupId}`,
+ ]),
],
{
reason: stopped.reason,
diff --git a/tests/process-global/pwntoolsStageRetirement.test.ts b/tests/process-global/pwntoolsStageRetirement.test.ts
index bcdaf858f..6ec062535 100644
--- a/tests/process-global/pwntoolsStageRetirement.test.ts
+++ b/tests/process-global/pwntoolsStageRetirement.test.ts
@@ -261,6 +261,14 @@ it.runIf(supportedHost).each([
if (result.ok) throw new Error("Expected unresolved process ownership");
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("cleanup_incomplete");
+ expect(projected).toMatchObject({
+ details: {
+ resources: expect.arrayContaining([
+ expect.stringMatching(/^pid:\d+$/u),
+ expect.stringMatching(/^process-group:\d+$/u),
+ ]),
+ },
+ });
if (stage === "debugger")
expect(projected).toMatchObject({
details: {
From 2c7240cf9d8231e16ad01de7678af9dbd01bab46 Mon Sep 17 00:00:00 2001
From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Date: Mon, 12 Oct 2026 21:12:05 -0700
Subject: [PATCH 009/159] perf(native): run independent Mach-O slice commands
concurrently (#2466)
inspect_macho launched otool, nm, two dyld_info reads, dwarfdump and
vtool one at a time. Each launch also resolves the tool through
`xcrun --find`, so a warm call spent about 350 ms in eight serial
supervised launches. The slice commands only read the selected
artifact, so launch them together. Every launch still settles before
the result, and declared order still fixes provenance and which
failure is reported.
Co-authored-by: Claude Opus 5.5
---
src/native/NativeMachoInspection.test.ts | 66 +++++++++++++++++++++++-
src/native/NativeMachoInspection.ts | 25 ++++-----
2 files changed, 78 insertions(+), 13 deletions(-)
diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts
index 4ed533c2e..6fe24eb90 100644
--- a/src/native/NativeMachoInspection.test.ts
+++ b/src/native/NativeMachoInspection.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
-import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
+import {
+ AnalysisCancelledError,
+ AnalysisOutputError,
+} from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
import { inspectNativeMacho } from "./NativeMachoInspection.js";
@@ -186,3 +189,64 @@ describe("Mach-O UUID reconciliation", () => {
);
});
});
+
+describe("Mach-O slice command scheduling", () => {
+ const SLICE_TOOLS = ["otool", "nm", "dyld_info", "dyld_info", "dwarfdump"];
+
+ /** Finish later-launched slice commands first and record overlap. */
+ const reorderingRunner = (failing: ReadonlySet = new Set()) => {
+ const fixtures = new NativeFixtureRunner();
+ let inFlight = 0;
+ let maxInFlight = 0;
+ let launched = 0;
+ const run = async (tool: string, args: readonly string[]) => {
+ if (tool === "file" || tool === "lipo") return fixtures.run(tool, args);
+ const order = launched++;
+ inFlight += 1;
+ maxInFlight = Math.max(maxInFlight, inFlight);
+ for (let turn = 0; turn < 12 - order * 2; turn += 1)
+ await new Promise((resolve) => setImmediate(resolve));
+ inFlight -= 1;
+ if (failing.has(tool))
+ return err(new AnalysisOutputError("inspect_macho", `${tool} failed`));
+ return fixtures.run(tool, args);
+ };
+ return { run, maxInFlight: () => maxInFlight };
+ };
+
+ const inspectWith = (run: ReturnType["run"]) =>
+ inspectNativeMacho({
+ target: nativeMachoTarget("/fixture/app"),
+ run,
+ invocation: (capture) => ({
+ tool: capture.tool,
+ command: [capture.executable, ...capture.arguments],
+ tool_version: capture.toolVersion,
+ version_reason: capture.versionReason,
+ executable_sha256: capture.executableSha256,
+ exit: { code: capture.exitCode, signal: capture.signal },
+ stdout_bytes: capture.stdoutBytes,
+ stderr_bytes: capture.stderrBytes,
+ }),
+ });
+
+ it("runs independent slice commands together and keeps provenance in declared order", async () => {
+ const runner = reorderingRunner();
+ const result = await inspectWith(runner.run);
+ if (!result.ok) throw result.error;
+ expect(runner.maxInFlight()).toBe(SLICE_TOOLS.length + 1);
+ expect(result.value.result).toMatchObject({
+ provenance: ["file", "lipo", ...SLICE_TOOLS, "vtool"].map((tool) => ({
+ tool,
+ })),
+ });
+ });
+
+ it("reports the first failing slice command in declared order", async () => {
+ const runner = reorderingRunner(new Set(["nm", "dwarfdump"]));
+ const result = await inspectWith(runner.run);
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error).toMatchObject({ reason: "nm failed" });
+ });
+});
diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts
index 2ec6cd4a9..24ccb2514 100644
--- a/src/native/NativeMachoInspection.ts
+++ b/src/native/NativeMachoInspection.ts
@@ -79,8 +79,11 @@ export const inspectNativeMacho = async (
);
const selectedArchitecture =
selection.status === "selected" ? selection.architecture : null;
- for (const [tool, prefix] of REQUIRED_SLICE_COMMANDS) {
- const captured = await context.run(
+ const runSliceCommand = ([tool, prefix]: readonly [
+ string,
+ readonly string[],
+ ]) =>
+ context.run(
tool,
[
...prefix,
@@ -89,20 +92,18 @@ export const inspectNativeMacho = async (
],
context.signal,
);
+ // Slice commands only read the selected artifact, so launch them together.
+ // Every launch settles before return; declared order fixes provenance and
+ // which failure is reported.
+ const [required, optional] = await Promise.all([
+ Promise.all(REQUIRED_SLICE_COMMANDS.map(runSliceCommand)),
+ runSliceCommand(OPTIONAL_VTOOL_COMMAND),
+ ]);
+ for (const captured of required) {
if (!captured.ok) return err(captured.error);
captures.push(captured.value);
}
const limitations: string[] = [];
- const [tool, prefix] = OPTIONAL_VTOOL_COMMAND;
- const optional = await context.run(
- tool,
- [
- ...prefix,
- ...selectedArchitectureArguments(tool, selectedArchitecture),
- context.target.path,
- ],
- context.signal,
- );
if (optional.ok) captures.push(optional.value);
else if (optional.error._tag === "AnalysisCapabilityUnavailableError")
limitations.push(VTOOL_UNAVAILABLE_LIMITATION);
From bb540826d65316c164d2de8ba4ef10f81a8ad073 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:18:39 +0800
Subject: [PATCH 010/159] fix(artifacts): retry transient DMG verification
refusals
---
src/artifacts/NativeDmgArtifactReader.ts | 111 +++++++++++----
.../native/nativeDmgArtifactReader.test.ts | 132 ++++++++++++++++++
2 files changed, 215 insertions(+), 28 deletions(-)
diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts
index a3f3fb120..801ee4daf 100644
--- a/src/artifacts/NativeDmgArtifactReader.ts
+++ b/src/artifacts/NativeDmgArtifactReader.ts
@@ -22,6 +22,7 @@ import {
type NativeDmgInfo,
} from "./NativeDmgOwnership.js";
const HDIUTIL_COMMAND_TIMEOUT_MS = 120_000;
+const VERIFY_SETTLE_DELAYS_MS = [250, 500] as const;
// APFS ejects and busy reporting can lag a detach attempt.
const DETACH_SETTLE_DELAYS_MS = [250, 500, 1000] as const;
const delay = (milliseconds: number): Promise =>
@@ -44,22 +45,30 @@ export interface NativeDmgHost {
class NativeDmgCommandFailure extends ArtifactReaderFailure {
readonly processNotStarted: boolean;
+ readonly verifyDiagnostic: string | undefined;
+ readonly exitCode: number | null;
constructor(
- reason: ArtifactReaderFailure["reason"],
+ failure: {
+ readonly reason: ArtifactReaderFailure["reason"];
+ readonly verifyDiagnostic?: string;
+ },
message: string,
readonly stdout: string,
options?: {
readonly cause?: unknown;
readonly processNotStarted?: boolean;
+ readonly exitCode?: number;
},
) {
super(
- reason,
+ failure.reason,
message,
options?.cause === undefined ? undefined : { cause: options.cause },
);
this.processNotStarted = options?.processNotStarted ?? false;
+ this.verifyDiagnostic = failure.verifyDiagnostic;
+ this.exitCode = options?.exitCode ?? null;
}
}
@@ -84,7 +93,7 @@ const createSystemHost = (
const output = execFileOutputFailure(cause);
if (cause instanceof Error && cause.name === "AbortError")
throw new NativeDmgCommandFailure(
- "cancelled",
+ { reason: "cancelled" },
"DMG operation cancelled",
output?.stdout ?? "",
{ cause },
@@ -99,7 +108,7 @@ const createSystemHost = (
cause,
};
throw new NativeDmgCommandFailure(
- commandFailureReason(cause, arguments_[0]),
+ classifyCommandFailure(cause, arguments_[0]),
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(cause, arguments_)}`,
output?.stdout ?? "",
{ cause, processNotStarted: processNotStarted(cause) },
@@ -348,8 +357,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
}
async #attach(signal?: AbortSignal): Promise {
- await runChecked(this.host, ["verify", this.path], signal);
- this.#provenance.push(command(["verify", this.path], ["read"]));
+ await this.#verify(signal);
const baseline = await this.#readInfo();
this.#baselineDevices = new Set(
baseline.images.flatMap((image) =>
@@ -431,6 +439,40 @@ export class NativeDmgArtifactReader implements ArtifactReader {
}
}
+ async #verify(signal?: AbortSignal): Promise {
+ const arguments_ = ["verify", this.path];
+ const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
+ for (let attempt = 0; ; attempt += 1) {
+ if (signal?.aborted === true)
+ throw new NativeDmgCommandFailure(
+ { reason: "cancelled" },
+ "DMG operation cancelled",
+ "",
+ { cause: signal.reason },
+ );
+ try {
+ await runChecked(this.host, arguments_, signal, {
+ timeoutMs: Math.max(1, Math.ceil(deadline - performance.now())),
+ });
+ this.#provenance.push(command(arguments_, ["read"]));
+ return;
+ } catch (cause: unknown) {
+ if (!(cause instanceof NativeDmgCommandFailure)) throw cause;
+ this.#provenance.push(command(arguments_, ["read"], cause.exitCode));
+ const delayMs = VERIFY_SETTLE_DELAYS_MS[attempt];
+ // Retry only the producer's explicit transient diagnostic, before any mount.
+ if (
+ cause.verifyDiagnostic !== "Resource temporarily unavailable" ||
+ delayMs === undefined ||
+ deadline - performance.now() <= delayMs
+ )
+ throw cause;
+ await (this.host.delay ?? delay)(delayMs);
+ if (performance.now() >= deadline) throw cause;
+ }
+ }
+ }
+
async #readInfo(): Promise {
const info = await runChecked(this.host, ["info", "-plist"], undefined, {
timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS,
@@ -501,14 +543,14 @@ const runChecked = async (
if (cause instanceof ArtifactReaderFailure) throw cause;
if (cause instanceof Error && cause.name === "AbortError")
throw new NativeDmgCommandFailure(
- "cancelled",
+ { reason: "cancelled" },
"DMG operation cancelled",
execFileOutputFailure(cause)?.stdout ?? "",
{ cause },
);
const output = execFileOutputFailure(cause);
throw new NativeDmgCommandFailure(
- commandFailureReason(cause, arguments_[0]),
+ classifyCommandFailure(cause, arguments_[0]),
`hdiutil ${arguments_[0] ?? "operation"} failed: ${describeCommandFailure(
cause,
arguments_,
@@ -527,17 +569,17 @@ const runChecked = async (
stdout: result.stdout,
...(result.stderr === undefined ? {} : { stderr: result.stderr }),
};
- const reason = commandFailureReason(failure, arguments_[0]);
+ const classification = classifyCommandFailure(failure, arguments_[0]);
const details = describeCommandFailure(failure, arguments_);
const unknownVerifyFailure =
- arguments_[0] === "verify" && reason === "unavailable";
+ arguments_[0] === "verify" && classification.reason === "unavailable";
throw new NativeDmgCommandFailure(
- reason,
+ classification,
unknownVerifyFailure
? `hdiutil verify failed; captured diagnostics do not establish the failure cause: ${details}`
: `hdiutil ${arguments_[0] ?? "operation"} failed: ${details}`,
result.stdout,
- { cause: result.cause ?? failure },
+ { cause: result.cause ?? failure, exitCode: result.exitCode },
);
}
return { stdout: result.stdout, exitCode: 0 };
@@ -587,20 +629,36 @@ const processOutput = (cause: unknown, field: "stdout" | "stderr"): string => {
return typeof value === "string" ? value : "";
};
-const commandFailureReason = (
+const classifyCommandFailure = (
cause: unknown,
operation: string | undefined,
-): ArtifactReaderFailure["reason"] => {
- if (typeof cause !== "object" || cause === null) return "unavailable";
+): {
+ readonly reason: ArtifactReaderFailure["reason"];
+ readonly verifyDiagnostic?: string;
+} => {
+ if (typeof cause !== "object" || cause === null)
+ return { reason: "unavailable" };
const code = Reflect.get(cause, "code");
const exitCode = Reflect.get(cause, "exitCode");
- if (typeof code === "number" || typeof exitCode === "number")
- return operation === "verify" ? verifyFailureReason(cause) : "unavailable";
+ if (typeof code === "number" || typeof exitCode === "number") {
+ if (operation !== "verify") return { reason: "unavailable" };
+ const diagnostic = verifyFailureDiagnostic(
+ processOutput(cause, "stdout"),
+ processOutput(cause, "stderr"),
+ );
+ return {
+ reason: verifyFailureReason(diagnostic),
+ ...(diagnostic === undefined ? {} : { verifyDiagnostic: diagnostic }),
+ };
+ }
if (code === "ENOENT") {
const syscall = Reflect.get(cause, "syscall");
- return typeof syscall === "string" && syscall.startsWith("spawn")
- ? "unavailable"
- : "io";
+ return {
+ reason:
+ typeof syscall === "string" && syscall.startsWith("spawn")
+ ? "unavailable"
+ : "io",
+ };
}
if (
code === "EACCES" ||
@@ -613,17 +671,13 @@ const commandFailureReason = (
code === "EMFILE" ||
code === "ENFILE"
)
- return "io";
- return "unavailable";
+ return { reason: "io" };
+ return { reason: "unavailable" };
};
const verifyFailureReason = (
- cause: unknown,
+ diagnostic: string | undefined,
): ArtifactReaderFailure["reason"] => {
- const diagnostic = verifyFailureDiagnostic(
- processOutput(cause, "stdout"),
- processOutput(cause, "stderr"),
- );
switch (diagnostic) {
// hdiutil follows AppleLanguages, so en-GB and en-AU spell it "recognised".
case "image not recognized":
@@ -656,11 +710,12 @@ const verifyFailureDiagnostic = (
const command = (
arguments_: readonly string[],
effects: ArtifactCommand["effects"],
+ exitCode: number | null = 0,
): ArtifactCommand => ({
tool: "/usr/bin/hdiutil",
arguments: [...arguments_],
tool_version: null,
executable_sha256: null,
- exit_code: 0,
+ exit_code: exitCode,
effects,
});
diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts
index 9e03727c1..5d6af987d 100644
--- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts
+++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts
@@ -1200,6 +1200,138 @@ describe("native DMG command diagnostics", () => {
});
});
+describe("native DMG verification resource availability", () => {
+ const transientFailure = {
+ stdout: "",
+ stderr:
+ 'hdiutil: verify: unable to recognize "/tmp/image.dmg" as a disk image. (Resource temporarily unavailable)\nhdiutil: verify failed - Resource temporarily unavailable\n',
+ exitCode: 1,
+ };
+
+ it("retries the explicit transient refusal before attachment and retains both attempts", async () => {
+ const { host, state } = createOmittedInventoryHost();
+ state.inventoryMode = "listed";
+ state.detachAttempts = 4;
+ const delays = vi.fn(async () => {});
+ const timeouts: number[] = [];
+ let attempts = 0;
+ const reader = await createAttachedReader(
+ "/tmp/image.dmg",
+ process.env,
+ undefined,
+ {
+ ...host,
+ delay: delays,
+ async run(arguments_, signal, options) {
+ if (arguments_[0] === "verify") {
+ attempts += 1;
+ if (options === undefined)
+ throw new Error("missing verify deadline");
+ timeouts.push(options.timeoutMs);
+ if (attempts === 1) return transientFailure;
+ }
+ return host.run(arguments_, signal, options);
+ },
+ },
+ );
+ onTestFinished(() => reader.close());
+ expect(attempts).toBe(2);
+ expect(delays.mock.calls).toEqual([[250]]);
+ expect(timeouts[0]).toBeLessThanOrEqual(120_000);
+ expect(timeouts[1]).toBeLessThanOrEqual(timeouts[0]!);
+ expect(reader.provenance().slice(0, 2)).toMatchObject([
+ {
+ arguments: ["verify", "/tmp/image.dmg"],
+ exit_code: 1,
+ effects: ["read"],
+ },
+ {
+ arguments: ["verify", "/tmp/image.dmg"],
+ exit_code: 0,
+ effects: ["read"],
+ },
+ ]);
+ await reader.close();
+ expect(state.attached).toBe(false);
+ });
+
+ it("bounds repeated transient refusals and never attaches an unverified image", async () => {
+ const run = vi.fn(async () => transientFailure);
+ const delay = vi.fn(async () => {});
+ const reader = new NativeDmgArtifactReader("/tmp/image.dmg", process.env, {
+ run,
+ delay,
+ });
+ await expect(entryPaths(reader)).rejects.toMatchObject({
+ reason: "unavailable",
+ message: expect.stringContaining("Resource temporarily unavailable"),
+ });
+ await reader.close();
+ expect(run.mock.calls).toHaveLength(3);
+ expect(delay.mock.calls).toEqual([[250], [500]]);
+ expect(reader.provenance().map(({ exit_code }) => exit_code)).toEqual([
+ 1, 1, 1,
+ ]);
+ });
+
+ it("honors cancellation during the wait without starting another command", async () => {
+ const controller = new AbortController();
+ const run = vi.fn(async () => transientFailure);
+ await expect(
+ createAttachedReader("/tmp/image.dmg", process.env, controller.signal, {
+ run,
+ async delay() {
+ controller.abort(new Error("cancel verification"));
+ },
+ }),
+ ).rejects.toMatchObject({ reason: "cancelled" });
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not start another verification after the shared deadline", async () => {
+ const clock = vi.spyOn(performance, "now").mockReturnValue(0);
+ const run = vi.fn(async () => transientFailure);
+ try {
+ await expect(
+ createAttachedReader("/tmp/image.dmg", process.env, undefined, {
+ run,
+ async delay() {
+ clock.mockReturnValue(120_001);
+ },
+ }),
+ ).rejects.toMatchObject({
+ reason: "unavailable",
+ message: expect.stringContaining("Resource temporarily unavailable"),
+ });
+ expect(run).toHaveBeenCalledTimes(1);
+ } finally {
+ clock.mockRestore();
+ }
+ });
+
+ it.each([
+ ["image data corrupted", "integrity"],
+ ["image not recognized", "format"],
+ ["Permission denied", "io"],
+ ["an unknown failure", "unavailable"],
+ ])("does not retry %s", async (diagnostic, reason) => {
+ const run = vi.fn(async () => ({
+ stdout: "",
+ stderr: `hdiutil: verify failed - ${diagnostic}\n`,
+ exitCode: 1,
+ }));
+ const delay = vi.fn(async () => {});
+ await expect(
+ createAttachedReader("/tmp/image.dmg", process.env, undefined, {
+ run,
+ delay,
+ }),
+ ).rejects.toMatchObject({ reason });
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(delay).not.toHaveBeenCalled();
+ });
+});
+
describe("native DMG real verification diagnostics", () => {
it.skipIf(process.platform !== "darwin")(
"classifies a real hdiutil verify refusal as a format failure with captured output",
From 45984a4319c8b6dcf2b03e969fbd4330c8432394 Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:38:12 +0800
Subject: [PATCH 011/159] test(perf): verify existing SQLite column selection
on QGIS data (#2489)
Verify existing SQLite column selection on pinned QGIS application data. Preserve complete requested schema and exact typed sample facts, all failed/repeated trials and mixed timing results; add reproducible corpus/question/grader and an isolated per-tool driver approval entry.
Existing controls reduce model input in both corrected pairs; no production/API/default/cache change is justified. Real CLI/MCP parity, independent SQLite binding, current-main SDK comparison and relevant checks pass. Ref #2016.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---
experiments/analysis-efficiency/README.md | 4 +
.../analysis-efficiency/agent-trial.py | 1 +
experiments/analysis-efficiency/corpus.json | 13 ++
experiments/analysis-efficiency/measure.mjs | 58 ++++++
.../questions/sqlite-qgis.txt | 1 +
.../analysis-efficiency/review-model.py | 7 +-
.../sqlite-existing-controls.md | 181 ++++++++++++++++++
7 files changed, 264 insertions(+), 1 deletion(-)
create mode 100644 experiments/analysis-efficiency/questions/sqlite-qgis.txt
create mode 100644 experiments/analysis-efficiency/sqlite-existing-controls.md
diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md
index b028953ce..5f656a02c 100644
--- a/experiments/analysis-efficiency/README.md
+++ b/experiments/analysis-efficiency/README.md
@@ -15,6 +15,10 @@ Follow-up: [Dart AOT summary and retained string pools](flutter-aot-summary.md)
records real LocalSend delivery pairs and old Evidence continuation, preserving
ABI selection, snapshot identity, original lower bounds and partial coverage.
+Follow-up: [SQLite existing-control measurements](sqlite-existing-controls.md)
+tests QGIS's shipped GeoPackage through the existing column selection, preserving
+full selected-table schema and typed cells before considering another API.
+
Follow-up: [native PE/DOS entry views and real model verification](native-entry-views.md)
records the post-merge native continuation, including a small-function case where
four selected replies cost more bytes than the complete dossier.
diff --git a/experiments/analysis-efficiency/agent-trial.py b/experiments/analysis-efficiency/agent-trial.py
index 682be27ec..6dcc7d7f8 100644
--- a/experiments/analysis-efficiency/agent-trial.py
+++ b/experiments/analysis-efficiency/agent-trial.py
@@ -178,6 +178,7 @@ def main():
'inspect_android_package', 'search_android_classes',
'inspect_android_class', 'inspect_android_method',
'inspect_firmware_regions', 'inspect_managed_artifact',
+ 'inspect_sqlite_database',
'inspect_managed_members', 'inspect_managed_native_boundaries',
'inspect_artifact', 'inspect_wasm_artifact',
'inspect_evm_interface', 'get_evidence_bundle', 'export_evidence_bundle',
diff --git a/experiments/analysis-efficiency/corpus.json b/experiments/analysis-efficiency/corpus.json
index 3b0f5c574..163b3b49a 100644
--- a/experiments/analysis-efficiency/corpus.json
+++ b/experiments/analysis-efficiency/corpus.json
@@ -250,6 +250,19 @@
"transport_bytes": 46558706,
"derivation": "Official arm64 release APK, compared with GitHub release asset digest; byte-only analysis without execution"
}
+ },
+ {
+ "id": "qgis-world-map.gpkg",
+ "file": "qgis-world-map.gpkg",
+ "bytes": 12914688,
+ "sha256": "01d293159d1b77bd9212b5252587853f61aa9e25b2387135f34afaba8d057190",
+ "provenance": {
+ "project": "QGIS bundled world map",
+ "source_commit": "e1223b8e9e71fc50ee995ed556f711c54ccc101b",
+ "git_blob_sha1": "c9ff80228dff0de93c9acdef16a97545ba360244",
+ "url": "https://raw.githubusercontent.com/qgis/QGIS/e1223b8e9e71fc50ee995ed556f711c54ccc101b/resources/data/world_map.gpkg",
+ "derivation": "unmodified official resource file; streaming Git blob and SHA-256 verification"
+ }
}
]
}
diff --git a/experiments/analysis-efficiency/measure.mjs b/experiments/analysis-efficiency/measure.mjs
index 8774ed30b..160973aa7 100644
--- a/experiments/analysis-efficiency/measure.mjs
+++ b/experiments/analysis-efficiency/measure.mjs
@@ -28,6 +28,7 @@ const { values } = parseArgs({
"javascript-question": { type: "string", default: "inventory" },
"managed-question": { type: "string", default: "inventory" },
schemas: { type: "string", default: "default" },
+ "sqlite-selection": { type: "string", default: "all" },
},
});
if (
@@ -43,6 +44,11 @@ if (
) ||
!["inventory", "selected"].includes(values["javascript-question"]) ||
!["default", "selected"].includes(values.schemas) ||
+ !["all", "columns"].includes(values["sqlite-selection"]) ||
+ (values.artifact !== "qgis-world-map.gpkg" &&
+ values["sqlite-selection"] !== "all") ||
+ (values.artifact === "qgis-world-map.gpkg" &&
+ values.delivery !== "complete") ||
!["inventory", "identity"].includes(values["managed-question"]) ||
(!["sharex.dll", "sharex.helpers.dll"].includes(values.artifact) &&
values["managed-question"] !== "inventory") ||
@@ -518,6 +524,54 @@ const inspectEvmTarget = async () => {
};
const workflows = {
+ "qgis-world-map.gpkg": async () => {
+ const columns = ["fid", "NAME", "ISO_A3"];
+ const input = {
+ path: values.path,
+ table: "countries",
+ row_limit: 1,
+ ...(values["sqlite-selection"] === "columns" ? { columns } : {}),
+ };
+ const definition = catalogDefinitions.get("inspect_sqlite_database");
+ if (
+ definition === undefined ||
+ !new Ajv2020({ strict: false, validateFormats: false }).validate(
+ definition.inputSchema,
+ input,
+ )
+ )
+ throw new Error(
+ "Selected SQLite request is not advertised by this runtime",
+ );
+ const inspected = await call("inspect_sqlite_database", input);
+ const report = normalized(inspected);
+ if (report.rows === null || report.rows.table !== "countries")
+ throw new Error("Runtime returned no selected country sample");
+ const indexes = columns.map((column) => {
+ const index = report.rows.columns.indexOf(column);
+ if (index < 0)
+ throw new Error(`Missing requested SQLite column ${column}`);
+ return index;
+ });
+ return {
+ artifact_sha256: targetSha(inspected),
+ artifact: report.artifact,
+ wal: report.wal,
+ engine: report.engine,
+ database_encoding: report.database_encoding,
+ schema: report.schema,
+ rows: {
+ ...report.rows,
+ columns,
+ values: report.rows.values.map((row) =>
+ indexes.map((index) => row[index]),
+ ),
+ },
+ producer_limitations: report.limitations,
+ limitations: inspected.limitations,
+ evidence_ids: [inspected.evidence_id],
+ };
+ },
"obsidian.asar": async () => {
if (values["javascript-question"] === "selected") {
const inspected = await call("analyze_javascript_application", {
@@ -902,6 +956,10 @@ const report = {
)
? values["managed-question"]
: null,
+ sqlite_selection:
+ values.artifact === "qgis-world-map.gpkg"
+ ? values["sqlite-selection"]
+ : null,
provider_settings_sha256: sha(JSON.stringify(selectedProviders)),
provider_search_path_sha256: sha(
selectedProviders.PATH ?? process.env.PATH ?? "",
diff --git a/experiments/analysis-efficiency/questions/sqlite-qgis.txt b/experiments/analysis-efficiency/questions/sqlite-qgis.txt
new file mode 100644
index 000000000..636c7a685
--- /dev/null
+++ b/experiments/analysis-efficiency/questions/sqlite-qgis.txt
@@ -0,0 +1 @@
+Statically inspect the supplied QGIS world-map GeoPackage at {{artifact_path}} using only the registered REA inspect_sqlite_database tool. Select the ordinary countries table with row_limit 1 and its default selected_table schema scope. Report the complete returned schema exactly as reported, including SQL, every declared column, indexes, views, triggers, completeness and explicit nulls. For the one returned sample, report only fid, NAME and ISO_A3 in that order, preserving the original tagged SQLite cells exactly: decimal integer strings, text values with bytes_hex and encoding, and SQL null. Preserve row_limit, returned_rows, truncated and order; row order is unspecified and this is not a complete country inventory. Preserve original artifact identity, wal including null, engine, database_encoding, both normalized-result producer limitations and outer Evidence limitations exactly including order. Do not return geometry blobs or unrequested sample cells, interpret boundaries/political labels as current facts, or infer missing values. Final JSON fields: artifact_sha256, artifact, wal, engine, database_encoding, schema, rows, producer_limitations, limitations, evidence_ids. Include real response Evidence IDs. Do not use shell or host file tools, network, other agents or execute QGIS. Stop once these facts are supported.
diff --git a/experiments/analysis-efficiency/review-model.py b/experiments/analysis-efficiency/review-model.py
index b8fe16bb3..0d6e1e8d7 100644
--- a/experiments/analysis-efficiency/review-model.py
+++ b/experiments/analysis-efficiency/review-model.py
@@ -69,7 +69,7 @@ def scope_with_verified_counts(value, expected_scope, returned_counts):
def main():
parser = argparse.ArgumentParser()
- parser.add_argument('--domain', choices=['wasm', 'evm', 'android', 'flutter-aot', 'managed', 'managed-nonempty', 'firmware', 'inventory', 'javascript', 'native', 'browser'], required=True)
+ parser.add_argument('--domain', choices=['wasm', 'evm', 'android', 'flutter-aot', 'sqlite', 'managed', 'managed-nonempty', 'firmware', 'inventory', 'javascript', 'native', 'browser'], required=True)
parser.add_argument('--trial', type=Path, required=True)
parser.add_argument('--reference', type=Path)
parser.add_argument('--resource-log', type=Path, required=True)
@@ -133,6 +133,11 @@ def main():
for key, expected in {'artifact_bytes': n['artifact']['bytes'], 'bytecode_sha256': n['bytecode']['sha256'], 'bytecode_bytes': n['bytecode']['bytes'], 'functions': n['functions'], 'evidence_kind': n['evidence_kind'], 'discovery_completeness': n['discovery_completeness'], 'runtime_execution': n['runtime_execution'], 'bytecode_kind': n['bytecode']['kind'], 'hardfork': n['bytecode']['hardfork'], 'deployment_authenticity': n['bytecode']['deployment_authenticity']}.items():
check(key, answer.get(key), expected)
check('evm_original_limitations', answer.get('limitations'), n['limitations'])
+ elif domain == 'sqlite':
+ for key in ['artifact', 'wal', 'engine', 'database_encoding', 'schema', 'rows', 'producer_limitations', 'limitations']:
+ # Missing is not SQL/JSON null; booleans are not numeric counters.
+ check(key, (key in answer, json.dumps(answer.get(key), sort_keys=True)),
+ (True, json.dumps(reference[key], sort_keys=True)))
elif domain == 'flutter-aot':
for key in ['target', 'abi', 'libapp', 'string_pool', 'coverage', 'limitations']:
check(key, answer.get(key), reference[key])
diff --git a/experiments/analysis-efficiency/sqlite-existing-controls.md b/experiments/analysis-efficiency/sqlite-existing-controls.md
new file mode 100644
index 000000000..9bac9f064
--- /dev/null
+++ b/experiments/analysis-efficiency/sqlite-existing-controls.md
@@ -0,0 +1,181 @@
+# SQLite existing-control measurements
+
+Continues [#2016](https://github.com/morluto/rea/issues/2016) after the Flutter
+lane. The measured main runtime is pinned to
+`172407787f1842dedbcd27b5385177595f786ce4`. Its only post-Flutter
+commit repairs Windows transient runtime admission (#2467); this Linux round
+does not establish Windows performance. No production/API change is proposed
+merely because another summary or cache could be added.
+
+Before PR integration, main advanced to
+`bb540826d65316c164d2de8ba4ef10f81a8ad073`. Its five intervening commits cover
+MCP archive contracts/documentation, an advertised-schema inventory test,
+pwntools cleanup identity, concurrent Mach-O slice inspection, and transient
+DMG verification retries. None changes the SQLite selector. Rebase and current
+main verification do not relabel the frozen measurements below.
+
+The committed post-format driver passes a real SDK smoke against the frozen
+runtime. After rebase, a fresh build, real all/selected-column SDK pair and docs
+check pass on the updated main. Selected facts match each other and the frozen
+runtime; artifact bytes remain unchanged. These are integration checks, not
+additional model performance pairs.
+
+## Real application data and question
+
+Use QGIS's bundled `world_map.gpkg`, unmodified from official immutable source
+commit `e1223b8e9e71fc50ee995ed556f711c54ccc101b`. `corpus.json` pins its URL,
+12,914,688-byte size, Git blob `c9ff80228dff0de93c9acdef16a97545ba360244`, and
+SHA-256 `01d293159d1b77bd9212b5252587853f61aa9e25b2387135f34afaba8d057190`.
+Streaming acquisition matches the upstream blob. The upstream resource install
+list includes `data/*`; [its README](https://github.com/qgis/QGIS/blob/e1223b8e9e71fc50ee995ed556f711c54ccc101b/resources/data/README_world_map.md)
+records data provenance. This is shipped application data, not a generated REA
+fixture. QGIS is neither installed nor executed.
+
+The source question asks for the complete returned `countries` table schema
+and one sample's `fid`, `NAME`, `ISO_A3` tagged cells, in that order. It preserves
+database/WAL identity, engine/encoding, exact original SQL and columns, integer
+strings, text bytes, nulls, row limit/count, unspecified order, truncation and
+both producer/outer limitations. It does not require geometry blobs or a full
+country inventory. Neither political labels nor the sampled ordering are
+inferred to be current or globally exhaustive.
+
+SQLite already defaults to compact schema inventory without a table, detailed
+selected-table schema with a table, and exposes exact `columns`. Selecting
+columns avoids constructing unrelated cells without changing the requested
+table schema. Thus compare existing all-visible columns against exact columns
+on the same main runtime, rather than changing an old user's default or adding
+a new delivery mechanism. Inventory scope cannot substitute for the detailed
+schema requested by this question.
+
+## Public SDK observations
+
+Frozen runtime: main `172407787f1842dedbcd27b5385177595f786ce4`, compiled
+dist/bridge file-table SHA-256
+`72a4ac6c6db491c455f3e4a798f8f52ed5850c08831e7cdc9f25c1525a5a1d11`.
+Node 24.18.0, SDK client/server 2.3.1, observed SQLite 3.53.1, WAL null. Driver,
+provider settings/search-path fingerprints, artifact and locked dependencies
+remain equal. Each workflow starts a fresh server and validates the selected
+actual advertised schema. Host page caches are not flushed.
+
+The initial default inventory returns 26 table entries, including explicit
+virtual/shadow classifications, no rows and `not_requested` column details:
+27,185 compact client-result JSON bytes. Selecting `countries` reveals eight
+columns, including a geometry blob. Its one-row full reply is 485,277 bytes.
+The selected request retains every requested declared-schema field and exact
+tagged sample values while omitting unrelated sample cells.
+
+| Trial | Reply bytes | SDK workflow seconds | Payload CPU seconds | Kernel peak bytes |
+| ------------- | ----------: | -------------------: | ------------------: | ----------------: |
+| sdk-all-1 | 485,277 | 11.820 | 12.755 | 562,884,608 |
+| sdk-columns-1 | 18,509 | 14.017 | 13.715 | 557,510,656 |
+| sdk-columns-2 | 18,509 | 15.826 | 14.162 | 548,343,808 |
+| sdk-all-2 | 485,277 | 5.679 | 7.310 | 561,704,960 |
+
+Both pairs make one successful call, pass settings/selected-fact comparison,
+retain complete original schema and all requested typed cells, and preserve
+source bytes. Reply bytes fall about 96.2%. These observations **do not show SDK
+speed or CPU improvement**: selected workflows are slower in both pairs and
+timing varies substantially. No cause is inferred from these data. Payload CPU
+includes runner/catalog work outside the SDK workflow timer. Memory changes
+are small and do not establish a general reduction. Raw replies and the exact
+pre-format driver source are archived privately.
+
+Reproduce with `measure.mjs --artifact qgis-world-map.gpkg` and
+`--sqlite-selection all|columns`, using the same `--runtime-root`, `--revision`,
+artifact path and `--schemas selected`. `--delivery` stays `complete`; this is
+an existing request selection, not a new summary API. Original caller arguments
+and schemas remain in the private receipts. The source question is
+`questions/sqlite-qgis.txt` and the selected factual grader is
+`review-model.py --domain sqlite`.
+
+## Decision and scope
+
+The initial four actual Codex attempts never executed SQLite: the client rejected
+the tool because its private snapshot writes require approval and the isolated
+driver lacked this exact tool's approval override. Every factual grade fails;
+these are failed client attempts, not completed-task performance comparisons.
+All profiles are removed. The existing experiment-only per-tool approval list
+now includes `inspect_sqlite_database`; each corrected trial exposes only that
+exact enabled tool. REA annotations, target authority and global Codex configuration
+are unchanged. The [official configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference)
+documents the per-tool override. Corrected cohorts use fresh output directories
+and retain the original failures separately.
+
+Actual repeated Codex comparisons determine the task token/time benefit before
+crediting smaller replies. An existing selector that answers the question is
+evidence against adding another production mechanism. Keep negative outcomes
+and unavailable coverage, including failure/retry costs and profile cleanup.
+
+Four corrected serial trials use Codex CLI 0.161.0, requested `gpt-5.6-terra`
+high, one enabled tool, identical owned context/discovery capture, and the same
+frozen main runtime. The requested alias is not a verified server-resolved model.
+Equal treatment instructions select the existing all-visible or exact-column
+request; the source question and requested schema/sample facts stay fixed.
+
+| Corrected trial | Input | Cached | Uncached | Output | Seconds | MCP calls | Facts |
+| ------------------------ | -----: | -----: | -------: | -----: | ------: | --------: | ----- |
+| approved-model-all-1 | 88,131 | 52,480 | 35,651 | 3,369 | 84.007 | 2 | pass |
+| approved-model-columns-1 | 37,340 | 20,992 | 16,348 | 3,006 | 87.514 | 1 | pass |
+| approved-model-columns-2 | 56,148 | 36,096 | 20,052 | 3,298 | 97.466 | 2 | pass |
+| approved-model-all-2 | 88,340 | 52,480 | 35,860 | 3,084 | 113.847 | 2 | pass |
+
+Both pairs pass every selected producer fact, exact SQL/schema/null/tagged
+cells, observed response Evidence IDs, target-byte preservation and profile
+cleanup. The grader checks producer observations, not independent geometry
+semantics. Actual events contain only the chosen MCP tool and agent messages;
+no shell or other agent execution appears. Usage is the final aggregate actually
+reported by the CLI, one usage record per trial; cached input remains included
+in total input. Output includes 853 / 357 / 523 / 664 reasoning tokens.
+
+Total input falls **57.63% / 36.44%**, uncached input **54.14% / 44.08%**.
+Time is mixed: columns are **4.17% slower** in pair 1 and **14.39% faster** in
+pair 2. Output is 10.77% lower / 6.94% higher. Repeated calls remain counted;
+pair 1 has two all-column calls versus one selected call, while pair 2 has two
+calls on both sides. This supports scoped token savings from using the existing
+selector, not a dependable speedup or a new API requirement. Client context and
+usage are observed separately from raw SDK reply bytes.
+
+Model payload CPU seconds / kernel peaks are 7.596 / 763,838,464;
+7.772 / 761,143,296; 8.409 / 762,597,376; and 11.759 / 774,025,216. CPU is mixed
+and memory differences are small. Payload cleanup/limit observations pass;
+missing terminal counters keep every strict whole-resource grade false.
+
+The original blocked attempts remain separate and never provide comparison
+denominators. They each have one failed call, no SQLite result and failed facts:
+
+| Blocked trial | Input | Cached | Uncached | Output | Seconds |
+| --------------- | -----: | -----: | -------: | -----: | ------: |
+| model-all-1 | 35,290 | 29,952 | 5,338 | 798 | 59.881 |
+| model-columns-1 | 35,356 | 22,016 | 13,340 | 670 | 44.764 |
+| model-columns-2 | 35,034 | 20,992 | 14,042 | 639 | 30.007 |
+| model-all-2 | 35,104 | 20,992 | 14,112 | 673 | 25.132 |
+
+**Decision: no production/API change.** Existing column selection materially
+reduces unnecessary delivery and actual model input while retaining the complete
+requested schema and exact cells. Existing documentation already recommends
+table/column selection. No additional summary, schema-scope default change,
+automatic field omission, plugin layer or cross-session cache is justified by
+these results. Preserve the negative/mixed time, output and CPU findings.
+
+Real one-shot CLI inventory, all-column sample and exact-column sample match
+the corresponding full MCP normalized results, subjects, parameters and IDs;
+the source digest remains unchanged. An independent Python binding using SQLite
+3.40.1 opens this immutable artifact read-only and agrees with the exact stored
+table SQL, eight declared columns and selected tagged sample cells. This is a
+second engine/binding observation of this file, not a general row-order or
+geometry-semantics guarantee. The six existing context/driver regression tests
+pass; Python compilation and JavaScript syntax checks pass. No new product test
+or production implementation is added for this measurement-only finding.
+
+Manual review tightens the SQLite grader to distinguish missing fields from
+explicit null and preserve JSON scalar types. Regrading all four stored model
+answers still passes every selected fact; deliberate omission of `wal` and a
+boolean substituted for numeric `row_limit` both fail. Original transcripts,
+usage and previous review receipts stay intact; no model trial is rerun.
+
+Heavy tasks remain serial under one CPU, unchanged 3 GiB early stop / 4 GiB hard
+memory, no swap, owned task scopes and host guards. SDK payload observations
+verify cleanup and zero phase limit events; final whole-scope counters remain
+unknown. This does not establish live/WAL atomicity, all SQLite applications,
+other runtimes/platforms or geospatial geometry semantics. Other packet/signal
+and capture lanes remain incremental follow-ups using their existing controls.
From fa4b8288655188426222773ead2ccbdaf76334fd Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:43:52 +0800
Subject: [PATCH 012/159] fix(cli): normalize empty Windows profile before
imports (#2482)
Follow-up to #2345; thanks to @gefrezim for the import-time home lookup diagnosis.
Normalize only an empty Windows USERPROFILE before runtime dependency imports, preserving all nonempty caller selections. Reuse the existing public setup dry-run regression in the Windows curated CI lane. No additional filesystem query, engine call, cache or retry.
Verified real Windows Node 24.18.0 CLI unset/empty/selected-profile controls, setup dry-run home selection and unchanged configuration, and baseline/candidate SDK stdio MCP catalogue parity. Nine related tests and static/docs checks passed. No IDA or Ghidra engine was used. Scoped resource observations and measurement limits are recorded in experiments/analysis-efficiency/windows-empty-profile.md.
Fixed the review finding and original CI package-scope ownership failure. Current-head CI is green; the unrelated MCP identity timeout passed on a failed-job rerun without changing its code or timeout. Current-head review completed with no further findings.
---
.github/workflows/ci.yml | 1 +
.../windows-empty-profile.md | 69 +++++++++++++++++++
scripts/ci/scopes.mjs | 1 +
scripts/rea.mjs | 6 ++
4 files changed, 77 insertions(+)
create mode 100644 experiments/analysis-efficiency/windows-empty-profile.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86f1ba2e4..6e6521b41 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -509,6 +509,7 @@ jobs:
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts
+ tests/acceptance/setup/homeDirectoryFallback.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/acceptance/webModuleCli.test.ts
diff --git a/experiments/analysis-efficiency/windows-empty-profile.md b/experiments/analysis-efficiency/windows-empty-profile.md
new file mode 100644
index 000000000..f8e5558e0
--- /dev/null
+++ b/experiments/analysis-efficiency/windows-empty-profile.md
@@ -0,0 +1,69 @@
+# Windows empty-profile startup verification
+
+Follow-up to [#2345](https://github.com/morluto/rea/pull/2345), including
+@gefrezim's diagnosis of the import-time Windows home lookup.
+
+## Source and boundary
+
+Baseline: main `172407787f1842dedbcd27b5385177595f786ce4`. Verification used
+the real Windows 11 10.0.22631.0 host, official Windows x64 Node 24.18.0,
+locked production dependencies and REA's compiled public CLI / SDK 2.3.1 MCP
+server. No IDA or Ghidra engine was used. Runtime copies and compact receipts
+live outside the checkout; no account profile path or ambient environment is
+recorded here.
+
+The tested dispatcher SHA-256 is
+`526105418604cc6e5a202a6275b700814e27450bfaffd45e7510530ba669de8d`.
+Only the dispatcher changed in the deployed runtime. It treats an empty Windows
+`USERPROFILE` as unset before dependency imports. Nonempty selections continue
+to reach the existing home resolver; no additional lookup or fallback owner is
+introduced.
+
+## Real Windows observations
+
+With `HOME` empty in each case, the baseline produced:
+
+| USERPROFILE selection | `--version` | `--help` | MCP discovery |
+| --------------------------- | ----------------------- | ----------------------- | ------------- |
+| Unset | Exit 0 | Exit 0 | Healthy |
+| Empty | Exit 1, `uv_os_homedir` | Exit 1, `uv_os_homedir` | Healthy |
+| Nonempty test-owned profile | Exit 0 | Exit 0 | Healthy |
+
+The corrected CLI passed all six invocations with no stderr. Version output
+remained 6 bytes and help output 15,497 bytes. Real setup dry runs with empty
+and nonempty profile selections both exited 0, planned their configuration
+under the expected home, preserved existing configuration bytes and created no
+configuration under the working directory. No installation was performed.
+
+MCP was already healthy in the baseline; this change does not claim to repair
+an MCP startup failure. Complete baseline discovery was 187 tools and 2,545,790
+serialized bytes, SHA-256
+`1e6bbca7f62852134eb703ea098f5a302d1fe333b8433475429f6fc1b1aba18f`.
+All three corrected MCP controls also passed with no stderr and the same
+catalogue bytes and digest.
+
+The existing `homeDirectoryFallback` acceptance test exercises the import-time
+failure through the public setup dry run. Adding it to the Windows curated lane
+makes that regression execute on the affected platform. No duplicate startup
+test was retained. Nine related tests passed, including the existing MCP doctor
+and curated-lane path checks; Linux passing alone does not prove Windows behavior.
+Review caught a missing package-scope registration for the newly listed test;
+the original CI shard failed that ownership invariant. The path is now also
+registered in `scripts/ci/scopes.mjs`, and all 84 scope checks plus the two
+curated-path checks pass. Runtime source and its tested digest are unchanged.
+
+## Efficiency and resource limits
+
+Heavy host probes ran serially at BelowNormal priority, with one CPU and a
+512 MiB CLI/server heap setting (256 MiB for the probe parent). Heap settings do
+not bound total process memory. An exit-time `process.resourceUsage()` observer
+recorded individual corrected CLI invocations: CPU time ranged from 7.968 to
+9.874 seconds and peak working set from 299,956 to 301,852 KiB. These figures
+exclude the probe parent and any observer overhead outside the CLI process.
+The six observed wall times ranged from 8.656 to 10.961 seconds.
+
+The baseline did not use that resource observer, and cold/warm caches were not
+normalized. These observations establish functional behavior and bounded probe
+scope; no CPU, memory or latency improvement is claimed. The fix adds one
+constant-time environment check, no filesystem lookup, engine call, retry or
+cache. Output bytes are not model tokens; actual model usage was not measured.
diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs
index 497dfeed0..4493fe512 100644
--- a/scripts/ci/scopes.mjs
+++ b/scripts/ci/scopes.mjs
@@ -55,6 +55,7 @@ export const SCOPES = {
"tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts",
"tests/boundary/filesystem/setupUnavailableProvider.test.ts",
"tests/boundary/filesystem/windowsNpxRegistration.test.ts",
+ "tests/acceptance/setup/homeDirectoryFallback.test.ts",
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
"tests/boundary/process/ProcessEvidence.host.test.ts",
"tests/acceptance/webModuleCli.test.ts",
diff --git a/scripts/rea.mjs b/scripts/rea.mjs
index 78ffbdd21..75ee552c5 100755
--- a/scripts/rea.mjs
+++ b/scripts/rea.mjs
@@ -16,6 +16,12 @@ const isMcpMode =
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+// Windows home lookup throws for an empty USERPROFILE. Dependencies resolve
+// home during import, before REA's own resolver can apply its account fallback.
+if (process.platform === "win32" && process.env.USERPROFILE === "") {
+ delete process.env.USERPROFILE;
+}
+
// Playwright's registry assumes one of its three desktop host platforms when
// it chooses the browser cache directory. Termux reports `android`, although
// its Node runtime and filesystem are POSIX-compatible. Keep the cache path
From b045df100e1489e08a36d6ff04cea7feecf1332c Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:46:05 +0800
Subject: [PATCH 013/159] fix(update): share parsed client activation with
maintenance
---
src/application/ClientRegistrationStatus.ts | 106 +++++++++++-------
src/application/UpdateMaintenance.ts | 51 ++-------
.../process-global/updateMaintenance.test.ts | 35 ++++++
3 files changed, 112 insertions(+), 80 deletions(-)
diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts
index e2696e088..caeba4fb6 100644
--- a/src/application/ClientRegistrationStatus.ts
+++ b/src/application/ClientRegistrationStatus.ts
@@ -7,6 +7,7 @@ import {
clientServerForcedEnabled,
clientServerListedDisabled,
effectiveClientServer,
+ type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { access } from "node:fs/promises";
import { resolve } from "node:path";
@@ -74,24 +75,48 @@ const registrationSchema = z
})
.passthrough();
-/** Inspect supported client registrations without reading their environment. */
+interface ClientRegistrationReadOptions {
+ readonly platform?: NodeJS.Platform;
+ readonly environment?: NodeJS.ProcessEnv;
+ readonly resources?: ArtifactResourceScope;
+}
+
+/** Registration facts shared by diagnostics and update maintenance. */
+export interface ClientRegistrationInspection {
+ readonly status: ClientRegistrationStatus;
+ readonly activation: "enabled" | "disabled" | "unknown";
+}
+
+/** Project diagnostics from the same inspection used to select maintenance. */
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
- options: {
- readonly platform?: NodeJS.Platform;
- readonly environment?: NodeJS.ProcessEnv;
- readonly resources?: ArtifactResourceScope;
- } = {},
-): Promise => {
- const statuses: ClientRegistrationStatus[] = [];
+ options: ClientRegistrationReadOptions = {},
+): Promise =>
+ (
+ await readClientRegistrationInspections(home, currentCommandPath, options)
+ ).map(({ status }) => status);
+
+/** Parse effective registrations and their client-specific activation once. */
+export const readClientRegistrationInspections = async (
+ home: string,
+ currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
+ options: ClientRegistrationReadOptions = {},
+): Promise => {
+ const inspections: ClientRegistrationInspection[] = [];
+ const record = (
+ status: ClientRegistrationStatus,
+ activation: ClientRegistrationInspection["activation"] = "unknown",
+ ): void => {
+ inspections.push({ status, activation });
+ };
for (const client of supportedClients(
home,
options.platform,
options.environment,
)) {
if (client.configPathError !== undefined) {
- statuses.push({
+ record({
client: client.name,
config_path: client.configPath,
command: [],
@@ -104,7 +129,7 @@ export const readClientRegistrationStatuses = async (
const manualRemediation = manualRegistrationRemediation(client.name);
if (client.format === "unsupported") {
if (manualRemediation !== undefined)
- statuses.push({
+ record({
client: client.name,
config_path: client.markerPath ?? client.configPath,
command: [],
@@ -119,7 +144,7 @@ export const readClientRegistrationStatuses = async (
options.resources,
);
if (!files.ok) {
- statuses.push({
+ record({
client: client.name,
config_path: client.configPath,
command: [],
@@ -130,9 +155,7 @@ export const readClientRegistrationStatuses = async (
}
const parsed = effectiveClientConfiguration(files.value);
if (parsed === undefined) {
- statuses.push(
- unavailableStatus(client.name, client.configPath, "missing"),
- );
+ record(unavailableStatus(client.name, client.configPath, "missing"));
continue;
}
const policy = await readClientPolicyBlock(
@@ -142,7 +165,7 @@ export const readClientRegistrationStatuses = async (
options.resources,
);
if (!policy.ok) {
- statuses.push({
+ record({
client: client.name,
config_path: client.configPath,
command: [],
@@ -159,7 +182,7 @@ export const readClientRegistrationStatuses = async (
? dshDocument(parsed.document).editabilityError
: undefined;
if (editabilityError !== undefined) {
- statuses.push({
+ record({
client: client.name,
config_path: client.configPath,
command: [],
@@ -168,9 +191,7 @@ export const readClientRegistrationStatuses = async (
});
continue;
}
- statuses.push(
- unavailableStatus(client.name, client.configPath, "missing"),
- );
+ record(unavailableStatus(client.name, client.configPath, "missing"));
continue;
}
const registration = parseRegistration(raw, client);
@@ -178,21 +199,18 @@ export const readClientRegistrationStatuses = async (
registration.command,
...registration.args,
];
- statuses.push(
+ const activation = registrationActivation(registration, client, parsed);
+ record(
configuredStatus(
client.name,
client.configPath,
command,
- registrationAligned(
- registration,
- client,
- currentCommandPath,
- options.platform ?? process.platform,
- clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
- ) &&
- !clientServerListedDisabled(
- parsed,
- PRODUCT_IDENTITY.mcpServerKey,
+ activation === "enabled" &&
+ registrationAligned(
+ registration,
+ client,
+ currentCommandPath,
+ options.platform ?? process.platform,
) &&
(parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) &&
policyBlock === undefined
@@ -200,9 +218,10 @@ export const readClientRegistrationStatuses = async (
: "stale",
policyBlock,
),
+ activation,
);
} catch (cause: unknown) {
- statuses.push(
+ record(
unavailableStatus(
client.name,
client.configPath,
@@ -211,26 +230,31 @@ export const readClientRegistrationStatuses = async (
);
}
}
- return statuses.sort((left, right) =>
- compareUnicodeCodePoints(left.client, right.client),
+ return inspections.sort((left, right) =>
+ compareUnicodeCodePoints(left.status.client, right.status.client),
);
};
+const registrationActivation = (
+ registration: z.output,
+ client: SetupClient,
+ parsed: ClientConfigurationDocument,
+): "enabled" | "disabled" =>
+ (client.format !== "grok" && registration.disabled === true) ||
+ (registration.enabled === false &&
+ !clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey)) ||
+ (client.format === "zcode" && registration.enable === false) ||
+ clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
+ ? "disabled"
+ : "enabled";
+
const registrationAligned = (
registration: z.output,
client: SetupClient,
currentCommandPath: string,
platform: NodeJS.Platform,
- forcedEnabled = false,
): boolean => {
const command = [registration.command, ...registration.args];
- if (
- (client.format !== "grok" && registration.disabled === true) ||
- (registration.enabled === false && !forcedEnabled) ||
- // ZCode's native flag is `enable`; other dialects leave that field opaque.
- (client.format === "zcode" && registration.enable === false)
- )
- return false;
if (!isOwnedClientRegistrationCommand(command, currentCommandPath))
return false;
if (
diff --git a/src/application/UpdateMaintenance.ts b/src/application/UpdateMaintenance.ts
index 06ac1b512..fd55c8f23 100644
--- a/src/application/UpdateMaintenance.ts
+++ b/src/application/UpdateMaintenance.ts
@@ -1,19 +1,10 @@
import { z } from "zod";
-import { PRODUCT_IDENTITY } from "../identity.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
-import { readClientRegistrationStatuses } from "./ClientRegistrationStatus.js";
-import {
- clientServerForcedEnabled,
- clientServerListedDisabled,
- effectiveClientServer,
- parseClientConfiguration,
-} from "./ClientConfigurationDocument.js";
-import { supportedClients } from "./SupportedClients.js";
+import { readClientRegistrationInspections } from "./ClientRegistrationStatus.js";
import { existingSkillDestinations } from "./SetupSkill.js";
import type { SetupAction } from "./SetupTypes.js";
import type { Result } from "../domain/result.js";
-import { readRegularFileText } from "./RegularFileRead.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
/** Existing REA integrations selected for maintenance, without discovering new targets. */
@@ -61,41 +52,23 @@ export const existingMaintenanceScope = async (
environment: Readonly,
resources?: ArtifactResourceScope,
): Promise => {
- const registrations = await readClientRegistrationStatuses(home, entryPoint, {
- environment,
- ...(resources === undefined ? {} : { resources }),
- });
- const supported = supportedClients(home, process.platform, environment);
+ const registrations = await readClientRegistrationInspections(
+ home,
+ entryPoint,
+ {
+ environment,
+ ...(resources === undefined ? {} : { resources }),
+ },
+ );
const clients: string[] = [];
- for (const registration of registrations) {
+ for (const { status: registration, activation } of registrations) {
if (
+ activation !== "enabled" ||
(registration.state !== "aligned" && registration.state !== "stale") ||
!isOwnedClientRegistrationCommand(registration.command, entryPoint)
)
continue;
- const client = supported.find(({ name }) => name === registration.client);
- if (client === undefined) continue;
- const parsed = parseClientConfiguration(
- await readRegularFileText(
- client.configPath,
- resources === undefined ? {} : { resources },
- ),
- client.format,
- );
- const enabled = z
- .object({
- enabled: z.boolean().optional(),
- disabled: z.boolean().optional(),
- })
- .parse(effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey));
- // Root disable lists override enabled entries and OMP's force-on allowlist.
- if (
- (enabled.enabled !== false ||
- clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey)) &&
- enabled.disabled !== true &&
- !clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
- )
- clients.push(client.name);
+ clients.push(registration.client);
}
const skillDestinations = await existingSkillDestinations(
home,
diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts
index a60a2e213..ae057bab2 100644
--- a/tests/process-global/updateMaintenance.test.ts
+++ b/tests/process-global/updateMaintenance.test.ts
@@ -126,6 +126,41 @@ describe("OMP integration maintenance", () => {
});
});
+describe("maintenance activation and layered configuration", () => {
+ it("leaves a natively disabled ZCode registration outside maintenance", async () => {
+ const configPath = await writeClient(
+ "zcode",
+ JSON.stringify({
+ mcp: {
+ servers: {
+ rea: { command: entryPoint, args: ["mcp"], enable: false },
+ },
+ },
+ }),
+ );
+ const original = await readFile(configPath, "utf8");
+ await expect(
+ existingMaintenanceScope(home, entryPoint, process.env),
+ ).resolves.toMatchObject({ clients: [] });
+ expect(await readFile(configPath, "utf8")).toBe(original);
+ });
+
+ it("selects an enabled registration inherited through layered OpenCode files", async () => {
+ const directory = join(home, ".config", "opencode");
+ await mkdir(directory, { recursive: true });
+ await writeFile(
+ join(directory, "opencode.json"),
+ JSON.stringify({
+ mcp: { rea: { type: "local", command: [entryPoint, "mcp"] } },
+ }),
+ );
+ await writeClient("opencode", JSON.stringify({ theme: "user-selected" }));
+ await expect(
+ existingMaintenanceScope(home, entryPoint, process.env),
+ ).resolves.toMatchObject({ clients: ["opencode"] });
+ });
+});
+
describe("existing REA integration maintenance", () => {
it("selects owned registrations while preserving unconfigured, foreign, and disabled choices", async () => {
await writeClient("codex", staleCodex);
From 042dcaa1ee66f1b8668f08b193f38ab01bceaacb Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:46:06 +0800
Subject: [PATCH 014/159] perf(evidence): reuse authenticated unknown mutation
snapshots
---
.../EvidenceLedger.residualUnknown.test.ts | 45 +++++++++++++++++++
.../investigation/EvidenceLedger.ts | 14 +++---
tests/boundary/mcp/serverUnknownsMcp.test.ts | 15 +++----
3 files changed, 57 insertions(+), 17 deletions(-)
diff --git a/src/application/investigation/EvidenceLedger.residualUnknown.test.ts b/src/application/investigation/EvidenceLedger.residualUnknown.test.ts
index bb81f97ce..2885c1906 100644
--- a/src/application/investigation/EvidenceLedger.residualUnknown.test.ts
+++ b/src/application/investigation/EvidenceLedger.residualUnknown.test.ts
@@ -78,6 +78,51 @@ const update = (
});
describe("residual unknown registry reads and identity", () => {
+ it("seals mutation Evidence without freezing caller-owned inputs", () => {
+ const store = ledger();
+ const recordMutation = mutation("mutable-record-mutation");
+ const recorded = store.recordUnknown(
+ input("Does record mutation evidence stay stable?"),
+ recordMutation,
+ );
+ if (!recorded.ok) throw recorded.error;
+
+ const updateMutation = mutation("mutable-update-mutation");
+ const updated = store.updateUnknown(update(recorded.value), updateMutation);
+ if (!updated.ok) throw updated.error;
+ const atomicEvidence = evidence("mutable-atomic-evidence");
+ const atomicMutation = mutation("mutable-atomic-mutation");
+ const atomicallyRecorded = store.recordWithUnknown(
+ atomicEvidence,
+ input("Does atomic evidence stay stable?"),
+ atomicMutation,
+ );
+ if (!atomicallyRecorded.ok) throw atomicallyRecorded.error;
+
+ for (const callerEvidence of [
+ recordMutation,
+ updateMutation,
+ atomicEvidence,
+ atomicMutation,
+ ]) {
+ expect(Object.isFrozen(callerEvidence)).toBe(false);
+ const retained = store.forAnalysis(callerEvidence.evidence_id);
+ if (retained === undefined) throw new Error("Missing retained Evidence");
+ const originalResult = structuredClone(retained.normalized_result);
+ expect(Object.isFrozen(retained)).toBe(true);
+ Reflect.set(callerEvidence, "operation", "caller mutation");
+ expect(
+ Reflect.set(
+ callerEvidence.normalized_result as object,
+ "label",
+ "caller nested mutation",
+ ),
+ ).toBe(true);
+ expect(retained.operation).not.toBe("caller mutation");
+ expect(retained.normalized_result).toEqual(originalResult);
+ }
+ });
+
it("merges record-only bundles without changing retained unknown history", () => {
const store = ledger();
const created = store.recordUnknown(
diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts
index ce806ad38..0cb1c5e29 100644
--- a/src/application/investigation/EvidenceLedger.ts
+++ b/src/application/investigation/EvidenceLedger.ts
@@ -11,11 +11,7 @@ import {
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
-import {
- immutableEvidence,
- parseEvidence,
- type Evidence,
-} from "../../domain/evidence.js";
+import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
import {
createResidualUnknown,
updateResidualUnknown,
@@ -220,7 +216,7 @@ export class EvidenceLedger {
): Result {
let parsedMutation: Evidence;
try {
- parsedMutation = parseEvidence(mutationEvidence);
+ parsedMutation = immutableEvidence(mutationEvidence);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Unknown mutation evidence is invalid", {
@@ -255,8 +251,8 @@ export class EvidenceLedger {
let evidence: Evidence;
let mutation: Evidence;
try {
- evidence = parseEvidence(evidenceInput);
- mutation = parseEvidence(mutationInput);
+ evidence = immutableEvidence(evidenceInput);
+ mutation = immutableEvidence(mutationInput);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Atomic Evidence validation failed", {
@@ -394,7 +390,7 @@ export class EvidenceLedger {
): Result {
let mutationEvidence: Evidence;
try {
- mutationEvidence = parseEvidence(mutationEvidenceInput);
+ mutationEvidence = immutableEvidence(mutationEvidenceInput);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Unknown mutation evidence is invalid", {
diff --git a/tests/boundary/mcp/serverUnknownsMcp.test.ts b/tests/boundary/mcp/serverUnknownsMcp.test.ts
index 0a27324ec..e39ce2aec 100644
--- a/tests/boundary/mcp/serverUnknownsMcp.test.ts
+++ b/tests/boundary/mcp/serverUnknownsMcp.test.ts
@@ -281,7 +281,7 @@ it("pages filtered unknown heads through the public MCP contract", async () => {
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
});
- expect(result.ok).toBe(true);
+ if (!result.ok) throw result.error;
}
const defaultPage = structured(
@@ -372,12 +372,11 @@ it("pages filtered unknown heads through the public MCP contract", async () => {
.result as typeof defaultPage;
expect(first).toMatchObject({ total: 3, offset: 0, next_offset: 2 });
expect(first.items).toHaveLength(2);
- const second = structured(
- await client.callTool({
- ...request,
- arguments: { ...request.arguments, offset: first.next_offset ?? 0 },
- }),
- ).result as typeof defaultPage;
+ const secondResponse = await client.callTool({
+ ...request,
+ arguments: { ...request.arguments, offset: first.next_offset ?? 0 },
+ });
+ const second = structured(secondResponse).result as typeof defaultPage;
expect(second).toMatchObject({ total: 3, offset: 2, next_offset: null });
expect(second.items).toHaveLength(1);
expect(
@@ -389,7 +388,7 @@ it("pages filtered unknown heads through the public MCP contract", async () => {
);
expect(
toolContract("list_unknowns").outputSchema.safeParse(
- (await client.callTool(request)).structuredContent,
+ secondResponse.structuredContent,
).success,
).toBe(true);
From 9581e067ad306aab842024e47f2dd18ac8056859 Mon Sep 17 00:00:00 2001
From: Quentin Limo <85309073+3QL@users.noreply.github.com>
Date: Tue, 13 Oct 2026 06:53:03 +0200
Subject: [PATCH 015/159] feat(pcap): add bounded capture summaries through CLI
and MCP
Summarize saved Wireshark4.x captures with enforced native resource limits, serialized admission, stable snapshot verification and retained fact/text pages. Preserve complete inventories within resource budgets and source uncertainty; report cleanup failure with partial Evidence and retained ownership.
Verified six recorded corpora /13026 packets with independent Scapy/Python oracles, real CLI/MCP/schema parity,60 SNI names,132 DNS addresses and1000 URIs. Real TShark CI now exercises summary/paging. Actual Windows/Bun Wireshark remains unverified; macOS capture requires a separate enforceable-bound decision.
Co-authored-by: 3QL <85309073+3QL@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
---
.../skills/reverse-engineer-anything/SKILL.md | 6 +
.github/workflows/real-packet-capture.yml | 8 +
README.md | 1 +
README_ar.md | 1 +
README_de.md | 1 +
README_es.md | 1 +
README_fa.md | 1 +
README_fr.md | 1 +
README_id.md | 1 +
README_it.md | 1 +
README_ja.md | 1 +
README_ko.md | 1 +
README_pl.md | 1 +
README_pt-BR.md | 1 +
README_ru.md | 1 +
README_th.md | 1 +
README_tr.md | 1 +
README_uk.md | 1 +
README_vi.md | 1 +
README_zh-TW.md | 1 +
README_zh.md | 1 +
docs/pcap-analysis.md | 118 ++++
docs/testing.md | 8 +
package.json | 1 +
scripts/lib/catalog-builders.mjs | 9 +
scripts/lib/catalog-core.mjs | 4 +-
scripts/verify-packet-capture.mjs | 46 ++
scripts/verify-pcap.mjs | 131 +++++
src/application/CapabilityInventory.ts | 2 +-
src/application/pcap/PcapAnalysisPort.ts | 23 +
src/application/pcap/PcapAnalysisService.ts | 36 ++
src/cli.ts | 2 +
src/cli/pcapCommands.ts | 69 +++
src/cliCommandNames.ts | 2 +
src/composition/pcap.ts | 7 +
src/contracts/analysisViewToolContracts.ts | 2 +-
src/contracts/pcap/pcapToolContracts.ts | 46 ++
src/contracts/toolContractTypes.ts | 1 +
src/contracts/toolContracts.ts | 2 +
src/contracts/toolEffects.test.ts | 2 +
src/contracts/toolEffects.ts | 10 +
src/domain/analysisView/analysisView.ts | 28 +
src/domain/analysisView/pcapCaptureView.ts | 327 +++++++++++
src/domain/pcap/pcapAnalysis.ts | 110 ++++
src/domain/pcap/pcapEvidence.ts | 34 ++
src/pcap/PcapCommand.ts | 207 +++++++
src/pcap/PcapConfiguration.test.ts | 170 ++++++
src/pcap/PcapConfiguration.ts | 153 ++++++
src/pcap/PcapNativeBound.ts | 68 +++
src/pcap/PcapOutput.test.ts | 149 +++++
src/pcap/PcapOutput.ts | 315 +++++++++++
src/pcap/PcapProvider.ts | 493 +++++++++++++++++
src/pcap/PcapSnapshot.ts | 269 ++++++++++
src/process/DarwinProcessRunTokenReader.ts | 6 +-
src/server/createServer.ts | 15 +
src/server/registerPcapTools.ts | 83 +++
tests/boundary/cli/pcapCli.test.ts | 23 +
tests/boundary/mcp/pcapMcp.test.ts | 118 ++++
tests/boundary/pcap/pcapCommand.test.ts | 53 ++
tests/boundary/pcap/pcapProvider.test.ts | 508 ++++++++++++++++++
.../pcap/pcapProviderCrossPlatform.test.ts | 144 +++++
tests/boundary/pcap/pcapRetainedView.test.ts | 196 +++++++
.../darwinProcessRunTokenReader.test.ts | 27 +-
tests/fixtures/localToolsMcp.ts | 5 +-
tests/fixtures/pcap/capinfos-stub.cmd | 19 +
tests/fixtures/pcap/capinfos-stub.sh | 11 +
tests/fixtures/pcap/tshark-stub.cmd | 17 +
tests/fixtures/pcap/tshark-stub.sh | 6 +
vitest.config.ts | 2 +-
69 files changed, 4082 insertions(+), 28 deletions(-)
create mode 100644 docs/pcap-analysis.md
create mode 100644 scripts/verify-pcap.mjs
create mode 100644 src/application/pcap/PcapAnalysisPort.ts
create mode 100644 src/application/pcap/PcapAnalysisService.ts
create mode 100644 src/cli/pcapCommands.ts
create mode 100644 src/composition/pcap.ts
create mode 100644 src/contracts/pcap/pcapToolContracts.ts
create mode 100644 src/domain/analysisView/pcapCaptureView.ts
create mode 100644 src/domain/pcap/pcapAnalysis.ts
create mode 100644 src/domain/pcap/pcapEvidence.ts
create mode 100644 src/pcap/PcapCommand.ts
create mode 100644 src/pcap/PcapConfiguration.test.ts
create mode 100644 src/pcap/PcapConfiguration.ts
create mode 100644 src/pcap/PcapNativeBound.ts
create mode 100644 src/pcap/PcapOutput.test.ts
create mode 100644 src/pcap/PcapOutput.ts
create mode 100644 src/pcap/PcapProvider.ts
create mode 100644 src/pcap/PcapSnapshot.ts
create mode 100644 src/server/registerPcapTools.ts
create mode 100644 tests/boundary/cli/pcapCli.test.ts
create mode 100644 tests/boundary/mcp/pcapMcp.test.ts
create mode 100644 tests/boundary/pcap/pcapCommand.test.ts
create mode 100644 tests/boundary/pcap/pcapProvider.test.ts
create mode 100644 tests/boundary/pcap/pcapProviderCrossPlatform.test.ts
create mode 100644 tests/boundary/pcap/pcapRetainedView.test.ts
create mode 100644 tests/fixtures/pcap/capinfos-stub.cmd
create mode 100755 tests/fixtures/pcap/capinfos-stub.sh
create mode 100644 tests/fixtures/pcap/tshark-stub.cmd
create mode 100755 tests/fixtures/pcap/tshark-stub.sh
diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md
index 3b508af1c..f8ec45268 100644
--- a/.agents/skills/reverse-engineer-anything/SKILL.md
+++ b/.agents/skills/reverse-engineer-anything/SKILL.md
@@ -83,6 +83,12 @@ explicit path or endpoint and do not need it.
frames; use `match_offset` / `match_limit` for later pages, or explicit null
limit for complete matching output. Preserve the source digest across pages.
This does not perform live capture.
+ For address/DNS/SNI/protocol summaries use `inspect_pcap_capture` with MCP
+ detail summary, then retained kind pcap pages. Requires Wireshark 4.x tshark
+ and capinfos, with Linux prlimit or Windows Job Objects; see
+ [packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md).
+ Select an exact DNS question for address pages; page exhaustion does not prove
+ network completeness. This reads recordings and does not acquire traffic.
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md).
diff --git a/.github/workflows/real-packet-capture.yml b/.github/workflows/real-packet-capture.yml
index 8faf9ba99..f481ed00d 100644
--- a/.github/workflows/real-packet-capture.yml
+++ b/.github/workflows/real-packet-capture.yml
@@ -7,6 +7,13 @@ on:
- "tests/boundary/cli/captureCleanup.test.ts"
- "tests/boundary/mcp/packetCaptureMcp.test.ts"
- "src/captures/**"
+ - "src/pcap/**"
+ - "src/domain/pcap/**"
+ - "src/domain/analysisView/pcapCaptureView.ts"
+ - "src/application/pcap/**"
+ - "src/contracts/pcap/**"
+ - "src/server/registerPcapTools.ts"
+ - "tests/boundary/pcap/**"
- "src/domain/captures/**"
- "src/application/captures/**"
- "src/contracts/captures/**"
@@ -48,3 +55,4 @@ jobs:
- run: node scripts/verify-packet-capture.mjs
env:
REA_TSHARK_COMMAND: /usr/bin/tshark
+ REA_CAPINFOS_COMMAND: /usr/bin/capinfos
diff --git a/README.md b/README.md
index 49173ab4b..6b78543ef 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,7 @@ Additional tools and host support depend on the target:
| Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) |
| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
| Recorded packets | Selected fields, repeated values, filters and decode-as rules | Linux: TShark 4.x + prlimit; [capture guide](docs/packet-captures.md) |
+| Packet summaries | Endpoints, DNS, TLS SNI, protocol distribution and HTTP requests | Caller-supplied tshark and capinfos (Linux-verified); [pcap guide](docs/pcap-analysis.md) |
| Recorded logic signals | Decoder annotations, sample ranges and diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [signal recordings](docs/signal-recordings.md) |
| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
diff --git a/README_ar.md b/README_ar.md
index 0693fe67f..4c33ac544 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -139,6 +139,7 @@ npx rea-agents@latest setup
| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) |
| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) |
| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](docs/web-network-captures.md) |
+| التقاط الحزم | نقاط النهاية وDNS وTLS SNI وتوزيع البروتوكولات وطلبات HTTP | tshark وcapinfos يوفّرهما المُستدعي؛ [دليل pcap](docs/pcap-analysis.md) |
| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](docs/managed-code-analysis.md) |
| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](docs/android-analysis.md) |
| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) |
diff --git a/README_de.md b/README_de.md
index 1e96cd46b..bf456b12d 100644
--- a/README_de.md
+++ b/README_de.md
@@ -139,6 +139,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) |
| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) |
| Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](docs/web-network-captures.md) |
+| Paketerfassungen | Endpunkte, DNS, TLS SNI, Protokollverteilung und HTTP-Anfragen | Vom Aufrufer bereitgestellte tshark- und capinfos-Binaries; [pcap-Leitfaden](docs/pcap-analysis.md) |
| .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](docs/managed-code-analysis.md) |
| Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](docs/android-analysis.md) |
| Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](docs/adb-device-analysis.md) |
diff --git a/README_es.md b/README_es.md
index 9a59a74d2..db20cd717 100644
--- a/README_es.md
+++ b/README_es.md
@@ -139,6 +139,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) |
| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) |
| Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](docs/web-network-captures.md) |
+| Capturas de paquetes | Endpoints, DNS, TLS SNI, distribución de protocolos y solicitudes HTTP | tshark y capinfos suministrados por el autor de la llamada; [guía pcap](docs/pcap-analysis.md) |
| Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](docs/managed-code-analysis.md) |
| APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](docs/android-analysis.md) |
| Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](docs/adb-device-analysis.md) |
diff --git a/README_fa.md b/README_fa.md
index e34788e7a..f2c358c63 100644
--- a/README_fa.md
+++ b/README_fa.md
@@ -139,6 +139,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
| برنامههای فلاتر | هش اسنپشات دارت، هش کتابخانهها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) |
| Firmware | ناحیهها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) |
| بستههای ضبطشده | فیلدهای منتخب، مقادیر تکراری، فیلترها و قواعد رمزگشایی | Linux: TShark 4.x + prlimit; [راهنمای ضبط بسته](docs/packet-captures.md) |
+| ضبط بستهها | نقاط پایانی، DNS، TLS SNI، توزیع پروتکل و درخواستهای HTTP | tshark و capinfos تأمینشده توسط فراخوانکننده؛ [راهنمای pcap](docs/pcap-analysis.md) |
| سیگنالهای منطقی ضبطشده | توضیحات رمزگشا، بازههای نمونه و اطلاعات عیبیابی | Linux: sigrok-cli 0.7.2 + prlimit; [راهنمای سیگنالها](docs/signal-recordings.md) |
| بستهها و منابع | فهرست فایلها، digestها، فایلهای plist، ساختار bundle اپل و منابع استخراجشده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامههای اپل](docs/apple-application-analysis.md) |
| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) |
diff --git a/README_fr.md b/README_fr.md
index f28e53f1a..41281ce5c 100644
--- a/README_fr.md
+++ b/README_fr.md
@@ -143,6 +143,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) |
| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) |
| Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](docs/web-network-captures.md) |
+| Captures de paquets | Points de terminaison, DNS, TLS SNI, distribution des protocoles et requêtes HTTP | tshark et capinfos fournis par l'appelant ; [guide pcap](docs/pcap-analysis.md) |
| Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](docs/managed-code-analysis.md) |
| APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](docs/android-analysis.md) |
| Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](docs/adb-device-analysis.md) |
diff --git a/README_id.md b/README_id.md
index 440a8f81b..e5e3dd1a4 100644
--- a/README_id.md
+++ b/README_id.md
@@ -146,6 +146,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) |
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) |
| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) |
+| Capturan paket | Endpoint, DNS, TLS SNI, distribusi protokol, dan permintaan HTTP | tshark dan capinfos buatan pemanggil; [panduan pcap](docs/pcap-analysis.md) |
| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) |
| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) |
| Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](docs/adb-device-analysis.md) |
diff --git a/README_it.md b/README_it.md
index 24c1d35bc..409799e32 100644
--- a/README_it.md
+++ b/README_it.md
@@ -143,6 +143,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) |
| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) |
| Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](docs/web-network-captures.md) |
+| Captured di pacchetti | Endpoint, DNS, TLS SNI, distribuzione dei protocolli e richieste HTTP | tshark e capinfos forniti dal chiamante; [guida pcap](docs/pcap-analysis.md) |
| Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](docs/managed-code-analysis.md) |
| APK Android | Dichiarazioni manifest, classi, metodi decompilati e riferimenti | JADX headless e un JDK completo su Linux/macOS/Windows x64; [guida Android](docs/android-analysis.md) |
| Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](docs/adb-device-analysis.md) |
diff --git a/README_ja.md b/README_ja.md
index 8b32338aa..ee0875bd4 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -139,6 +139,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) |
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) |
| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) |
+| パケットキャプチャ | エンドポイント、DNS、TLS SNI、プロトコル分布、HTTPリクエスト | 呼び出し側が用意した tshark と capinfos;[pcapガイド](docs/pcap-analysis.md) |
| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) |
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) |
| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) |
diff --git a/README_ko.md b/README_ko.md
index 656753d7b..39814030c 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -139,6 +139,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](docs/web-network-captures.md) |
+| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](docs/pcap-analysis.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](docs/managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) |
diff --git a/README_pl.md b/README_pl.md
index f29d5d2fe..31ed33869 100644
--- a/README_pl.md
+++ b/README_pl.md
@@ -139,6 +139,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) |
| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) |
| Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](docs/web-network-captures.md) |
+| Przechwytywanie pakietów | Punkty końcowe, DNS, TLS SNI, dystrybucja protokołów i żądania HTTP | Dostarczone przez wywołującego tshark i capinfos; [przewodnik pcap](docs/pcap-analysis.md) |
| Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](docs/managed-code-analysis.md) |
| Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](docs/android-analysis.md) |
| Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](docs/adb-device-analysis.md) |
diff --git a/README_pt-BR.md b/README_pt-BR.md
index ec18da8ff..cba8b9c3d 100644
--- a/README_pt-BR.md
+++ b/README_pt-BR.md
@@ -139,6 +139,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) |
| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) |
| Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](docs/web-network-captures.md) |
+| Capturas de pacotes | Endpoints, DNS, TLS SNI, distribuição de protocolos e requisições HTTP | tshark e capinfos fornecidos pelo chamador; [guia pcap](docs/pcap-analysis.md) |
| Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](docs/managed-code-analysis.md) |
| APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](docs/android-analysis.md) |
| Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](docs/adb-device-analysis.md) |
diff --git a/README_ru.md b/README_ru.md
index 2d75af75e..320574bf6 100644
--- a/README_ru.md
+++ b/README_ru.md
@@ -147,6 +147,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) |
| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) |
| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](docs/packet-captures.md) |
+| Захват пакетов | Конечные точки, DNS, TLS SNI, распределение протоколов и HTTP-запросы | Предоставленные вызывающей стороной tshark и capinfos; [руководство по pcap](docs/pcap-analysis.md) |
| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](docs/signal-recordings.md) |
| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) |
| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) |
diff --git a/README_th.md b/README_th.md
index 9e0c6831a..f67b04e89 100644
--- a/README_th.md
+++ b/README_th.md
@@ -139,6 +139,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) |
| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) |
| ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](docs/web-network-captures.md) |
+| การจับแพ็กเก็ต | จุดเชื่อมต่อ, DNS, TLS SNI, การกระจายโปรโตคอล และคำขอ HTTP | tshark และ capinfos ที่ผู้เรียกจัดเตรียม; [คู่มือ pcap](docs/pcap-analysis.md) |
| แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](docs/managed-code-analysis.md) |
| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](docs/android-analysis.md) |
| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) |
diff --git a/README_tr.md b/README_tr.md
index 3c6e3f3b6..ccf7db7ce 100644
--- a/README_tr.md
+++ b/README_tr.md
@@ -139,6 +139,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) |
| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) |
| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](docs/web-network-captures.md) |
+| Paket yakalamaları | Uç noktalar, DNS, TLS SNI, protokol dağılımı ve HTTP istekleri | Çağıranın sağladığı tshark ve capinfos; [pcap rehberi](docs/pcap-analysis.md) |
| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](docs/managed-code-analysis.md) |
| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](docs/android-analysis.md) |
| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](docs/adb-device-analysis.md) |
diff --git a/README_uk.md b/README_uk.md
index aea416718..78f1af9f4 100644
--- a/README_uk.md
+++ b/README_uk.md
@@ -139,6 +139,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) |
| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) |
| Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](docs/web-network-captures.md) |
+| Захоплення пакетів | Кінцеві точки, DNS, TLS SNI, розподіл протоколів та HTTP-запити | Надані викликаючою стороною tshark та capinfos; [підручник pcap](docs/pcap-analysis.md) |
| Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](docs/managed-code-analysis.md) |
| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](docs/android-analysis.md) |
| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) |
diff --git a/README_vi.md b/README_vi.md
index ab3fd493d..55aa4dd18 100644
--- a/README_vi.md
+++ b/README_vi.md
@@ -139,6 +139,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) |
| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) |
| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](docs/web-network-captures.md) |
+| Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](docs/pcap-analysis.md) |
| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](docs/managed-code-analysis.md) |
| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](docs/android-analysis.md) |
| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](docs/adb-device-analysis.md) |
diff --git a/README_zh-TW.md b/README_zh-TW.md
index 81371e91d..061cc9a09 100644
--- a/README_zh-TW.md
+++ b/README_zh-TW.md
@@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) |
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) |
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](docs/web-network-captures.md) |
+| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](docs/pcap-analysis.md) |
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](docs/managed-code-analysis.md) |
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) |
diff --git a/README_zh.md b/README_zh.md
index d802cad50..be60efeec 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) |
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) |
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](docs/web-network-captures.md) |
+| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](docs/pcap-analysis.md) |
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](docs/managed-code-analysis.md) |
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) |
diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md
new file mode 100644
index 000000000..1df3e5ccd
--- /dev/null
+++ b/docs/pcap-analysis.md
@@ -0,0 +1,118 @@
+# Packet capture summaries
+
+REA summarizes already saved pcap/pcapng files through caller-selected Wireshark
+4.x `tshark` and `capinfos`. Use `inspect_packet_capture` for selected fields,
+filters and decode-as rules; use this family for address, DNS, SNI, protocol-stack
+and URI inventories. It does not acquire traffic or fetch recorded targets.
+
+## Supply tools
+
+```sh
+export REA_TSHARK_COMMAND=/absolute/path/tshark
+export REA_CAPINFOS_COMMAND=/absolute/path/capinfos
+# Linux only; default /usr/bin/prlimit
+export REA_TSHARK_PRLIMIT_COMMAND=/absolute/path/prlimit
+rea inspect-pcap-client
+rea inspect-pcap-capture /captures/session.pcapng --json
+```
+
+Without explicit tool commands, this family resolves them through PATH and
+records that selection source. Client inspection reads no capture file. Capture
+inspection requires Linux with util-linux prlimit or Windows with owned Job
+Objects. macOS capture inspection returns a capability gap because the new
+native memory boundary is unverified; its version probes remain available.
+Tools and installed libraries/plugins are caller-trusted. An empty private
+personal Wireshark configuration is used; this is not an OS sandbox.
+
+## Inspect only the needed facts
+
+MCP `inspect_pcap_capture` accepts `path` and optional `detail: "summary"`.
+Complete delivery remains the default. Summary commits and retains the same
+complete Evidence, returning identity, capture metadata, observed counts,
+retained counts, source coverage and diagnostic lengths without large lists.
+Then use its `normalized_result.parent_evidence_id` with
+`inspect_analysis_view` source kind `retained-evidence`:
+
+```json
+{"kind":"pcap","facet":"endpoints","offset":0,"limit":20}
+{"kind":"pcap","facet":"dns-queries","offset":0,"limit":20}
+{"kind":"pcap","facet":"dns-addresses","query":"example.com","offset":0,"limit":20}
+{"kind":"pcap","facet":"tls-sni","offset":0,"limit":20}
+{"kind":"pcap","facet":"diagnostics","stream":"tshark","offset":0,"limit":4096}
+```
+
+Other row facets are `protocols`, `http-uris` and `unattributed-addresses`.
+Defaults are offset 0 and limit 64. DNS question pages give address counts;
+select one exact retained question to page its addresses. Diagnostic windows
+count UTF-16 code units; other windows count rows. Missing/ambiguous selectors
+fail. Unknown legacy diagnostics remain unknown, not an observed empty stream.
+Portable CLI views use the existing `inspect-analysis-view` workflow with inline
+complete Evidence. Views never reread the source or launch Wireshark and work
+after the original file is deleted. Page exhaustion covers the retained facet;
+it does not establish network completeness or recover missing traffic.
+
+## Interpret provenance and coverage
+
+`target.sha256` binds observations to an admitted stable capture snapshot. Both
+tools read that copy, which is hashed again after analysis. Capture metadata and
+literal field values are producer observations; address/protocol counts and DNS
+associations are derivations. Capture Evidence has historical-reference authority.
+
+Addresses count once per packet, including loopback and nested IP occurrences.
+DNS addresses are associated only with one question in the same response packet;
+additional records may be included, so this does not prove final resolution.
+Ambiguous addresses remain in a separate inventory. Protocol distribution counts
+exact `frame.protocols` stack strings. TLS SNI is recorded handshake metadata,
+not process identity or proof of the destination's authenticity.
+
+All examined inventories are retained within the resource budget; no fixed
+200/100/50-item truncation is applied. HTTP transport credentials are excluded
+without rewriting other URI bytes; ambiguous credential spans cause omission.
+Counts preserve distinct observed source URIs before omission or coalescing.
+`coverage: "partial"` records unresolved DNS attribution or URI item loss;
+component credential exclusion is also counted explicitly. Diagnostics retain
+both tool stderr streams. Recorded fields do not establish decrypted payloads,
+causality, process identity, absence of unrecorded traffic or current deployments.
+
+## Resources and lifecycle
+
+One inspection is admitted at a time per provider. Concurrent calls return a
+capacity error; close drains active work before releasing resources. Captures
+are streamed into a read-only owned snapshot with a 2 GiB disk budget. Native
+capture commands enforce Linux address-space ceilings of at most 1 GiB,
+300 CPU seconds, 64 MiB per file and no core dumps, preserving stricter inherited
+limits. Windows uses a 1 GiB committed-memory limit for each owned tool tree.
+These are not aggregate machine or filesystem quotas.
+
+TShark output is bounded at 8 MiB; its known five-level selected-field shape and
+128 MiB graph-expansion estimate are checked before allocation. Retained facts
+have a separate 16 MiB estimate. Capinfos output is limited to 64 KiB. Command
+wall deadlines are 300 s for dissection, 60 s for metadata and 20 s for probes.
+Native ownership preparation has its own bounded lifecycle.
+
+Malformed/oversized output returns no partial success. Unvalidated partial JSON
+is not returned as facts. Cancellation reaches probes, acquisition, commands and
+projection. Cleanup failure returns a typed error, retains completed Evidence
+when available, and retains owned resources for retry on close.
+
+## Verification scope
+
+Current follow-up verification uses Linux/Node 24.18.0 and Wireshark 4.0.17.
+Private real recorded corpora are checked against independent raw-frame/IP
+parsing and Python aggregation, public CLI/stdio MCP parity, SDK schemas, source
+hashes and retained views. Synthetic native tools separately test malformed
+output, cancellation, source/snapshot mutation, capacity and cleanup; they do
+not establish real Wireshark or other-platform behavior.
+
+The original proposal reported Linux 4.6.6, macOS/Windows 4.6.9 verification on
+its earlier implementation. Those reports do not verify the new final memory
+boundary. Actual Windows/Bun Wireshark execution is unverified here; macOS
+capture support needs a separate enforceable-bound decision.
+
+```sh
+npm run verify:pcap -- --pcap /absolute/saved-capture.pcapng
+```
+
+This verifier exercises built CLI/stdio MCP, schemas, artifact identity and
+cleanup. Without `--pcap`, capture inspection is reported as unverified. Private
+captures, keys and provider runtime files are not committed.
diff --git a/docs/testing.md b/docs/testing.md
index 6f0b80131..a36eff6ec 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -849,6 +849,14 @@ establish passing Android coverage; inspect its execution receipt.
The `termux-browser-diagnostics` artifact retains the Termux command log, exit
status, and Android logcat on success or failure. The emulator is disposable.
+## Packet capture analysis
+
+`npm run verify:pcap -- --pcap PATH` exercises caller-supplied tshark and
+capinfos against a saved capture. Linux verification uses Wireshark 4.6.6
+and captures containing DNS, TLS and HTTP traffic. Native Windows and macOS
+engine behavior remains unverified; process fixtures do not establish it.
+See [Packet capture analysis](pcap-analysis.md) for contracts and budgets.
+
## Apple native metadata and UI
`npm run verify:apple-dispatch` compiles the Objective-C fixture (classes,
diff --git a/package.json b/package.json
index bc5c0edc9..6fdb0d9de 100644
--- a/package.json
+++ b/package.json
@@ -167,6 +167,7 @@
"verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs",
"verify:architecture-guards": "node scripts/verify/architecture-guards.mjs",
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
+ "verify:pcap": "npm run build:cached && node scripts/verify-pcap.mjs",
"verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs
index f97f5ed5c..32ec51ee3 100644
--- a/scripts/lib/catalog-builders.mjs
+++ b/scripts/lib/catalog-builders.mjs
@@ -52,6 +52,11 @@ export const toolFamilyCatalog = (sources) => {
surface: "android-provider",
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
+ {
+ id: "pcap",
+ surface: "pcap-provider",
+ contracts: sources.pcapContracts.PCAP_TOOL_CONTRACTS,
+ },
{
id: "flutter",
surface: "flutter-provider",
@@ -251,6 +256,10 @@ export const providerCatalog = (sources) => {
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
+ {
+ identity: sources.pcapProvider.PCAP_PROVIDER_IDENTITY,
+ contracts: sources.pcapContracts.PCAP_TOOL_CONTRACTS,
+ },
{
identity: sources.flutterProvider.FLUTTER_PROVIDER_IDENTITY,
contracts: sources.flutterContracts.FLUTTER_TOOL_CONTRACTS,
diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs
index 5a3b0e909..f5488a9e6 100644
--- a/scripts/lib/catalog-core.mjs
+++ b/scripts/lib/catalog-core.mjs
@@ -68,8 +68,8 @@ const SOURCE_PATHS = {
flutterProvider: "dist/flutter/FlutterBuildProvider.js",
androidProvider: "dist/android/JadxRelease.js",
apktoolContracts: "dist/contracts/apktool/apktoolToolContracts.js",
- flutterContracts: "dist/contracts/flutter/flutterToolContracts.js",
- flutterProvider: "dist/flutter/FlutterBuildProvider.js",
+ pcapContracts: "dist/contracts/pcap/pcapToolContracts.js",
+ pcapProvider: "dist/pcap/PcapProvider.js",
apktoolProvider: "dist/apktool/ApktoolProvider.js",
jebContracts: "dist/contracts/jeb/jebToolContracts.js",
jebProvider: "dist/jeb/JebProvider.js",
diff --git a/scripts/verify-packet-capture.mjs b/scripts/verify-packet-capture.mjs
index 371c72367..2b959e602 100644
--- a/scripts/verify-packet-capture.mjs
+++ b/scripts/verify-packet-capture.mjs
@@ -236,6 +236,52 @@ try {
const hci = await inspect({ path: bluetooth, fields: ["bthci_cmd.opcode"] });
assert.deepEqual(hci.packets[0]["bthci_cmd.opcode"], ["0x0c03"]);
assert.deepEqual(await readFile(path), source);
+ // Exercise the incremental summary workflow against the same owned bytes
+ // using real installed Wireshark tools; these inputs are format fixtures.
+ const pcapTool = (await client.listTools()).tools.find(
+ (t) => t.name === "inspect_pcap_capture",
+ );
+ assert.ok(pcapTool?.outputSchema);
+ const pcapAjv = new Ajv2020({ strict: false, validateFormats: false });
+ assert.ok(pcapAjv.validateSchema(pcapTool.inputSchema));
+ assert.ok(pcapAjv.validateSchema(pcapTool.outputSchema));
+ for (const capturePath of [path, bluetooth]) {
+ const response = await client.callTool({
+ name: pcapTool.name,
+ arguments: { path: capturePath, detail: "summary" },
+ });
+ assert.notEqual(response.isError, true, JSON.stringify(response));
+ assert.ok(
+ pcapAjv.validate(pcapTool.outputSchema, response.structuredContent),
+ pcapAjv.errorsText(),
+ );
+ const summary = parseEvidence(response.structuredContent).normalized_result;
+ assert.equal(
+ summary.summary.target.sha256,
+ createHash("sha256")
+ .update(await readFile(capturePath))
+ .digest("hex"),
+ );
+ const source = {
+ kind: "retained-evidence",
+ evidence_id: summary.parent_evidence_id,
+ };
+ const page = await client.callTool({
+ name: "inspect_analysis_view",
+ arguments: {
+ source,
+ view: { kind: "pcap", facet: "protocols", limit: 10 },
+ },
+ });
+ assert.notEqual(page.isError, true, JSON.stringify(page));
+ assert.equal(
+ parseEvidence(page.structuredContent).normalized_result
+ .parent_evidence_id,
+ source.evidence_id,
+ );
+ cases++;
+ }
+
assert.deepEqual(await readFile(bluetooth), bluetoothSource);
for (const input of [
{ path, fields: ["frame.number"], display_filter: "this is not a filter" },
diff --git a/scripts/verify-pcap.mjs b/scripts/verify-pcap.mjs
new file mode 100644
index 000000000..3adf14531
--- /dev/null
+++ b/scripts/verify-pcap.mjs
@@ -0,0 +1,131 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { mkdtemp, readdir, rmdir } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { toolContract } from "../dist/contracts/toolContracts.js";
+import { hashStableFile } from "../dist/filesystem/StableFileHash.js";
+import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
+
+const args = process.argv.slice(2);
+if (args.includes("--help") || args.includes("-h")) {
+ console.log(
+ "Usage: npm run verify:pcap -- [--pcap PATH]\nRequires caller-supplied tshark/capinfos. Without --pcap, only client readiness is verified.",
+ );
+ process.exit(0);
+}
+if (!(args.length === 0 || (args.length === 2 && args[0] === "--pcap")))
+ throw new Error("Expected --pcap PATH");
+const path = args[1] === undefined ? null : resolve(args[1]);
+// This parent is shared by independent CLI/MCP owners. A leased Windows
+// runtime root would prevent their native admission of its directory handle.
+const root = await mkdtemp(join(tmpdir(), "rea-pcap-verifier-"));
+const environment = Object.fromEntries(
+ Object.entries(process.env).filter(([, value]) => typeof value === "string"),
+);
+Object.assign(environment, {
+ TMPDIR: root,
+ TMP: root,
+ TEMP: root,
+});
+const entrypoint = resolve("scripts/rea.mjs");
+const execute = promisify(execFile);
+const client = new Client({ name: "pcap-real-verifier", version: "1" });
+const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+});
+transport.stderr?.resume();
+try {
+ await client.connect(transport);
+ const readiness = await run("inspect_pcap_client", {});
+ assert(readiness.client.tshark_version && readiness.client.capinfos_version);
+ if (path === null)
+ console.log("[unverified] Capture inspection: no --pcap was supplied");
+ else {
+ const identity = await hashStableFile(path);
+ const result = await run("inspect_pcap_capture", { path });
+ assert.deepEqual(result.target, { path, ...identity });
+ assert(result.packet_count >= 0);
+ assert(
+ result.endpoints.every(
+ (entry) => entry.packet_count <= result.packet_count,
+ ),
+ );
+ assert(
+ result.protocol_distribution.every(
+ (entry) => entry.packet_count <= result.packet_count,
+ ),
+ );
+ const partial =
+ result.endpoint_count > result.endpoints.length ||
+ result.dns_query_count > result.dns_queries.length ||
+ result.tls_sni_count > result.tls_sni_values.length ||
+ result.protocol_count > result.protocol_distribution.length ||
+ result.http_request_uri_count > result.http_request_uris.length ||
+ result.unattributed_dns_packet_count > 0 ||
+ result.dns_queries.some(
+ (entry) =>
+ entry.response_address_count > entry.response_addresses.length,
+ );
+ assert.equal(result.coverage, partial ? "partial" : "complete");
+ assert.deepEqual(
+ await hashStableFile(path),
+ identity,
+ "Input capture must remain unchanged",
+ );
+ console.log(
+ `[ok] ${result.packet_count} packets; ${result.endpoint_count} addresses; coverage=${result.coverage}`,
+ );
+ }
+} finally {
+ try {
+ await client.close();
+ } finally {
+ await transport.close();
+ }
+ assert.deepEqual(
+ await readdir(root),
+ [],
+ `Owned capture resources remain under ${root}`,
+ );
+ await rmdir(root);
+}
+console.log("All asserted pcap CLI/MCP checks passed");
+
+async function run(name, input) {
+ const contract = toolContract(name);
+ const { stdout } = await execute(
+ process.execPath,
+ [
+ entrypoint,
+ name.replaceAll("_", "-"),
+ ...(input.path === undefined ? [] : [input.path]),
+ "--json",
+ ],
+ { env: environment, timeout: 360_000, maxBuffer: 8 * 1024 * 1024 },
+ );
+ const cli = contract.outputSchema.parse(JSON.parse(stdout));
+ const response = await client.callTool(
+ { name, arguments: input },
+ { timeout: 360_000 },
+ );
+ assert.notEqual(response.isError, true, mcpTextValue(response));
+ const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response)));
+ assert.deepEqual(mcp.normalized_result, cli.normalized_result);
+ assert.deepEqual(mcp.subject, cli.subject);
+ if (input.path !== undefined) {
+ assert.equal(cli.subject.local_path, input.path);
+ assert.equal(
+ cli.subject.digest.sha256,
+ cli.normalized_result.target.sha256,
+ );
+ }
+ console.log(`[ok] ${name}: named output schema and CLI/MCP Evidence parity`);
+ return cli.normalized_result;
+}
diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts
index c0f341506..34b418029 100644
--- a/src/application/CapabilityInventory.ts
+++ b/src/application/CapabilityInventory.ts
@@ -481,7 +481,7 @@ const workflowAvailabilityFor = ({
remediation: "Run process capture on a supported Linux or macOS host.",
};
if (kind === "application") return { reason: "available", remediation: null };
- if (kind === "flutter-provider")
+ if (kind === "pcap-provider" || kind === "flutter-provider")
return { reason: "available", remediation: null };
const electron = electronProviderAvailability(name, kind, policy);
if (electron !== null) return electron;
diff --git a/src/application/pcap/PcapAnalysisPort.ts b/src/application/pcap/PcapAnalysisPort.ts
new file mode 100644
index 000000000..c5acecd38
--- /dev/null
+++ b/src/application/pcap/PcapAnalysisPort.ts
@@ -0,0 +1,23 @@
+import type {
+ AnalysisExecution,
+ ExecutionOptions,
+ ProviderAvailability,
+} from "../AnalysisProvider.js";
+import type { PcapRequest } from "../../domain/pcap/pcapAnalysis.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type { Result } from "../../domain/result.js";
+
+/**
+ * Packet-capture analysis boundary over caller-supplied tshark/capinfos.
+ * Implementation details belong to the provider adapter.
+ */
+export interface PcapAnalysisPort {
+ /** Probe both tools without reading any capture file. */
+ inspectAvailability(signal?: AbortSignal): Promise;
+ /** Retry cleanup of retained owned commands and capture snapshots. */
+ close(): Promise;
+ execute(
+ request: PcapRequest,
+ options?: ExecutionOptions,
+ ): Promise>;
+}
diff --git a/src/application/pcap/PcapAnalysisService.ts b/src/application/pcap/PcapAnalysisService.ts
new file mode 100644
index 000000000..23b968e58
--- /dev/null
+++ b/src/application/pcap/PcapAnalysisService.ts
@@ -0,0 +1,36 @@
+import type { PcapAnalysisPort } from "./PcapAnalysisPort.js";
+import type { ExecutionOptions } from "../AnalysisProvider.js";
+import {
+ pcapRequestSchema,
+ type PcapOperation,
+} from "../../domain/pcap/pcapAnalysis.js";
+import {
+ AnalysisCancelledError,
+ AnalysisInputError,
+} from "../../domain/analysisErrorCore.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type { Evidence } from "../../domain/evidence.js";
+import { createPcapEvidence } from "../../domain/pcap/pcapEvidence.js";
+import { err, ok, type Result } from "../../domain/result.js";
+
+/** Shared CLI/MCP admission, execution, and Evidence composition for pcap. */
+export class PcapAnalysisService {
+ constructor(readonly provider: PcapAnalysisPort) {}
+
+ /** Run one caller-selected pcap request and retain capture identity inline. */
+ async execute(
+ operation: PcapOperation,
+ input: unknown,
+ options?: ExecutionOptions,
+ ): Promise> {
+ if (options?.signal?.aborted === true)
+ return err(new AnalysisCancelledError(operation));
+ const request = pcapRequestSchema.safeParse({ operation, input });
+ if (!request.success)
+ return err(new AnalysisInputError(operation, { cause: request.error }));
+ const executed = await this.provider.execute(request.data, options);
+ if (!executed.ok) return executed;
+ const execution = executed.value;
+ return ok(createPcapEvidence(request.data, execution));
+ }
+}
diff --git a/src/cli.ts b/src/cli.ts
index 0b545c0ba..d1bdf2912 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -24,6 +24,7 @@ import { registerManagedCommands } from "./cli/managedCommands.js";
import { registerFirmwareCommands } from "./cli/firmwareCommands.js";
import { registerAndroidCommands } from "./cli/androidCommands.js";
import { registerApktoolCommands } from "./cli/apktoolCommands.js";
+import { registerPcapCommands } from "./cli/pcapCommands.js";
import { registerFlutterCommands } from "./cli/flutterCommands.js";
import { registerJebCommands } from "./cli/jebCommands.js";
import { registerAdbCommands } from "./cli/adbCommands.js";
@@ -105,6 +106,7 @@ export const createCli = (
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
registerAndroidCommands(cli, logger, environment);
registerApktoolCommands(cli, logger, environment);
+ registerPcapCommands(cli, logger, environment);
registerFlutterCommands(cli, logger);
registerJebCommands(cli, logger, environment);
registerAdbCommands(cli, logger, environment);
diff --git a/src/cli/pcapCommands.ts b/src/cli/pcapCommands.ts
new file mode 100644
index 000000000..1b53e47cc
--- /dev/null
+++ b/src/cli/pcapCommands.ts
@@ -0,0 +1,69 @@
+import { z } from "incur";
+import { AnalysisError } from "../domain/analysisErrorBase.js";
+import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
+import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
+import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
+import { createPcapAnalysisProvider } from "../composition/pcap.js";
+import { CLI_COMMANDS } from "../cliCommandNames.js";
+import { logCliCommand } from "../cliLogging.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import type { PcapOperation } from "../domain/pcap/pcapAnalysis.js";
+import type { Logger } from "pino";
+import type { CliInstance } from "./types.js";
+import { withCommandCancellation } from "./commandCancellation.js";
+
+/** Expose the same explicit pcap requests through one-shot CLI commands. */
+export const registerPcapCommands = (
+ cli: CliInstance,
+ logger: Logger,
+ environment: Readonly>,
+ provider: PcapAnalysisPort = createPcapAnalysisProvider(environment),
+): void => {
+ const service = new PcapAnalysisService(provider);
+ const execute = (name: string, operation: PcapOperation, input: unknown) =>
+ withCommandCancellation((signal) =>
+ logCliCommand(logger, name, async () => {
+ const result = await service.execute(operation, input, { signal });
+ try {
+ await provider.close();
+ } catch (cause) {
+ if (cause instanceof AnalysisError) {
+ if (result.ok) cause.retainPartialObservation(result.value);
+ return projectAnalysisError(
+ result.ok
+ ? cause
+ : analysisErrorWithCleanupFailure(
+ result.error,
+ cause,
+ operation,
+ ),
+ );
+ }
+ throw cause;
+ }
+ return result.ok ? result.value : projectAnalysisError(result.error);
+ }),
+ );
+
+ cli.command(CLI_COMMANDS.inspectPcapClient, {
+ description:
+ "Inspect the caller-selected tshark and capinfos binaries without reading a capture",
+ args: z.object({}),
+ run: () =>
+ execute(CLI_COMMANDS.inspectPcapClient, "inspect_pcap_client", {}),
+ });
+ cli.command(CLI_COMMANDS.inspectPcapCapture, {
+ description:
+ "Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution",
+ args: z.object({
+ path: z
+ .string()
+ .min(1)
+ .describe("Local pcap or pcapng capture file to inspect"),
+ }),
+ run: ({ args }) =>
+ execute(CLI_COMMANDS.inspectPcapCapture, "inspect_pcap_capture", {
+ path: args.path,
+ }),
+ });
+};
diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts
index 1278cbea8..e7c9bc922 100644
--- a/src/cliCommandNames.ts
+++ b/src/cliCommandNames.ts
@@ -55,6 +55,8 @@ export const CLI_COMMANDS = Object.freeze({
inspectFirmwareRegions: "inspect-firmware-regions",
extractFirmware: "extract-firmware",
inspectAndroidPackage: "inspect-android-package",
+ inspectPcapClient: "inspect-pcap-client",
+ inspectPcapCapture: "inspect-pcap-capture",
identifyFlutterBuild: "identify-flutter-build",
inspectDartAot: "inspect-dart-aot",
inspectApktoolClient: "inspect-apktool-client",
diff --git a/src/composition/pcap.ts b/src/composition/pcap.ts
new file mode 100644
index 000000000..f3aaa3915
--- /dev/null
+++ b/src/composition/pcap.ts
@@ -0,0 +1,7 @@
+import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
+import { PcapProvider } from "../pcap/PcapProvider.js";
+
+/** Construct one pcap provider bound to the caller-selected tools. */
+export const createPcapAnalysisProvider = (
+ environment: Readonly> = process.env,
+): PcapAnalysisPort => new PcapProvider({ environment });
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index 1a4dbbb5c..aba5b9a93 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -16,7 +16,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_evm_interface, inspect_managed_artifact, 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
+ 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, 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. 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 distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
diff --git a/src/contracts/pcap/pcapToolContracts.ts b/src/contracts/pcap/pcapToolContracts.ts
new file mode 100644
index 000000000..66f281b4e
--- /dev/null
+++ b/src/contracts/pcap/pcapToolContracts.ts
@@ -0,0 +1,46 @@
+import {
+ pcapInputSchemas,
+ pcapResultSchemas,
+} from "../../domain/pcap/pcapAnalysis.js";
+import type { ToolContract } from "../toolContractTypes.js";
+import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
+import { toolContractMetadata } from "../toolEffects.js";
+import {
+ analysisDetailSchema,
+ analysisDetailOutputSchemaOf,
+} from "../analysisDetail.js";
+import { pcapCaptureSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
+
+/** Packet-capture analysis served by caller-supplied tshark and capinfos. */
+export const PCAP_TOOL_CONTRACTS = [
+ {
+ name: "inspect_pcap_client",
+ ...toolContractMetadata("inspect_pcap_client"),
+ description:
+ "Inspect the caller-selected tshark and capinfos binaries: selected commands, selection sources, and versions. Both tools must be usable. Reads no capture file. REA never installs the Wireshark suite; select binaries with REA_TSHARK_COMMAND and REA_CAPINFOS_COMMAND (absolute) or from PATH.",
+ kind: "pcap-provider",
+ inputSchema: pcapInputSchemas.inspect_pcap_client,
+ outputSchema: evidenceResultOf(pcapResultSchemas.inspect_pcap_client),
+ examples: [{ title: "Check the analysis tools", input: {} }],
+ },
+ {
+ name: "inspect_pcap_capture",
+ ...toolContractMetadata("inspect_pcap_capture"),
+ description:
+ "Request detail summary for capture identity/counts, then page retained facts with inspect_analysis_view kind pcap. Default detail complete preserves inline delivery. Inspect one saved pcap or pcapng capture through an owned snapshot: metadata, exact packet count, SHA-256, IPv4/IPv6 address counts, DNS questions and address values from the same single-question response, TLS SNI, protocol distribution and HTTP URIs. All derived inventories are retained within declared resource budgets; unknown DNS attribution and excluded credentials remain explicit. Linux requires util-linux prlimit; Windows uses owned Job Objects. Requires caller-supplied capinfos and tshark; REA never captures traffic.",
+ kind: "pcap-provider",
+ inputSchema: pcapInputSchemas.inspect_pcap_capture.extend({
+ detail: analysisDetailSchema,
+ }),
+ outputSchema: analysisDetailOutputSchemaOf(
+ pcapResultSchemas.inspect_pcap_capture,
+ pcapCaptureSummaryViewResultSchema,
+ ),
+ examples: [
+ {
+ title: "Inspect a capture file",
+ input: { path: "/captures/session.pcap" },
+ },
+ ],
+ },
+] as const satisfies readonly ToolContract[];
diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts
index d7e19d37e..de5f53411 100644
--- a/src/contracts/toolContractTypes.ts
+++ b/src/contracts/toolContractTypes.ts
@@ -33,6 +33,7 @@ export const TOOL_KINDS = [
"browser-provider",
"electron-provider",
"runtime-provider",
+ "pcap-provider",
"application",
"session",
] as const;
diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts
index 101cca17e..2fdac1fbe 100644
--- a/src/contracts/toolContracts.ts
+++ b/src/contracts/toolContracts.ts
@@ -12,6 +12,7 @@ import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js";
import { FIRMWARE_TOOL_CONTRACTS } from "./firmware/firmwareToolContracts.js";
import { ANDROID_TOOL_CONTRACTS } from "./android/androidToolContracts.js";
import { APKTOOL_TOOL_CONTRACTS } from "./apktool/apktoolToolContracts.js";
+import { PCAP_TOOL_CONTRACTS } from "./pcap/pcapToolContracts.js";
import { FLUTTER_TOOL_CONTRACTS } from "./flutter/flutterToolContracts.js";
import { JEB_TOOL_CONTRACTS } from "./jeb/jebToolContracts.js";
import { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js";
@@ -46,6 +47,7 @@ export const TOOL_CONTRACTS = [
...MANAGED_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...APKTOOL_TOOL_CONTRACTS,
+ ...PCAP_TOOL_CONTRACTS,
...FLUTTER_TOOL_CONTRACTS,
...JEB_TOOL_CONTRACTS,
...ADB_TOOL_CONTRACTS,
diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts
index 836d190b1..fe18c96b3 100644
--- a/src/contracts/toolEffects.test.ts
+++ b/src/contracts/toolEffects.test.ts
@@ -44,6 +44,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = {
search_android_classes: "states it does not execute the APK",
inspect_apktool_client:
"inspects the operator-selected apktool launcher; never installs it",
+ inspect_pcap_client:
+ "probes the operator-selected tshark and capinfos; never installs them",
inspect_jeb_client: "talks to a caller-started JEB; never launches it",
open_jeb_project: "the engine analyzes the file; REA does not execute it",
inspect_adb_client:
diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts
index 4b0d0b745..7ce3e4ded 100644
--- a/src/contracts/toolEffects.ts
+++ b/src/contracts/toolEffects.ts
@@ -182,6 +182,16 @@ export const TOOL_EFFECTS: Readonly> = {
launchesProcess: true,
writesFilesystem: true,
}),
+ inspect_pcap_client: effects({
+ mutatesSession: true,
+ launchesProcess: true,
+ writesFilesystem: true,
+ }),
+ inspect_pcap_capture: effects({
+ mutatesSession: true,
+ launchesProcess: true,
+ writesFilesystem: true,
+ }),
identify_flutter_build: effects({
mutatesSession: true,
writesFilesystem: true,
diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts
index 48eee73f0..6cc5ae8a3 100644
--- a/src/domain/analysisView/analysisView.ts
+++ b/src/domain/analysisView/analysisView.ts
@@ -62,6 +62,12 @@ import {
captureSummarySchema,
} from "./webNetworkCaptureView.js";
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
+import {
+ pcapViewRequestSchema,
+ pcapViewPageSchema,
+ pcapSummarySchema,
+ projectPcapCaptureView,
+} from "./pcapCaptureView.js";
import {
qlogViewRequestSchema,
qlogViewPageSchema,
@@ -196,6 +202,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
apiSurfaceViewSchema,
captureViewRequestSchema,
qlogViewRequestSchema,
+ pcapViewRequestSchema,
dartAotViewRequestSchema,
]);
@@ -312,6 +319,7 @@ const viewResultBase = {
"inspect_managed_artifact",
"inspect_web_network_capture",
"inspect_android_package",
+ "inspect_pcap_capture",
"inspect_dart_aot",
]),
parent_digest: digestSchema,
@@ -384,6 +392,12 @@ export const androidPackageSummaryViewResultSchema = z.strictObject({
summary: androidPackageSummarySchema,
});
+export const pcapCaptureSummaryViewResultSchema = z.strictObject({
+ ...summaryViewResultBase,
+ parent_operation: z.literal("inspect_pcap_capture"),
+ summary: pcapSummarySchema,
+});
+
/** Exact snapshot and pool-coverage carrier for selected AOT delivery. */
export const dartAotSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -415,6 +429,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
managedSummarySchema,
captureSummarySchema,
androidPackageSummarySchema,
+ pcapSummarySchema,
dartAotSummarySchema,
]),
}),
@@ -433,6 +448,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_web_network_capture"),
}),
+ z.strictObject({
+ kind: z.literal("pcap"),
+ view: pcapViewRequestSchema,
+ page: pcapViewPageSchema,
+ source_coverage: z.enum(["complete", "partial"]),
+ ...viewResultBase,
+ parent_operation: z.literal("inspect_pcap_capture"),
+ }),
z.strictObject({
kind: z.literal("facet"),
view: facetViewSchema,
@@ -562,6 +585,7 @@ export type UnsignedAnalysisView =
| Omit, "view_digest">
| Omit, "view_digest">
| Omit, "view_digest">
+ | Omit, "view_digest">
| Omit<
Extract,
"view_digest"
@@ -686,6 +710,10 @@ export const projectAnalysisView = (
const projected = projectWebNetworkCaptureView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
+ if (parent.operation === "inspect_pcap_capture") {
+ const projected = projectPcapCaptureView(parent, view);
+ return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
+ }
if (parent.operation === "inspect_binary_layout") {
const layout = binaryLayoutSchema.safeParse(parent.normalizedResult);
if (!layout.success)
diff --git a/src/domain/analysisView/pcapCaptureView.ts b/src/domain/analysisView/pcapCaptureView.ts
new file mode 100644
index 000000000..a28398d44
--- /dev/null
+++ b/src/domain/analysisView/pcapCaptureView.ts
@@ -0,0 +1,327 @@
+import { z } from "zod";
+import { pcapResultSchemas } from "../pcap/pcapAnalysis.js";
+import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
+import type { AnalysisError } from "../analysisErrorBase.js";
+import { err, ok, type Result } from "../result.js";
+import {
+ analysisViewInputError,
+ type AnalysisViewParent,
+ type AnalysisViewRequest,
+ type UnsignedAnalysisView,
+} from "./analysisView.js";
+import {
+ completeWithinViewCoverage,
+ pageViewCoverage,
+} from "./analysisViewCoverage.js";
+
+const captureSchema = pcapResultSchemas.inspect_pcap_capture;
+const fields = captureSchema.shape;
+const pageShape = {
+ kind: z.literal("pcap"),
+ offset: z.number().int().nonnegative().default(0),
+ limit: z.number().int().positive().default(64),
+};
+export const pcapViewRequestSchema = z.discriminatedUnion("facet", [
+ z.strictObject({
+ ...pageShape,
+ facet: z.enum([
+ "endpoints",
+ "dns-queries",
+ "tls-sni",
+ "protocols",
+ "http-uris",
+ "unattributed-addresses",
+ ]),
+ }),
+ z.strictObject({
+ ...pageShape,
+ facet: z.literal("dns-addresses"),
+ query: z.string().min(1),
+ }),
+ z.strictObject({
+ ...pageShape,
+ facet: z.literal("diagnostics"),
+ stream: z.enum(["tshark", "capinfos"]),
+ }),
+]);
+export const pcapViewPageSchema = z.discriminatedUnion("facet", [
+ z.strictObject({ facet: z.literal("endpoints"), items: fields.endpoints }),
+ z.strictObject({
+ facet: z.literal("dns-queries"),
+ items: z.array(
+ fields.dns_queries.element.omit({ response_addresses: true }).extend({
+ retained_response_address_count: z.number().int().nonnegative(),
+ }),
+ ),
+ }),
+ z.strictObject({ facet: z.literal("tls-sni"), items: fields.tls_sni_values }),
+ z.strictObject({
+ facet: z.literal("protocols"),
+ items: fields.protocol_distribution,
+ }),
+ z.strictObject({
+ facet: z.literal("http-uris"),
+ items: fields.http_request_uris,
+ }),
+ z.strictObject({
+ facet: z.literal("unattributed-addresses"),
+ items: fields.unattributed_dns_addresses,
+ }),
+ z.strictObject({
+ facet: z.literal("dns-addresses"),
+ items: fields.unattributed_dns_addresses,
+ }),
+ z.strictObject({
+ facet: z.literal("diagnostics"),
+ text: z.string(),
+ status: z.enum(["retained", "unknown"]),
+ }),
+]);
+export const pcapSummarySchema = captureSchema
+ .omit({
+ endpoints: true,
+ dns_queries: true,
+ tls_sni_values: true,
+ protocol_distribution: true,
+ http_request_uris: true,
+ unattributed_dns_addresses: true,
+ diagnostics: true,
+ })
+ .extend({
+ retained_counts: z.strictObject({
+ endpoints: z.number().int().nonnegative(),
+ dns_queries: z.number().int().nonnegative(),
+ tls_sni_values: z.number().int().nonnegative(),
+ protocol_distribution: z.number().int().nonnegative(),
+ http_request_uris: z.number().int().nonnegative(),
+ unattributed_dns_addresses: z.number().int().nonnegative(),
+ }),
+ diagnostics_code_units: z
+ .strictObject({
+ tshark: z.number().int().nonnegative(),
+ capinfos: z.number().int().nonnegative(),
+ })
+ .nullable(),
+ });
+type Capture = z.output;
+type PcapView = z.output;
+type Page = z.output;
+
+const parseParent = (
+ parent: AnalysisViewParent,
+): Result => {
+ const parsed = captureSchema.safeParse(parent.normalizedResult);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(
+ "inspect_analysis_view",
+ parsed.error.issues,
+ parent.normalizedResult,
+ { cause: parsed.error },
+ ),
+ );
+ if (
+ parent.subject?.local_path !== parsed.data.target.path ||
+ parent.subject.digest.sha256 !== parsed.data.target.sha256
+ )
+ return err(
+ analysisViewInputError([
+ {
+ path: ["source", "subject"],
+ reason: "invalid_value",
+ message:
+ "Packet parent subject must identify the report's exact path and SHA-256.",
+ },
+ ]),
+ );
+ return ok(parsed.data);
+};
+const summarize = ({
+ endpoints,
+ dns_queries,
+ tls_sni_values,
+ protocol_distribution,
+ http_request_uris,
+ unattributed_dns_addresses,
+ diagnostics,
+ ...metadata
+}: Capture) => ({
+ ...metadata,
+ retained_counts: {
+ endpoints: endpoints.length,
+ dns_queries: dns_queries.length,
+ tls_sni_values: tls_sni_values.length,
+ protocol_distribution: protocol_distribution.length,
+ http_request_uris: http_request_uris.length,
+ unattributed_dns_addresses: unattributed_dns_addresses.length,
+ },
+ diagnostics_code_units:
+ diagnostics === undefined
+ ? null
+ : {
+ tshark: diagnostics.tshark_stderr.length,
+ capinfos: diagnostics.capinfos_stderr.length,
+ },
+});
+
+const selectRows = (
+ capture: Capture,
+ view: Exclude,
+): Result<
+ { page: Exclude; total: number },
+ AnalysisError
+> => {
+ const slice = (items: readonly T[]) =>
+ items.slice(view.offset, view.offset + view.limit);
+ switch (view.facet) {
+ case "endpoints":
+ return ok({
+ page: { facet: view.facet, items: slice(capture.endpoints) },
+ total: capture.endpoints.length,
+ });
+ case "dns-queries":
+ return ok({
+ page: {
+ facet: view.facet,
+ items: slice(capture.dns_queries).map(
+ ({ response_addresses, ...query }) => ({
+ ...query,
+ retained_response_address_count: response_addresses.length,
+ }),
+ ),
+ },
+ total: capture.dns_queries.length,
+ });
+ case "tls-sni":
+ return ok({
+ page: { facet: view.facet, items: slice(capture.tls_sni_values) },
+ total: capture.tls_sni_values.length,
+ });
+ case "protocols":
+ return ok({
+ page: {
+ facet: view.facet,
+ items: slice(capture.protocol_distribution),
+ },
+ total: capture.protocol_distribution.length,
+ });
+ case "http-uris":
+ return ok({
+ page: { facet: view.facet, items: slice(capture.http_request_uris) },
+ total: capture.http_request_uris.length,
+ });
+ case "unattributed-addresses":
+ return ok({
+ page: {
+ facet: view.facet,
+ items: slice(capture.unattributed_dns_addresses),
+ },
+ total: capture.unattributed_dns_addresses.length,
+ });
+ case "dns-addresses": {
+ const matches = capture.dns_queries.filter(
+ (query) => query.query === view.query,
+ );
+ if (matches.length !== 1)
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view", "query"],
+ reason: "invalid_value",
+ message: "Select one unambiguous exact retained DNS question.",
+ },
+ ]),
+ );
+ return ok({
+ page: {
+ facet: view.facet,
+ items: slice(matches[0]!.response_addresses),
+ },
+ total: matches[0]!.response_addresses.length,
+ });
+ }
+ }
+};
+
+/** Historical packet facts are paged without reopening the source or rerunning Wireshark. */
+export const projectPcapCaptureView = (
+ parent: AnalysisViewParent,
+ view: AnalysisViewRequest,
+): Result => {
+ if (view.kind !== "summary" && view.kind !== "pcap")
+ return err(
+ analysisViewInputError([
+ {
+ path: ["view"],
+ reason: "invalid_value",
+ message:
+ "Packet summary parents support summary and pcap fact pages.",
+ },
+ ]),
+ );
+ const parsed = parseParent(parent);
+ if (!parsed.ok) return parsed;
+ const capture = parsed.value;
+ const shared = {
+ parent_evidence_id: parent.evidenceId,
+ parent_operation: "inspect_pcap_capture" as const,
+ parent_digest: parent.evidenceId.slice(3),
+ artifact: { path: capture.target.path, sha256: capture.target.sha256 },
+ limitations: [
+ ...parent.limitations,
+ "Page totals count retained rows or UTF-16 diagnostic code units. Exhaustion does not prove whole-network completeness; source coverage and examined counts remain separate.",
+ ],
+ unknowns: [
+ "Saved packet observations do not establish process identity, causality, encrypted payload contents or traffic missing from the recording.",
+ ],
+ };
+ if (view.kind === "summary")
+ return ok({
+ kind: "summary",
+ view,
+ ...shared,
+ summary: summarize(capture),
+ coverage: completeWithinViewCoverage(1, 1),
+ });
+ if (view.facet === "diagnostics") {
+ const value =
+ view.stream === "tshark"
+ ? capture.diagnostics?.tshark_stderr
+ : capture.diagnostics?.capinfos_stderr;
+ const text = value?.slice(view.offset, view.offset + view.limit) ?? "";
+ return ok({
+ kind: "pcap",
+ view,
+ ...shared,
+ source_coverage: capture.coverage,
+ page: {
+ facet: "diagnostics",
+ text,
+ status: value === undefined ? "unknown" : "retained",
+ },
+ coverage: pageViewCoverage(view.offset, text.length, value?.length ?? 0),
+ unknowns:
+ value === undefined
+ ? [
+ ...shared.unknowns,
+ "The parent did not retain diagnostics; an empty text page does not establish their absence.",
+ ]
+ : shared.unknowns,
+ });
+ }
+ const selected = selectRows(capture, view);
+ return selected.ok
+ ? ok({
+ kind: "pcap",
+ view,
+ ...shared,
+ source_coverage: capture.coverage,
+ page: selected.value.page,
+ coverage: pageViewCoverage(
+ view.offset,
+ selected.value.page.items.length,
+ selected.value.total,
+ ),
+ })
+ : selected;
+};
diff --git a/src/domain/pcap/pcapAnalysis.ts b/src/domain/pcap/pcapAnalysis.ts
new file mode 100644
index 000000000..21c64f365
--- /dev/null
+++ b/src/domain/pcap/pcapAnalysis.ts
@@ -0,0 +1,110 @@
+import { z } from "zod";
+import { localPathStringSchema } from "../localPath.js";
+import { digestSchema } from "../digests.js";
+
+/**
+ * Caller intent for packet-capture analysis through caller-supplied tshark
+ * and capinfos. REA never installs Wireshark or its tools.
+ */
+export const pcapInputSchemas = {
+ inspect_pcap_client: z.strictObject({}),
+ inspect_pcap_capture: z.strictObject({
+ path: localPathStringSchema.describe(
+ "Local pcap or pcapng capture file to inspect",
+ ),
+ }),
+} as const;
+
+/** Supported pcap-backed operations. */
+export type PcapOperation = keyof typeof pcapInputSchemas;
+/** Validated pcap requests carry their selected operation. */
+export type PcapRequest = {
+ [Name in PcapOperation]: {
+ operation: Name;
+ input: z.infer<(typeof pcapInputSchemas)[Name]>;
+ };
+}[PcapOperation];
+
+/** Validate operation and input together so their types remain correlated. */
+export const pcapRequestSchema = z.discriminatedUnion("operation", [
+ z.strictObject({
+ operation: z.literal("inspect_pcap_client"),
+ input: pcapInputSchemas.inspect_pcap_client,
+ }),
+ z.strictObject({
+ operation: z.literal("inspect_pcap_capture"),
+ input: pcapInputSchemas.inspect_pcap_capture,
+ }),
+]);
+
+const client = z.strictObject({
+ tshark_command: z.string().min(1),
+ tshark_source: z.enum(["environment", "path"]),
+ tshark_version: z.string().min(1).nullable(),
+ capinfos_command: z.string().min(1),
+ capinfos_source: z.enum(["environment", "path"]),
+ capinfos_version: z.string().min(1).nullable(),
+});
+
+const endpoint = z.strictObject({
+ address: z.string().min(1),
+ port: z.number().int().positive().nullable(),
+ packet_count: z.number().int().positive(),
+});
+
+const dnsQuery = z.strictObject({
+ query: z.string().min(1),
+ response_addresses: z.array(z.string().min(1)),
+ response_address_count: z.number().int().nonnegative(),
+});
+
+const protocolCount = z.strictObject({
+ protocol: z.string().min(1),
+ packet_count: z.number().int().positive(),
+});
+
+/** Portable pcap observations with explicit coverage limitations. */
+export const pcapResultSchemas = {
+ inspect_pcap_client: z.strictObject({
+ client,
+ }),
+ inspect_pcap_capture: z.strictObject({
+ client,
+ target: z.strictObject({
+ path: localPathStringSchema,
+ bytes: z.number().int().nonnegative(),
+ sha256: digestSchema,
+ }),
+ /** File format as capinfos reports it (pcap vs pcapng). */
+ file_type: z.string().min(1).nullable(),
+ encapsulation: z.string().min(1).nullable(),
+ packet_count: z.number().int().nonnegative().nullable(),
+ capture_start: z.string().nullable(),
+ capture_end: z.string().nullable(),
+ capture_duration_seconds: z.number().nonnegative().nullable(),
+ snapshot_length: z.number().int().positive().nullable(),
+ /** Distinct endpoints with packet counts, bounded. */
+ endpoints: z.array(endpoint),
+ endpoint_count: z.number().int().nonnegative(),
+ /** DNS queries with resolved addresses, bounded. */
+ dns_queries: z.array(dnsQuery),
+ dns_query_count: z.number().int().nonnegative(),
+ /** TLS Server Name Indication values observed in handshakes. */
+ tls_sni_values: z.array(z.string().min(1)),
+ tls_sni_count: z.number().int().nonnegative(),
+ /** Protocol distribution (frame.protocols column counts). */
+ protocol_distribution: z.array(protocolCount),
+ protocol_count: z.number().int().nonnegative(),
+ /** HTTP request URIs seen on plain-text ports, bounded. */
+ http_request_uris: z.array(z.string().min(1)),
+ http_request_uri_count: z.number().int().nonnegative(),
+ http_uri_redaction_count: z.number().int().nonnegative(),
+ unattributed_dns_addresses: z.array(z.string().min(1)),
+ unattributed_dns_address_count: z.number().int().nonnegative(),
+ unattributed_dns_packet_count: z.number().int().nonnegative(),
+ coverage: z.enum(["complete", "partial"]),
+ diagnostics: z
+ .strictObject({ capinfos_stderr: z.string(), tshark_stderr: z.string() })
+ .optional(),
+ }),
+} as const;
diff --git a/src/domain/pcap/pcapEvidence.ts b/src/domain/pcap/pcapEvidence.ts
new file mode 100644
index 000000000..848350fe3
--- /dev/null
+++ b/src/domain/pcap/pcapEvidence.ts
@@ -0,0 +1,34 @@
+import { createEvidence } from "../evidence.js";
+import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
+import type { PcapRequest } from "./pcapAnalysis.js";
+
+/** Domain facts needed by both successful and retained partial observations. */
+interface PcapObservation {
+ readonly subject: Parameters[0] | null;
+ readonly provider: Parameters[1];
+ readonly result: JsonValue;
+ readonly rawResult: JsonValue | null;
+ readonly limitations: readonly string[];
+ readonly locations: NonNullable<
+ Parameters[2]["locations"]
+ >;
+}
+
+/** One owner commits packet aggregation provenance and caller intent into Evidence. */
+export const createPcapEvidence = (
+ request: PcapRequest,
+ observation: PcapObservation,
+) =>
+ createEvidence(observation.subject ?? undefined, observation.provider, {
+ operation: request.operation,
+ parameters: jsonObjectSchema.parse(request.input),
+ result: observation.result,
+ rawResult: observation.rawResult,
+ limitations: observation.limitations,
+ locations: observation.locations,
+ confidence:
+ request.operation === "inspect_pcap_capture" ? "derived" : "observed",
+ ...(request.operation === "inspect_pcap_capture"
+ ? { authority: "historical-reference" as const }
+ : {}),
+ });
diff --git a/src/pcap/PcapCommand.ts b/src/pcap/PcapCommand.ts
new file mode 100644
index 000000000..a0833f599
--- /dev/null
+++ b/src/pcap/PcapCommand.ts
@@ -0,0 +1,207 @@
+import { randomUUID } from "node:crypto";
+import { AnalysisError } from "../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisProtocolError,
+ AnalysisResourceConstraintError,
+ AnalysisTimeoutError,
+ AnalysisUnsupportedTargetError,
+} from "../domain/analysisErrorCore.js";
+import {
+ OwnedCommandFailure,
+ runOwnedCommand,
+} from "../process/OwnedCommand.js";
+import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
+import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
+import {
+ isWindowsBatchFile,
+ windowsBatchCommand,
+ WindowsBatchCommandError,
+} from "../process/WindowsBatchCommand.js";
+
+export interface PcapCommandLimits {
+ readonly timeoutMs: number;
+ readonly maxOutputBytes: number;
+}
+
+/** Owned process execution shared by version probes and capture reads. */
+export const runPcapCommand = async (options: {
+ readonly operation: string;
+ readonly command: string;
+ readonly arguments: readonly string[];
+ readonly limits: PcapCommandLimits;
+ readonly environment: Readonly>;
+ readonly signal: AbortSignal | undefined;
+ readonly inputPath?: string;
+ readonly nativeBound?: {
+ readonly limiter?: { command: string; arguments: readonly string[] };
+ readonly memoryLimitBytes?: number;
+ };
+ readonly retain: (
+ owner: ProviderProcessSupervisor,
+ resources: readonly string[],
+ ) => void;
+}): Promise<{ stdout: string; stderr: string }> => {
+ const { operation, command, limits, signal } = options;
+ if (signal?.aborted)
+ throw new AnalysisCancelledError(operation, { cause: signal.reason });
+ const hostEnvironment = {
+ ...process.env,
+ ...options.environment,
+ LC_ALL: "C",
+ LANG: "C",
+ };
+ try {
+ const batch = process.platform === "win32" && isWindowsBatchFile(command);
+ const invocation = batch
+ ? windowsBatchCommand({
+ environment: hostEnvironment,
+ batchPath: command,
+ arguments: options.arguments,
+ })
+ : { command, arguments: options.arguments };
+ const boundedInvocation =
+ options.nativeBound?.limiter === undefined
+ ? invocation
+ : {
+ command: options.nativeBound.limiter.command,
+ arguments: [
+ ...options.nativeBound.limiter.arguments,
+ invocation.command,
+ ...invocation.arguments,
+ ],
+ };
+ // Native ownership preparation has its own bounded compiler lifecycle;
+ // do not spend the tool's execution deadline rebuilding a cold SDK cache.
+ await prepareProcessOwnershipInspection(signal);
+ const output = await runOwnedCommand(
+ {
+ ...boundedInvocation,
+ runId: randomUUID(),
+ hostEnvironment,
+ windowsVerbatimArguments: batch,
+ expectedCommand: null,
+ ...(options.nativeBound?.memoryLimitBytes === undefined
+ ? {}
+ : { memoryLimitBytes: options.nativeBound.memoryLimitBytes }),
+ },
+ { timeoutMs: limits.timeoutMs, diagnosticBytes: limits.maxOutputBytes },
+ signal === undefined ? {} : { signal },
+ );
+ return { stdout: output.stdout.text, stderr: output.stderr.text };
+ } catch (cause) {
+ if (cause instanceof AnalysisError) throw cause;
+ if (
+ cause instanceof OwnedCommandFailure &&
+ cause.cleanupOwner !== undefined
+ )
+ options.retain(cause.cleanupOwner, cause.resources);
+ const details = {
+ cause,
+ ...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
+ ? {
+ capturedOutput: {
+ stdout: "",
+ stderr: cause.snapshot.stderr.text,
+ truncated:
+ cause.snapshot.stdout.observedBytes > 0 ||
+ (cause.snapshot.diagnosticTruncated ?? false),
+ exit_code: cause.snapshot.exitCode ?? null,
+ signal: cause.snapshot.signal ?? null,
+ },
+ }
+ : {}),
+ ...(cause instanceof OwnedCommandFailure && cause.cleanupFailure !== null
+ ? {
+ cleanup: {
+ reason: cause.cleanupFailure,
+ resources: cause.resources,
+ },
+ }
+ : {}),
+ };
+ if (
+ signal?.aborted ||
+ (cause instanceof OwnedCommandFailure && cause.reason === "cancelled")
+ )
+ throw new AnalysisCancelledError(operation, details);
+ if (
+ cause instanceof OwnedCommandFailure &&
+ cause.snapshot?.memoryLimitReached === true
+ )
+ throw new AnalysisResourceConstraintError(
+ operation,
+ "memory",
+ "The owned Windows tool tree reached its enforced committed-memory limit.",
+ {
+ process_memory_limit_bytes:
+ options.nativeBound?.memoryLimitBytes ?? null,
+ },
+ details,
+ );
+ if (
+ cause instanceof OwnedCommandFailure &&
+ (cause.snapshot?.signal === "SIGXCPU" ||
+ cause.snapshot?.signal === "SIGXFSZ")
+ )
+ throw new AnalysisResourceConstraintError(
+ operation,
+ cause.snapshot.signal === "SIGXCPU" ? "cpu" : "file-size",
+ `Producer terminated with ${cause.snapshot.signal}; its exact cause is not independently established.`,
+ null,
+ details,
+ );
+ if (cause instanceof OwnedCommandFailure && cause.reason === "timeout")
+ throw new AnalysisTimeoutError(operation, limits.timeoutMs, details);
+ if (cause instanceof OwnedCommandFailure && cause.reason === "output-limit")
+ throw new AnalysisResourceConstraintError(
+ operation,
+ "memory",
+ "Tool output exceeded its complete-output budget",
+ { max_output_bytes: limits.maxOutputBytes },
+ details,
+ );
+ if (
+ cause instanceof WindowsBatchCommandError ||
+ (cause instanceof Error &&
+ "code" in cause &&
+ (cause.code === "ENOENT" || cause.code === "EACCES"))
+ )
+ throw new AnalysisCapabilityUnavailableError(
+ "pcap",
+ operation,
+ `Cannot run selected tool ${command}: ${cause.message}`,
+ details,
+ );
+ const diagnostic =
+ cause instanceof OwnedCommandFailure
+ ? cause.snapshot?.stderr.text.trim() || cause.message
+ : cause instanceof Error
+ ? cause.message
+ : String(cause);
+ if (options.inputPath === undefined)
+ throw new AnalysisCapabilityUnavailableError(
+ "pcap",
+ operation,
+ `The selected tool ${command} cannot report its version: ${diagnostic}`,
+ details,
+ );
+ if (
+ options.inputPath !== undefined &&
+ /not a capture file|file.*isn't a capture file|file format.*not supported/iu.test(
+ diagnostic,
+ )
+ )
+ throw new AnalysisUnsupportedTargetError(
+ operation,
+ options.inputPath,
+ diagnostic,
+ details,
+ );
+ throw new AnalysisProtocolError(
+ `Packet capture tool ${command} failed: ${diagnostic}`,
+ details,
+ );
+ }
+};
diff --git a/src/pcap/PcapConfiguration.test.ts b/src/pcap/PcapConfiguration.test.ts
new file mode 100644
index 000000000..edbd223b2
--- /dev/null
+++ b/src/pcap/PcapConfiguration.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ CAPINFOS_COMMAND_ENV,
+ TSHARK_COMMAND_ENV,
+ resolvePcapTools,
+} from "./PcapConfiguration.js";
+import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js";
+
+describe("resolvePcapTools", () => {
+ it("prefers absolute environment paths for both tools", () => {
+ expect(
+ resolvePcapTools({
+ [TSHARK_COMMAND_ENV]: "/usr/bin/tshark",
+ [CAPINFOS_COMMAND_ENV]: "/usr/bin/capinfos",
+ }),
+ ).toEqual({
+ tshark: { command: "/usr/bin/tshark", source: "environment" },
+ capinfos: { command: "/usr/bin/capinfos", source: "environment" },
+ });
+ });
+
+ it("falls back to PATH resolution without the environment keys", () => {
+ expect(resolvePcapTools({})).toEqual({
+ tshark: { command: "tshark", source: "path" },
+ capinfos: { command: "capinfos", source: "path" },
+ });
+ });
+
+ it("rejects a relative tshark path explicitly", () => {
+ expect(() =>
+ resolvePcapTools({ [TSHARK_COMMAND_ENV]: "bin/tshark" }),
+ ).toThrow(/must be an absolute path/u);
+ });
+
+ it("rejects a relative capinfos path explicitly", () => {
+ expect(() =>
+ resolvePcapTools({ [CAPINFOS_COMMAND_ENV]: "capinfos" }),
+ ).toThrow(/must be an absolute path/u);
+ });
+});
+
+const REAL_CAPINFOS = [
+ "File name: /work/rich-capture.pcap",
+ "File type: Wireshark/tcpdump/... - pcap",
+ "File encapsulation: Linux cooked-mode capture v2",
+ "Packet size limit: file hdr: 262144 bytes",
+ "Number of packets: 50",
+ "File size: 23279 bytes",
+ "Capture duration: 0.373473 seconds",
+ "Earliest packet time: 2026-10-11 14:13:40.608505",
+ "Latest packet time: 2026-10-11 14:13:40.981978",
+ "SHA256: c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
+].join("\n");
+
+describe("parseCapinfosOutput", () => {
+ it("projects metadata from real capinfos output", () => {
+ expect(parseCapinfosOutput(REAL_CAPINFOS)).toEqual({
+ fileType: "Wireshark/tcpdump/... - pcap",
+ encapsulation: "Linux cooked-mode capture v2",
+ packetCount: 50,
+ captureStart: "2026-10-11 14:13:40.608505",
+ captureEnd: "2026-10-11 14:13:40.981978",
+ captureDurationSeconds: 0.373473,
+ snapshotLength: 262144,
+ sha256:
+ "c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
+ });
+ });
+
+ it("rejects empty metadata rather than reporting complete unknown facts", () => {
+ expect(() => parseCapinfosOutput("")).toThrow(/machine-readable/u);
+ });
+});
+
+const REAL_TSHARK_JSON = JSON.stringify([
+ {
+ _source: {
+ layers: {
+ "frame.number": ["1"],
+ "frame.protocols": ["sll:ethertype:ip:udp:dns"],
+ "ip.src": ["192.168.1.1"],
+ "ip.dst": ["8.8.8.8"],
+ "dns.qry.name": ["example.com"],
+ "dns.flags.response": ["1"],
+ "dns.a": ["93.184.216.34"],
+ },
+ },
+ },
+ {
+ _source: {
+ layers: {
+ "frame.number": ["2"],
+ "frame.protocols": ["sll:ethertype:ip:tcp:tls"],
+ "ip.src": ["192.168.1.1"],
+ "ip.dst": ["93.184.216.34"],
+ "tls.handshake.extensions_server_name": ["example.com"],
+ },
+ },
+ },
+ {
+ _source: {
+ layers: {
+ "frame.number": ["3"],
+ "frame.protocols": ["sll:ethertype:ip:tcp:http"],
+ "ip.src": ["192.168.1.1"],
+ "ip.dst": ["93.184.216.34"],
+ "http.request.full_uri": ["http://example.com/page"],
+ },
+ },
+ },
+]);
+
+describe("parseTsharkJsonOutput", () => {
+ it("projects endpoints, DNS, TLS SNI, protocols, and HTTP URIs", async () => {
+ const result = await parseTsharkJsonOutput(REAL_TSHARK_JSON);
+ expect(result.endpoints).toEqual(
+ expect.arrayContaining([
+ { address: "192.168.1.1", port: null, packet_count: 3 },
+ { address: "8.8.8.8", port: null, packet_count: 1 },
+ { address: "93.184.216.34", port: null, packet_count: 2 },
+ ]),
+ );
+ expect(result.dnsQueries).toEqual([
+ {
+ query: "example.com",
+ response_addresses: ["93.184.216.34"],
+ response_address_count: 1,
+ },
+ ]);
+ expect(result.tlsSniValues).toEqual(["example.com"]);
+ expect(result.httpRequestUris).toEqual(["http://example.com/page"]);
+ expect(result.protocolDistribution).toEqual(
+ expect.arrayContaining([
+ { protocol: "sll:ethertype:ip:udp:dns", packet_count: 1 },
+ { protocol: "sll:ethertype:ip:tcp:tls", packet_count: 1 },
+ { protocol: "sll:ethertype:ip:tcp:http", packet_count: 1 },
+ ]),
+ );
+ });
+
+ it("retains every endpoint within the aggregate resource budget", async () => {
+ const packets = Array.from({ length: 10 }, (_, index) => ({
+ _source: {
+ layers: {
+ "frame.number": [String(index + 1)],
+ "frame.protocols": ["ip:tcp"],
+ "ip.src": [`10.0.0.${index}`],
+ "ip.dst": ["10.0.1.1"],
+ },
+ },
+ }));
+ const result = await parseTsharkJsonOutput(JSON.stringify(packets));
+ expect(result.endpoints).toHaveLength(11);
+ expect(result.endpointCount).toBe(11); // 10 src + 1 dst
+ });
+
+ it.each([
+ "",
+ "not json",
+ "{}",
+ "null",
+ "[null]",
+ '[{"_source":{"layers":{"ip.src":[3]}}}]',
+ ])("rejects malformed producer output %s", async (text) => {
+ await expect(parseTsharkJsonOutput(text)).rejects.toMatchObject({
+ _tag: "AnalysisProtocolError",
+ });
+ });
+});
diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts
new file mode 100644
index 000000000..8f75b4602
--- /dev/null
+++ b/src/pcap/PcapConfiguration.ts
@@ -0,0 +1,153 @@
+import { isAbsolute } from "node:path";
+
+import type { ProviderAvailability } from "../application/AnalysisProvider.js";
+import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
+import type { JsonValue } from "../domain/jsonValue.js";
+
+/** Environment keys selecting the caller's tshark and capinfos binaries. */
+export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND";
+export const CAPINFOS_COMMAND_ENV = "REA_CAPINFOS_COMMAND";
+
+/** How each tool was selected for this provider instance. */
+export interface PcapToolSelection {
+ readonly tshark: { command: string; source: "environment" | "path" };
+ readonly capinfos: { command: string; source: "environment" | "path" };
+}
+
+/**
+ * Resolve the caller-supplied tshark and capinfos binaries. Each accepts an
+ * absolute environment path or falls back to PATH resolution, mirroring the
+ * mitmdump pattern. REA never installs the Wireshark suite.
+ */
+export const resolvePcapTools = (
+ environment: Readonly>,
+): PcapToolSelection => {
+ const tsharkEnv = environment[TSHARK_COMMAND_ENV];
+ const capinfosEnv = environment[CAPINFOS_COMMAND_ENV];
+ if (
+ tsharkEnv !== undefined &&
+ tsharkEnv.trim() !== "" &&
+ !isAbsolute(tsharkEnv)
+ )
+ throw new PcapConfigurationFailure(
+ "tshark_not_absolute",
+ `${TSHARK_COMMAND_ENV} must be an absolute path to the tshark binary`,
+ { configured: tsharkEnv },
+ );
+ if (
+ capinfosEnv !== undefined &&
+ capinfosEnv.trim() !== "" &&
+ !isAbsolute(capinfosEnv)
+ )
+ throw new PcapConfigurationFailure(
+ "capinfos_not_absolute",
+ `${CAPINFOS_COMMAND_ENV} must be an absolute path to the capinfos binary`,
+ { configured: capinfosEnv },
+ );
+ return {
+ tshark: {
+ command:
+ tsharkEnv === undefined || tsharkEnv.trim() === ""
+ ? "tshark"
+ : tsharkEnv,
+ source:
+ tsharkEnv === undefined || tsharkEnv.trim() === ""
+ ? "path"
+ : "environment",
+ },
+ capinfos: {
+ command:
+ capinfosEnv === undefined || capinfosEnv.trim() === ""
+ ? "capinfos"
+ : capinfosEnv,
+ source:
+ capinfosEnv === undefined || capinfosEnv.trim() === ""
+ ? "path"
+ : "environment",
+ },
+ };
+};
+
+export type PcapConfigurationFailureCode =
+ | "tshark_not_absolute"
+ | "capinfos_not_absolute"
+ | "tshark_missing"
+ | "capinfos_missing"
+ | "tshark_version_unresolved"
+ | "capinfos_version_unresolved"
+ | "tshark_version_unsupported"
+ | "capinfos_version_unsupported";
+
+export class PcapConfigurationFailure extends Error {
+ constructor(
+ readonly code: PcapConfigurationFailureCode,
+ message: string,
+ readonly diagnostics: Readonly> = {},
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ }
+}
+
+/** Recognize the selected Wireshark tool's version banner. */
+const parseVersion = (
+ stdout: string,
+ tool: "tshark" | "capinfos",
+): string | null => {
+ const line = stdout
+ .split(/\r?\n/u)
+ .map((entry) => entry.trim())
+ .find((entry) => entry !== "");
+ const parsed =
+ line === undefined
+ ? null
+ : new RegExp(`^${tool} \\(Wireshark\\) (\\d+)\\.\\d+\\.\\d+`, "iu").exec(
+ line,
+ );
+ if (parsed === null || line === undefined) return null;
+ if (parsed[1] !== "4")
+ throw new PcapConfigurationFailure(
+ `${tool}_version_unsupported`,
+ "Packet summary inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.",
+ { version: line },
+ );
+ return line;
+};
+
+export interface PcapClientIdentity {
+ readonly tsharkVersion: string | null;
+ readonly capinfosVersion: string | null;
+}
+
+/** Probe both tools without reading any capture file. */
+export const inspectPcapClient = async (
+ selection: PcapToolSelection,
+ probe: (command: string) => Promise,
+): Promise => ({
+ tsharkVersion: parseVersion(await probe(selection.tshark.command), "tshark"),
+ capinfosVersion: parseVersion(
+ await probe(selection.capinfos.command),
+ "capinfos",
+ ),
+});
+
+/** Availability in the shape shared by every provider. */
+const rejectionCodeOf = (
+ code: PcapConfigurationFailureCode,
+): ProviderRejectionCode =>
+ code.includes("missing")
+ ? "executable_missing"
+ : code.includes("unsupported")
+ ? "unsupported_version"
+ : code.includes("not_absolute")
+ ? "not_configured"
+ : "version_unresolved";
+
+export const pcapConfigurationAvailability = (
+ cause: PcapConfigurationFailure,
+): ProviderAvailability => ({
+ status: "unavailable",
+ code: rejectionCodeOf(cause.code),
+ reason: cause.message,
+ diagnostics: { ...cause.diagnostics },
+});
diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts
new file mode 100644
index 000000000..172eaba91
--- /dev/null
+++ b/src/pcap/PcapNativeBound.ts
@@ -0,0 +1,68 @@
+import { access, realpath } from "node:fs/promises";
+import { constants } from "node:fs";
+import { isAbsolute } from "node:path";
+import { AnalysisError } from "../domain/analysisErrorBase.js";
+import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
+import type { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
+import type { runPcapCommand } from "./PcapCommand.js";
+
+/** Own native capture admission and the exact enforced resource profile. */
+export const preparePcapNativeBound = async (
+ environment: Readonly>,
+ resources: ArtifactAnalysisResources,
+): Promise<{
+ nativeBound: NonNullable[0]["nativeBound"]>;
+ limitations: readonly string[];
+}> => {
+ if (process.platform === "win32") {
+ return {
+ nativeBound: { memoryLimitBytes: 1024 ** 3 },
+ limitations: [
+ "Each owned Windows tool tree has a 1 GiB Job Object committed-memory limit; this is not a machine-wide quota.",
+ ],
+ };
+ }
+ if (process.platform !== "linux")
+ throw new AnalysisCapabilityUnavailableError(
+ "pcap",
+ "inspect_pcap_capture",
+ "Packet summaries require an enforceable native memory bound: Linux with util-linux prlimit, or Windows with owned Job Objects. This host's capture boundary remains unsupported.",
+ );
+ const configured =
+ environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
+ try {
+ if (!isAbsolute(configured))
+ throw new Error("REA_TSHARK_PRLIMIT_COMMAND must be absolute.");
+ const limiter = await realpath(configured);
+ await access(limiter, constants.X_OK);
+ const limits = await resources.readLimits({
+ address_space_bytes: 1024 ** 3,
+ file_bytes: 64 * 1024 ** 2,
+ cpu_seconds: 300,
+ });
+ const nativeBound = {
+ limiter: {
+ command: limiter,
+ arguments: [
+ `--as=${limits.address_space_bytes}:${limits.address_space_bytes}`,
+ `--fsize=${limits.file_bytes}:${limits.file_bytes}`,
+ `--cpu=${limits.cpu_seconds}:${limits.cpu_seconds}`,
+ "--core=0:0",
+ "--",
+ ],
+ },
+ };
+ const limitations = [
+ `Linux prlimit preserves stricter inherited ceilings: ${limits.address_space_bytes} bytes of per-process address space, ${limits.file_bytes} bytes per file and ${limits.cpu_seconds} CPU seconds; core dumps are disabled. These are not aggregate machine quotas.`,
+ ];
+ return { nativeBound, limitations };
+ } catch (cause) {
+ if (cause instanceof AnalysisError) throw cause;
+ throw new AnalysisCapabilityUnavailableError(
+ "pcap",
+ "inspect_pcap_capture",
+ "Cannot establish the selected Linux resource limiter before processing capture bytes.",
+ { cause },
+ );
+ }
+};
diff --git a/src/pcap/PcapOutput.test.ts b/src/pcap/PcapOutput.test.ts
new file mode 100644
index 000000000..6cfa6b0d3
--- /dev/null
+++ b/src/pcap/PcapOutput.test.ts
@@ -0,0 +1,149 @@
+import { expect, it } from "vitest";
+import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js";
+
+const capture = (layers: ReadonlyArray>) =>
+ JSON.stringify(
+ layers.map((fields, index) => ({
+ _source: {
+ layers: {
+ "frame.number": [String(index + 1)],
+ "frame.protocols": ["ip:udp:dns"],
+ ...fields,
+ },
+ },
+ })),
+ );
+
+it("binds interleaved DNS answers to the question in their response packet", async () => {
+ const result = await parseTsharkJsonOutput(
+ capture([
+ { "dns.qry.name": ["alpha.example"], "dns.flags.response": ["False"] },
+ { "dns.qry.name": ["beta.example"], "dns.flags.response": ["False"] },
+ {
+ "dns.qry.name": ["alpha.example"],
+ "dns.flags.response": ["True"],
+ "dns.a": ["192.0.2.10"],
+ },
+ {
+ "dns.qry.name": ["beta.example"],
+ "dns.flags.response": ["True"],
+ "dns.a": ["192.0.2.20"],
+ },
+ ]),
+ );
+ expect(result.dnsQueries).toEqual([
+ {
+ query: "alpha.example",
+ response_addresses: ["192.0.2.10"],
+ response_address_count: 1,
+ },
+ {
+ query: "beta.example",
+ response_addresses: ["192.0.2.20"],
+ response_address_count: 1,
+ },
+ ]);
+ expect(result.partial).toBe(false);
+});
+
+it("retains unassignable DNS addresses without inventing a query relationship", async () => {
+ const result = await parseTsharkJsonOutput(
+ capture([
+ {
+ "dns.qry.name": ["alpha.example", "beta.example"],
+ "dns.flags.response": ["True"],
+ "dns.a": ["192.0.2.9"],
+ },
+ { "dns.flags.response": ["True"], "dns.aaaa": ["2001:db8::9"] },
+ ]),
+ );
+ expect(
+ result.dnsQueries.every((query) => query.response_addresses.length === 0),
+ ).toBe(true);
+ expect(result.unattributedDnsAddresses).toEqual(["192.0.2.9", "2001:db8::9"]);
+ expect(result.unattributedDnsPackets).toBe(2);
+ expect(result.partial).toBe(true);
+});
+
+it("counts IPv6 endpoints once per packet and preserves every examined fact", async () => {
+ const packets = Array.from({ length: 51 }, (_, index) => ({
+ "ipv6.src": ["2001:db8::1"],
+ "ipv6.dst": ["2001:db8::1"],
+ "tls.handshake.extensions_server_name": [`host${String(index)}.example`],
+ "frame.protocols": [`ipv6:protocol${String(index)}`],
+ }));
+ const result = await parseTsharkJsonOutput(capture(packets));
+ expect(result.endpoints).toEqual([
+ { address: "2001:db8::1", port: null, packet_count: 51 },
+ ]);
+ expect(result.tlsSniCount).toBe(51);
+ expect(result.tlsSniValues).toHaveLength(51);
+ expect(result.protocolCount).toBe(51);
+ expect(result.protocolDistribution).toHaveLength(51);
+ expect(result.partial).toBe(false);
+});
+
+it("preserves all DNS addresses and their exact examined count", async () => {
+ const result = await parseTsharkJsonOutput(
+ capture(
+ Array.from({ length: 101 }, (_, index) => ({
+ "dns.qry.name": ["rotating.example"],
+ "dns.flags.response": ["True"],
+ "dns.a": [`192.0.2.${String(index)}`],
+ })),
+ ),
+ );
+ expect(result.dnsQueries[0]?.response_addresses).toHaveLength(101);
+ expect(result.dnsQueries[0]?.response_address_count).toBe(101);
+ expect(result.partial).toBe(false);
+});
+
+it("accepts exact machine packet counts and rejects scaled human counts", () => {
+ const prefix =
+ "File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: ";
+ expect(parseCapinfosOutput(`${prefix}20010`).packetCount).toBe(20010);
+ expect(() => parseCapinfosOutput(`${prefix}20 k`)).toThrow(
+ /machine-readable/u,
+ );
+});
+
+it("excludes transport credentials without normalizing observed URL bytes", async () => {
+ const result = await parseTsharkJsonOutput(
+ capture([
+ {
+ "http.request.full_uri": [
+ "http://user:password@Example.com/a/../b?token=local-evidence#keep",
+ ],
+ },
+ {
+ "http.request.full_uri": [
+ "http://other:secret@Example.com/a/../b?token=local-evidence#keep",
+ ],
+ },
+ ]),
+ );
+ expect(result.httpRequestUris).toEqual([
+ "http://Example.com/a/../b?token=local-evidence#keep",
+ ]);
+ expect(result.httpRequestUriCount).toBe(2);
+ expect(result.httpUriRedactionCount).toBe(2);
+ expect(result.partial).toBe(true);
+});
+
+it("refuses dense JSON before its expanded graph exceeds the decoding budget", async () => {
+ await expect(
+ parseTsharkJsonOutput(`[${"{},".repeat(600_000)}{}]`),
+ ).rejects.toMatchObject({
+ _tag: "AnalysisResourceConstraintError",
+ });
+});
+
+it("permits cancellation while validating and aggregating producer output", async () => {
+ const controller = new AbortController();
+ const result = parseTsharkJsonOutput(
+ capture([{ "ip.src": ["192.0.2.1"] }]),
+ controller.signal,
+ );
+ controller.abort(new Error("stopped"));
+ await expect(result).rejects.toThrow("stopped");
+});
diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts
new file mode 100644
index 000000000..95cb81926
--- /dev/null
+++ b/src/pcap/PcapOutput.ts
@@ -0,0 +1,315 @@
+import { setImmediate as yieldToEventLoop } from "node:timers/promises";
+import {
+ AnalysisProtocolError,
+ AnalysisResourceConstraintError,
+} from "../domain/analysisErrorCore.js";
+import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js";
+
+const OPERATION = "inspect_pcap_capture";
+/** Bounds JSON decoding plus its expanded object graph before JSON.parse. */
+export const TSHARK_JSON_MAX_BYTES = 8 * 1024 * 1024;
+const MAX_RETAINED_BYTES = 16 * 1024 * 1024;
+const MAX_DECODED_BYTES = 128 * 1024 * 1024;
+
+const protocolFailure = (reason: string): never => {
+ throw new AnalysisProtocolError(`Invalid packet-capture output: ${reason}`);
+};
+
+export interface CapinfosMetadata {
+ readonly fileType: string;
+ readonly encapsulation: string;
+ readonly packetCount: number;
+ readonly captureStart: string | null;
+ readonly captureEnd: string | null;
+ readonly captureDurationSeconds: number | null;
+ readonly snapshotLength: number | null;
+ readonly sha256: string | null;
+}
+
+/** capinfos -M with a C locale provides exact numbers instead of "20 k". */
+export const parseCapinfosOutput = (stdout: string): CapinfosMetadata => {
+ const lines = new Map();
+ for (const line of stdout.split(/\r?\n/u)) {
+ const at = line.indexOf(":");
+ if (at >= 0 && !/^\s/u.test(line))
+ lines.set(line.slice(0, at), line.slice(at + 1).trim());
+ }
+ const fileType = lines.get("File type");
+ const encapsulation = lines.get("File encapsulation");
+ const countText = lines.get("Number of packets");
+ if (
+ !fileType ||
+ !encapsulation ||
+ countText === undefined ||
+ !/^\d+$/u.test(countText)
+ )
+ return protocolFailure(
+ "capinfos did not provide machine-readable type, encapsulation and packet count",
+ );
+ const packetCount = Number(countText);
+ if (!Number.isSafeInteger(packetCount))
+ return protocolFailure("packet count is not representable");
+ const durationText = lines.get("Capture duration");
+ const duration =
+ durationText === undefined
+ ? null
+ : /^(\d+(?:\.\d+)?)(?:\s+seconds?)?$/u.exec(durationText);
+ const snapshot = /^file hdr:\s*(\d+) bytes$/u.exec(
+ lines.get("Packet size limit") ?? "",
+ );
+ const snapshotLength = snapshot === null ? null : Number(snapshot[1]);
+ const sha256 = lines.get("SHA256") ?? null;
+ if (sha256 !== null && !/^[0-9a-f]{64}$/u.test(sha256))
+ return protocolFailure("capinfos SHA256 is malformed");
+ return {
+ fileType,
+ encapsulation,
+ packetCount,
+ captureStart: lines.get("Earliest packet time") ?? null,
+ captureEnd: lines.get("Latest packet time") ?? null,
+ captureDurationSeconds: duration === null ? null : Number(duration[1]),
+ snapshotLength:
+ snapshotLength !== null &&
+ Number.isSafeInteger(snapshotLength) &&
+ snapshotLength > 0
+ ? snapshotLength
+ : null,
+ sha256,
+ };
+};
+
+export interface TsharkFields {
+ readonly endpoints: { address: string; port: null; packet_count: number }[];
+ readonly endpointCount: number;
+ readonly dnsQueries: {
+ query: string;
+ response_addresses: string[];
+ response_address_count: number;
+ }[];
+ readonly dnsQueryCount: number;
+ readonly tlsSniValues: string[];
+ readonly tlsSniCount: number;
+ readonly protocolDistribution: { protocol: string; packet_count: number }[];
+ readonly protocolCount: number;
+ readonly httpRequestUris: string[];
+ readonly httpRequestUriCount: number;
+ readonly httpUriRedactionCount: number;
+ readonly unattributedDnsAddresses: string[];
+ readonly unattributedDnsAddressCount: number;
+ readonly unattributedDnsPackets: number;
+ readonly packetCount: number;
+ readonly partial: boolean;
+}
+
+const object = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+/** Validate the fixed JSON field representation before retaining aggregate facts. */
+export const parseTsharkJsonOutput = async (
+ stdout: string,
+ signal?: AbortSignal,
+): Promise => {
+ signal?.throwIfAborted();
+ if (Buffer.byteLength(stdout) > TSHARK_JSON_MAX_BYTES)
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "memory",
+ "tshark JSON exceeds the bounded decoding budget",
+ { max_json_bytes: TSHARK_JSON_MAX_BYTES },
+ );
+ // The selected-field protocol has five container levels. Reject deeper
+ // graphs before materializing them; quoted brackets are string data.
+ let depth = 0,
+ quoted = false,
+ escaped = false;
+ let decodedBytes = stdout.length * 2;
+ for (let index = 0; index < stdout.length; index += 1) {
+ if (index % 65536 === 0) {
+ await yieldToEventLoop();
+ signal?.throwIfAborted();
+ }
+ const char = stdout[index];
+ if (quoted) {
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') quoted = false;
+ } else if (char === '"') {
+ quoted = true;
+ decodedBytes += 64;
+ } else if (char === "{" || char === "[") {
+ decodedBytes += 128;
+ if (++depth > 5) protocolFailure("unexpected JSON nesting");
+ } else if (char === "}" || char === "]") depth -= 1;
+ else if (char === ":" || char === ",") decodedBytes += 128;
+ if (decodedBytes > MAX_DECODED_BYTES)
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "memory",
+ "tshark JSON expansion exceeds the decoding budget",
+ { max_decoded_bytes: MAX_DECODED_BYTES },
+ );
+ }
+ let packets: unknown;
+ try {
+ packets = JSON.parse(stdout) as unknown;
+ } catch (cause) {
+ throw new AnalysisProtocolError("tshark emitted malformed JSON", { cause });
+ }
+ if (!Array.isArray(packets))
+ return protocolFailure("tshark packet root must be an array");
+ const endpoints = new Map();
+ const dns = new Map>();
+ const sni = new Set();
+ const protocols = new Map();
+ const uris = new Set();
+ const observedUris = new Set();
+ let uriRedactions = 0;
+ const unattributed = new Set();
+ let unattributedPackets = 0;
+ let retainedBytes = 0;
+ const charge = (value: string): void => {
+ retainedBytes += 192 + 2 * Buffer.byteLength(value);
+ if (retainedBytes > MAX_RETAINED_BYTES)
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "memory",
+ "Packet inventory exceeds the retained-fact budget",
+ { max_retained_bytes: MAX_RETAINED_BYTES },
+ );
+ };
+ const add = (set: Set, value: string): void => {
+ if (!set.has(value)) {
+ charge(value);
+ set.add(value);
+ }
+ };
+ for (let index = 0; index < packets.length; index += 1) {
+ if (index % 256 === 0) {
+ await yieldToEventLoop();
+ signal?.throwIfAborted();
+ }
+ const packet: unknown = packets[index];
+ if (
+ !object(packet) ||
+ !object(packet._source) ||
+ !object(packet._source.layers)
+ )
+ return protocolFailure(
+ `packet ${String(index + 1)} lacks _source.layers`,
+ );
+ const layers = packet._source.layers;
+ for (const [key, value] of Object.entries(layers))
+ if (
+ !Array.isArray(value) ||
+ !value.every((item) => typeof item === "string")
+ )
+ return protocolFailure(
+ `packet ${String(index + 1)} field ${key} must be an array of strings`,
+ );
+ const field = (name: string): readonly string[] =>
+ (layers[name] as string[] | undefined) ?? [];
+ const frame = field("frame.number");
+ if (
+ frame.length !== 1 ||
+ !/^\d+$/u.test(frame[0]!) ||
+ Number(frame[0]) !== index + 1
+ )
+ return protocolFailure(
+ "frame inventory is missing, duplicated or out of order",
+ );
+ if (
+ field("frame.protocols").length !== 1 ||
+ field("frame.protocols")[0] === ""
+ )
+ return protocolFailure(
+ "packet protocol inventory is missing or ambiguous",
+ );
+ // Count one occurrence per packet even for loopback or nested IP layers.
+ for (const address of new Set([
+ ...field("ip.src"),
+ ...field("ip.dst"),
+ ...field("ipv6.src"),
+ ...field("ipv6.dst"),
+ ])) {
+ if (address === "") continue;
+ if (!endpoints.has(address)) charge(address);
+ endpoints.set(address, (endpoints.get(address) ?? 0) + 1);
+ }
+ const questions = new Set(
+ field("dns.qry.name").filter((value) => value !== ""),
+ );
+ for (const question of questions)
+ if (!dns.has(question)) {
+ charge(question);
+ dns.set(question, new Set());
+ }
+ const addresses = [...field("dns.a"), ...field("dns.aaaa")].filter(
+ (value) => value !== "",
+ );
+ const response = field("dns.flags.response");
+ if (addresses.length > 0) {
+ if (
+ questions.size === 1 &&
+ response.length > 0 &&
+ response.every((value) => value === "True" || value === "1")
+ ) {
+ const values = dns.get(questions.values().next().value!)!;
+ for (const address of addresses) add(values, address);
+ } else {
+ unattributedPackets += 1;
+ for (const address of addresses) add(unattributed, address);
+ }
+ }
+ for (const value of field("tls.handshake.extensions_server_name"))
+ if (value !== "") add(sni, value);
+ for (const value of new Set(field("frame.protocols"))) {
+ if (value === "") continue;
+ if (!protocols.has(value)) charge(value);
+ protocols.set(value, (protocols.get(value) ?? 0) + 1);
+ }
+ for (const value of field("http.request.full_uri")) {
+ if (value === "" || observedUris.has(value)) continue;
+ add(observedUris, value);
+ const { credentials } = parseRecordedTransportUrl(value);
+ if (credentials !== null) uriRedactions += 1;
+ if (credentials === "ambiguous") continue;
+ add(
+ uris,
+ credentials === null
+ ? value
+ : value.slice(0, credentials.start) + value.slice(credentials.end),
+ );
+ }
+ }
+ signal?.throwIfAborted();
+ return {
+ endpoints: [...endpoints]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .map(([address, packet_count]) => ({
+ address,
+ port: null,
+ packet_count,
+ })),
+ endpointCount: endpoints.size,
+ dnsQueries: [...dns].map(([query, addresses]) => ({
+ query,
+ response_addresses: [...addresses],
+ response_address_count: addresses.size,
+ })),
+ dnsQueryCount: dns.size,
+ tlsSniValues: [...sni],
+ tlsSniCount: sni.size,
+ protocolDistribution: [...protocols]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .map(([protocol, packet_count]) => ({ protocol, packet_count })),
+ protocolCount: protocols.size,
+ httpRequestUris: [...uris],
+ httpRequestUriCount: observedUris.size,
+ httpUriRedactionCount: uriRedactions,
+ unattributedDnsAddresses: [...unattributed],
+ unattributedDnsAddressCount: unattributed.size,
+ unattributedDnsPackets: unattributedPackets,
+ packetCount: packets.length,
+ partial: observedUris.size > uris.size || unattributedPackets > 0,
+ };
+};
diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts
new file mode 100644
index 000000000..7228d694f
--- /dev/null
+++ b/src/pcap/PcapProvider.ts
@@ -0,0 +1,493 @@
+import type {
+ ExecutionOptions,
+ ProviderAvailability,
+} from "../application/AnalysisProvider.js";
+import {
+ createAnalysisExecution,
+ type AnalysisExecution,
+} from "../application/AnalysisProvider.js";
+import {
+ pcapResultSchemas,
+ type PcapRequest,
+ type PcapOperation,
+} from "../domain/pcap/pcapAnalysis.js";
+import {
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisProtocolError,
+} from "../domain/analysisErrorCore.js";
+import { AnalysisError } from "../domain/analysisErrorBase.js";
+import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
+import { err, ok, type Result } from "../domain/result.js";
+import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
+import {
+ PcapConfigurationFailure,
+ inspectPcapClient,
+ resolvePcapTools,
+ pcapConfigurationAvailability,
+ type PcapToolSelection,
+} from "./PcapConfiguration.js";
+import {
+ parseCapinfosOutput,
+ parseTsharkJsonOutput,
+ TSHARK_JSON_MAX_BYTES,
+} from "./PcapOutput.js";
+import { runPcapCommand, type PcapCommandLimits } from "./PcapCommand.js";
+import { ProviderCleanupError } from "../domain/providerCleanupError.js";
+import { createPcapEvidence } from "../domain/pcap/pcapEvidence.js";
+import { preparePcapNativeBound } from "./PcapNativeBound.js";
+import { PcapSnapshot } from "./PcapSnapshot.js";
+import { readStableArtifact } from "../artifacts/readStableArtifact.js";
+import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
+import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js";
+
+export const PCAP_PROVIDER_IDENTITY = {
+ id: "pcap",
+ name: "Packet capture analysis",
+ version: null,
+} as const;
+const FIELDS = [
+ "frame.number",
+ "frame.protocols",
+ "ip.src",
+ "ip.dst",
+ "ipv6.src",
+ "ipv6.dst",
+ "dns.flags.response",
+ "dns.qry.name",
+ "dns.a",
+ "dns.aaaa",
+ "tls.handshake.extensions_server_name",
+ "http.request.full_uri",
+];
+
+export interface PcapProviderOptions {
+ readonly environment?: Readonly>;
+}
+
+/** Inspect saved captures through bounded, owned tool processes and one stable input copy. */
+export class PcapProvider {
+ readonly #resources = new ArtifactAnalysisResources(
+ "pcap",
+ "inspect_pcap_capture",
+ readStableArtifact,
+ readLinuxResourceLimits,
+ {
+ message: "A packet summary is already active; retry after it completes.",
+ details: { max_concurrent_inspections: 1 },
+ },
+ );
+ private readonly environment: Readonly>;
+ private readonly snapshots = new Set();
+ private readonly commands = new Map<
+ ProviderProcessSupervisor,
+ { snapshot: PcapSnapshot | undefined; resources: readonly string[] }
+ >();
+
+ constructor(options: PcapProviderOptions = {}) {
+ this.environment = options.environment ?? process.env;
+ }
+
+ private run(
+ operation: PcapOperation,
+ command: string,
+ args: readonly string[],
+ limits: PcapCommandLimits,
+ signal: AbortSignal | undefined,
+ snapshot?: PcapSnapshot,
+ inputPath?: string,
+ ) {
+ return runPcapCommand({
+ operation,
+ command,
+ arguments: args,
+ limits,
+ signal,
+ environment:
+ snapshot === undefined
+ ? this.environment
+ : {
+ ...this.environment,
+ WIRESHARK_CONFIG_DIR: snapshot.configurationPath,
+ },
+ ...(inputPath === undefined ? {} : { inputPath }),
+ ...(inputPath === undefined || this.nativeBound === undefined
+ ? {}
+ : { nativeBound: this.nativeBound }),
+ retain: (owner, resources) => {
+ this.commands.set(owner, { snapshot, resources });
+ },
+ });
+ }
+
+ private nativeBound: Parameters[0]["nativeBound"];
+ private nativeLimitations: readonly string[] = [];
+
+ private async prepareCaptureBound(): Promise {
+ const prepared = await preparePcapNativeBound(
+ this.environment,
+ this.#resources,
+ );
+ this.nativeBound = prepared.nativeBound;
+ this.nativeLimitations = prepared.limitations;
+ }
+
+ private async client(operation: PcapOperation, signal?: AbortSignal) {
+ const selection = resolvePcapTools(this.environment);
+ const identity = await inspectPcapClient(
+ selection,
+ async (command) =>
+ (
+ await this.run(
+ operation,
+ command,
+ ["--version"],
+ { timeoutMs: 20_000, maxOutputBytes: 64 * 1024 },
+ signal,
+ )
+ ).stdout,
+ );
+ for (const [name, version] of [
+ ["tshark", identity.tsharkVersion],
+ ["capinfos", identity.capinfosVersion],
+ ] as const) {
+ if (version === null)
+ throw new PcapConfigurationFailure(
+ `${name}_version_unresolved`,
+ `The selected ${name} command did not report a version. Set REA_${name.toUpperCase()}_COMMAND to its absolute executable path.`,
+ );
+ }
+ return {
+ selection,
+ client: {
+ tshark_command: selection.tshark.command,
+ tshark_source: selection.tshark.source,
+ tshark_version: identity.tsharkVersion,
+ capinfos_command: selection.capinfos.command,
+ capinfos_source: selection.capinfos.source,
+ capinfos_version: identity.capinfosVersion,
+ },
+ };
+ }
+
+ async inspectAvailability(
+ signal?: AbortSignal,
+ ): Promise {
+ const result = await this.#resources.run(
+ async () => ok(await this.inspectAvailabilityAdmitted(signal)),
+ signal,
+ );
+ if (!result.ok) throw result.error;
+ return result.value;
+ }
+
+ private async inspectAvailabilityAdmitted(
+ signal?: AbortSignal,
+ ): Promise {
+ try {
+ const { client } = await this.client("inspect_pcap_client", signal);
+ if (process.platform !== "linux" && process.platform !== "win32")
+ return {
+ status: "unavailable",
+ code: "unsupported_host",
+ reason:
+ "No enforceable native memory boundary is available for capture summaries on this host.",
+ diagnostics: client,
+ };
+ await this.prepareCaptureBound();
+ return {
+ status: "available",
+ code: null,
+ reason: null,
+ diagnostics: client,
+ };
+ } catch (cause) {
+ if (signal?.aborted || cause instanceof AnalysisCancelledError)
+ throw new AnalysisCancelledError("inspect_pcap_client", { cause });
+ if (cause instanceof PcapConfigurationFailure)
+ return pcapConfigurationAvailability(cause);
+ return {
+ status: "unavailable",
+ code:
+ cause instanceof AnalysisCapabilityUnavailableError
+ ? "executable_missing"
+ : "version_unresolved",
+ reason: cause instanceof Error ? cause.message : String(cause),
+ diagnostics: {},
+ };
+ }
+ }
+
+ private async discard(
+ snapshot: PcapSnapshot,
+ ): Promise {
+ try {
+ if (
+ [...this.commands.values()].some(
+ (command) => command.snapshot === snapshot,
+ )
+ )
+ throw new Error(
+ "An unreleased command still owns the capture snapshot",
+ );
+ await snapshot.close();
+ this.snapshots.delete(snapshot);
+ return undefined;
+ } catch (cause) {
+ return new ProviderCleanupError(
+ "pcap",
+ [snapshot.path],
+ { reason: String(cause) },
+ { operation: "inspect_pcap_capture", cause },
+ );
+ }
+ }
+
+ async close(): Promise {
+ const failures: unknown[] = [];
+ try {
+ await this.#resources.close();
+ } catch (cause) {
+ failures.push(cause);
+ }
+ for (const owner of this.commands.keys()) {
+ try {
+ const result = await owner.stop();
+ if (result.status === "incomplete") failures.push(result.reason);
+ else if (result.status === "not-owned")
+ failures.push("Command ownership remains unresolved");
+ else this.commands.delete(owner);
+ } catch (cause) {
+ failures.push(cause);
+ }
+ }
+ for (const snapshot of this.snapshots) {
+ const failure = await this.discard(snapshot);
+ if (failure !== undefined) failures.push(failure);
+ }
+ if (failures.length > 0)
+ throw new ProviderCleanupError(
+ "pcap",
+ [...this.snapshots]
+ .map((snapshot) => snapshot.path)
+ .concat(
+ [...this.commands.values()].flatMap((command) => command.resources),
+ ),
+ { reason: "Owned process or snapshot cleanup failed" },
+ { operation: "pcap.close", cause: new AggregateError(failures) },
+ );
+ }
+
+ async execute(
+ request: PcapRequest,
+ options?: ExecutionOptions,
+ ): Promise> {
+ return this.#resources.run(
+ () => this.executeAdmitted(request, options),
+ options?.signal,
+ );
+ }
+
+ private async executeAdmitted(
+ request: PcapRequest,
+ options?: ExecutionOptions,
+ ): Promise> {
+ const signal = options?.signal;
+ let snapshot: PcapSnapshot | undefined;
+ let result: Result;
+ try {
+ signal?.throwIfAborted();
+ const { selection, client } = await this.client(
+ request.operation,
+ signal,
+ );
+ const provider = {
+ ...PCAP_PROVIDER_IDENTITY,
+ version: client.tshark_version,
+ };
+ if (request.operation === "inspect_pcap_client")
+ result = ok(
+ createAnalysisExecution(
+ pcapResultSchemas.inspect_pcap_client.parse({ client }),
+ provider,
+ ),
+ );
+ else {
+ await this.prepareCaptureBound();
+ snapshot = await PcapSnapshot.create();
+ this.snapshots.add(snapshot);
+ const identity = await snapshot.capture(request.input.path, signal);
+ const captured = await this.inspectCapture(
+ selection,
+ snapshot,
+ request.input.path,
+ identity,
+ signal,
+ );
+ const data = pcapResultSchemas.inspect_pcap_capture.parse({
+ client,
+ ...captured.result,
+ });
+ result = ok(
+ createAnalysisExecution(data, provider, {
+ subject: {
+ path: request.input.path,
+ format: "file",
+ sha256: identity.sha256,
+ },
+ limitations: captured.limitations,
+ }),
+ );
+ }
+ } catch (cause) {
+ result = err(
+ cause instanceof AnalysisError
+ ? cause
+ : signal?.aborted
+ ? new AnalysisCancelledError(request.operation, { cause })
+ : cause instanceof PcapConfigurationFailure
+ ? new AnalysisCapabilityUnavailableError(
+ "pcap",
+ request.operation,
+ cause.message,
+ { cause },
+ )
+ : new AnalysisProtocolError(
+ `Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`,
+ { cause },
+ ),
+ );
+ }
+ if (signal?.aborted && result.ok)
+ result = err(
+ new AnalysisCancelledError(request.operation, {
+ cause: signal.reason,
+ partialObservation: createPcapEvidence(request, result.value),
+ }),
+ );
+ if (snapshot !== undefined) {
+ const cleanup = await this.discard(snapshot);
+ if (signal?.aborted && result.ok)
+ result = err(
+ new AnalysisCancelledError(request.operation, {
+ cause: signal.reason,
+ partialObservation: createPcapEvidence(request, result.value),
+ }),
+ );
+ if (cleanup !== undefined)
+ result = result.ok
+ ? err(
+ cleanup.retainPartialObservation(
+ createPcapEvidence(request, result.value),
+ ),
+ )
+ : err(
+ analysisErrorWithCleanupFailure(
+ result.error,
+ cleanup,
+ request.operation,
+ ),
+ );
+ }
+ return result;
+ }
+
+ private async inspectCapture(
+ selection: PcapToolSelection,
+ snapshot: PcapSnapshot,
+ path: string,
+ identity: { bytes: number; sha256: string },
+ signal: AbortSignal | undefined,
+ ) {
+ const info = await this.run(
+ "inspect_pcap_capture",
+ selection.capinfos.command,
+ ["-M", "--", snapshot.path],
+ { timeoutMs: 60_000, maxOutputBytes: 64 * 1024 },
+ signal,
+ snapshot,
+ path,
+ );
+ const metadata = parseCapinfosOutput(info.stdout);
+ if (metadata.sha256 !== null && metadata.sha256 !== identity.sha256)
+ throw new AnalysisProtocolError(
+ "capinfos SHA256 disagrees with the owned capture snapshot",
+ );
+ const dissected = await this.run(
+ "inspect_pcap_capture",
+ selection.tshark.command,
+ [
+ "-n",
+ "-r",
+ snapshot.path,
+ "-T",
+ "json",
+ "--no-duplicate-keys",
+ `--temp-dir=${snapshot.configurationPath}`,
+ ...FIELDS.flatMap((field) => ["-e", field]),
+ ],
+ { timeoutMs: 300_000, maxOutputBytes: TSHARK_JSON_MAX_BYTES },
+ signal,
+ snapshot,
+ path,
+ );
+ const fields = await parseTsharkJsonOutput(dissected.stdout, signal);
+ await snapshot.verify(identity, path, signal);
+ if (fields.packetCount !== metadata.packetCount)
+ throw new AnalysisProtocolError(
+ `capinfos counted ${String(metadata.packetCount)} packets but tshark returned ${String(fields.packetCount)}`,
+ );
+ return {
+ result: {
+ target: { path, ...identity },
+ file_type: metadata.fileType,
+ encapsulation: metadata.encapsulation,
+ packet_count: metadata.packetCount,
+ capture_start: metadata.captureStart,
+ capture_end: metadata.captureEnd,
+ capture_duration_seconds: metadata.captureDurationSeconds,
+ diagnostics: {
+ capinfos_stderr: info.stderr,
+ tshark_stderr: dissected.stderr,
+ },
+ snapshot_length: metadata.snapshotLength,
+ endpoints: fields.endpoints,
+ endpoint_count: fields.endpointCount,
+ dns_queries: fields.dnsQueries,
+ dns_query_count: fields.dnsQueryCount,
+ tls_sni_values: fields.tlsSniValues,
+ tls_sni_count: fields.tlsSniCount,
+ protocol_distribution: fields.protocolDistribution,
+ protocol_count: fields.protocolCount,
+ http_request_uris: fields.httpRequestUris,
+ http_request_uri_count: fields.httpRequestUriCount,
+ http_uri_redaction_count: fields.httpUriRedactionCount,
+ unattributed_dns_addresses: fields.unattributedDnsAddresses,
+ unattributed_dns_address_count: fields.unattributedDnsAddressCount,
+ unattributed_dns_packet_count: fields.unattributedDnsPackets,
+ coverage: fields.partial ? "partial" : "complete",
+ },
+ limitations: [
+ ...this.nativeLimitations,
+ "Both tools read one owned snapshot; target identity comes from its admitted source descriptor",
+ "DNS address values are associated only with a single question in the same response packet; values may include additional DNS records, not only final resolution answers",
+ "Only bytes present in the saved capture are examined; encryption and capture truncation can hide higher-level fields",
+ ...(fields.partial
+ ? [
+ "One or more projected categories were capped, credential-bearing URIs were omitted or coalesced, or DNS address attribution remains unresolved; counts retain the examined totals",
+ ]
+ : []),
+ ...(fields.httpUriRedactionCount > 0
+ ? [
+ "HTTP transport credentials were excluded; ambiguous credential spans cause the URI to be omitted",
+ ]
+ : []),
+ ...(fields.unattributedDnsPackets > 0
+ ? [
+ `DNS address attribution was unresolved in ${String(fields.unattributedDnsPackets)} packet(s); unattributed addresses are retained separately`,
+ ]
+ : []),
+ ],
+ };
+ }
+}
diff --git a/src/pcap/PcapSnapshot.ts b/src/pcap/PcapSnapshot.ts
new file mode 100644
index 000000000..08f1ea552
--- /dev/null
+++ b/src/pcap/PcapSnapshot.ts
@@ -0,0 +1,269 @@
+import { createHash } from "node:crypto";
+import { chmod, open, stat, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import { AnalysisError } from "../domain/analysisErrorBase.js";
+import {
+ AnalysisAccessDeniedError,
+ AnalysisArtifactChangedError,
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisInputError,
+ AnalysisResourceConstraintError,
+} from "../domain/analysisErrorCore.js";
+import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
+import {
+ openRegularFile,
+ RegularFileAdmissionFailure,
+ sameRegularFileState,
+} from "../filesystem/RegularFile.js";
+import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
+import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
+
+const OPERATION = "inspect_pcap_capture";
+const MAX_CAPTURE_BYTES = 2 * 1024 ** 3;
+
+/** An owned capture copy and every descriptor needed to release it safely. */
+export class PcapSnapshot {
+ private readonly handles = new Set();
+ readonly path: string;
+ readonly configurationPath: string;
+ private constructor(private readonly root: PrivateRuntimeRoot) {
+ this.path = join(root.path, "capture.pcap");
+ this.configurationPath = join(root.path, "configuration");
+ }
+
+ static async create(): Promise {
+ try {
+ return new PcapSnapshot(
+ await PrivateRuntimeRoot.create({ prefix: "rea-pcap-" }),
+ );
+ } catch (cause) {
+ if (cause instanceof PrivateRuntimeRootUnavailableError)
+ throw new AnalysisCapabilityUnavailableError(
+ "pcap",
+ OPERATION,
+ cause.message,
+ { cause },
+ );
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "file-size",
+ "Cannot allocate the owned capture workspace",
+ null,
+ { cause },
+ );
+ }
+ }
+
+ private own(handle: OwnedFileHandle): OwnedFileHandle {
+ this.handles.add(handle);
+ return handle;
+ }
+ private async release(handle: OwnedFileHandle): Promise {
+ await handle.close();
+ this.handles.delete(handle);
+ }
+
+ async capture(
+ path: string,
+ signal?: AbortSignal,
+ ): Promise<{ bytes: number; sha256: string }> {
+ const sourceRead = async (read: () => Promise): Promise => {
+ try {
+ return await read();
+ } catch (cause) {
+ if (signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, { cause });
+ if (cause instanceof AnalysisError) throw cause;
+ if (
+ cause instanceof Error &&
+ "code" in cause &&
+ (cause.code === "EACCES" || cause.code === "EPERM")
+ )
+ throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
+ cause,
+ });
+ throw new AnalysisInputError(OPERATION, { cause }, [
+ {
+ path: ["path"],
+ reason: "invalid_value",
+ message: `Cannot read selected capture ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
+ },
+ ]);
+ }
+ };
+ try {
+ signal?.throwIfAborted();
+ await mkdir(this.configurationPath, { mode: 0o700 });
+ const source = await sourceRead(async () => {
+ try {
+ return this.own(
+ new OwnedFileHandle(
+ await openRegularFile(path, { symlinks: "follow", signal }),
+ ),
+ );
+ } catch (cause) {
+ if (cause instanceof RegularFileAdmissionFailure) {
+ this.own(cause.owner);
+ throw cause.cause;
+ }
+ throw cause;
+ }
+ });
+ const before = await sourceRead(() =>
+ source.handle.stat({ bigint: true }),
+ );
+ if (before.size > BigInt(MAX_CAPTURE_BYTES))
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "file-size",
+ "Capture exceeds the owned-snapshot disk budget",
+ { max_capture_bytes: MAX_CAPTURE_BYTES },
+ );
+ const destination = this.own(
+ new OwnedFileHandle(await open(this.path, "wx", 0o600)),
+ );
+ const buffer = Buffer.alloc(64 * 1024);
+ const hash = createHash("sha256");
+ let copied = 0;
+ while (copied <= Number(before.size)) {
+ signal?.throwIfAborted();
+ const { bytesRead } = await sourceRead(() =>
+ source.handle.read(
+ buffer,
+ 0,
+ Math.min(buffer.length, Number(before.size) - copied + 1),
+ copied,
+ ),
+ );
+ signal?.throwIfAborted();
+ if (bytesRead === 0) break;
+ copied += bytesRead;
+ if (BigInt(copied) > before.size)
+ throw new AnalysisArtifactChangedError(
+ OPERATION,
+ path,
+ "Capture grew during acquisition",
+ );
+ const chunk = buffer.subarray(0, bytesRead);
+ await destination.handle.writeFile(chunk);
+ hash.update(chunk);
+ }
+ const after = await sourceRead(() =>
+ source.handle.stat({ bigint: true }),
+ );
+ const selected = await stat(path, { bigint: true }).catch(
+ (cause: unknown) => {
+ if (
+ cause instanceof Error &&
+ "code" in cause &&
+ (cause.code === "EACCES" || cause.code === "EPERM")
+ )
+ throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
+ cause,
+ });
+ throw new AnalysisArtifactChangedError(
+ OPERATION,
+ path,
+ "Selected path became unavailable during acquisition",
+ { cause },
+ );
+ },
+ );
+ if (
+ BigInt(copied) !== before.size ||
+ !sameRegularFileState(before, after) ||
+ !sameRegularFileState(before, selected)
+ )
+ throw new AnalysisArtifactChangedError(
+ OPERATION,
+ path,
+ "Capture identity or contents changed during acquisition",
+ );
+ await this.release(source);
+ await this.release(destination);
+ await chmod(this.path, 0o400);
+ signal?.throwIfAborted();
+ return { bytes: copied, sha256: hash.digest("hex") };
+ } catch (cause) {
+ if (signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, { cause });
+ if (cause instanceof AnalysisError) throw cause;
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "file-size",
+ "Owned capture snapshot I/O failed",
+ null,
+ { cause },
+ );
+ }
+ }
+
+ /** Recheck byte identity after both tools; a read-only mode alone is not proof. */
+ async verify(
+ expected: { bytes: number; sha256: string },
+ selectedPath: string,
+ signal?: AbortSignal,
+ ): Promise {
+ try {
+ const file = this.own(
+ new OwnedFileHandle(
+ await openRegularFile(this.path, { symlinks: "reject", signal }),
+ ),
+ );
+ const before = await file.handle.stat({ bigint: true });
+ const buffer = Buffer.alloc(64 * 1024);
+ const hash = createHash("sha256");
+ let bytes = 0;
+ while (bytes <= expected.bytes) {
+ signal?.throwIfAborted();
+ const read = await file.handle.read(
+ buffer,
+ 0,
+ Math.min(buffer.length, expected.bytes - bytes + 1),
+ bytes,
+ );
+ if (read.bytesRead === 0) break;
+ bytes += read.bytesRead;
+ hash.update(buffer.subarray(0, read.bytesRead));
+ }
+ const after = await file.handle.stat({ bigint: true });
+ const selected = await stat(this.path, { bigint: true });
+ if (
+ bytes !== expected.bytes ||
+ hash.digest("hex") !== expected.sha256 ||
+ !sameRegularFileState(before, after) ||
+ !sameRegularFileState(before, selected)
+ )
+ throw new AnalysisArtifactChangedError(
+ OPERATION,
+ selectedPath,
+ "Owned packet snapshot changed during tool analysis.",
+ );
+ await this.release(file);
+ } catch (cause) {
+ if (signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, { cause });
+ if (cause instanceof RegularFileAdmissionFailure) this.own(cause.owner);
+ if (cause instanceof AnalysisError) throw cause;
+ throw new AnalysisArtifactChangedError(
+ OPERATION,
+ selectedPath,
+ "Cannot establish the owned packet snapshot's post-analysis identity.",
+ { cause },
+ );
+ }
+ }
+
+ async close(): Promise {
+ const results = await Promise.allSettled(
+ [...this.handles].map((handle) => this.release(handle)),
+ );
+ const failures = results.flatMap((result) =>
+ result.status === "rejected" ? [result.reason as unknown] : [],
+ );
+ if (failures.length > 0)
+ throw new AggregateError(failures, "Capture descriptor cleanup failed");
+ await this.root.close();
+ }
+}
diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts
index 5aafcd787..335e72fbe 100644
--- a/src/process/DarwinProcessRunTokenReader.ts
+++ b/src/process/DarwinProcessRunTokenReader.ts
@@ -74,7 +74,9 @@ const sourceFiles = [
export const SWIFTC_INTERRUPT = "SIGINT";
/** Deadline for the one-shot helper compilation. */
-const COMPILER_TIMEOUT_MS = 60_000;
+// A fresh Swift SDK cache took 88 seconds on the supported Intel macOS VM.
+// Keep compilation bounded independently of short provider version probes.
+const COMPILER_TIMEOUT_MS = 120_000;
/** Bound on compiler diagnostics embedded in the inspection failure text. */
const STDERR_EXCERPT_LIMIT = 512;
@@ -253,7 +255,7 @@ export type DarwinProcessReaderCompiler =
export const createDarwinProcessRunTokenReader = (
options: {
readonly compiler?: DarwinProcessReaderCompiler;
- /** Deadline for the helper compilation; the default keeps verification fast. */
+ /** Deadline for helper compilation, independent of provider execution. */
readonly compileTimeoutMs?: number;
/**
* Caller-owned directory holding the compiler's module cache. Unset keeps
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index 4929142c4..294984339 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -84,6 +84,7 @@ import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnaly
import { createFirmwareAnalysisProvider } from "../composition/firmware.js";
import { registerAndroidTools } from "./registerAndroidTools.js";
import { registerApktoolTools } from "./registerApktoolTools.js";
+import { registerPcapTools } from "./registerPcapTools.js";
import { registerFlutterTools } from "./registerFlutterTools.js";
import { registerJebTools } from "./registerJebTools.js";
import { registerAdbTools } from "./registerAdbTools.js";
@@ -91,11 +92,14 @@ import { AndroidAnalysisService } from "../application/android/AndroidAnalysisSe
import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js";
import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js";
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
+import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
+import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js";
import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js";
import { createAndroidAnalysisProvider } from "../composition/android.js";
import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js";
+import { createPcapAnalysisProvider } from "../composition/pcap.js";
import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js";
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
import { createJebAnalysisProvider } from "../composition/jeb.js";
@@ -146,6 +150,7 @@ export interface CreateServerOptions {
readonly webNetworkCapture?: WebNetworkCaptureService;
readonly androidAnalysis?: AndroidAnalysisPort;
readonly apktoolAnalysis?: ApktoolResourceAnalysisPort;
+ readonly pcapAnalysis?: PcapAnalysisPort;
readonly flutterAnalysis?: FlutterBuildAnalysisPort;
readonly jebAnalysis?: JebAnalysisPort;
readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort;
@@ -303,6 +308,7 @@ export const createServer = (
);
const android =
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
+ const pcap = options.pcapAnalysis ?? createPcapAnalysisProvider(environment);
const flutter =
options.flutterAnalysis ?? createFlutterBuildAnalysisProvider();
const fridaInstrumentation =
@@ -333,6 +339,7 @@ export const createServer = (
jeb.close(),
adbDevice.close(),
flutter.close(),
+ pcap.close(),
evmInterface.close(),
sqliteDatabase.close(),
binaryLayout.close(),
@@ -438,6 +445,14 @@ export const createServer = (
recordEvidence,
analysisAdmission,
);
+ registerPcapTools(
+ server,
+ new PcapAnalysisService(pcap),
+ toolLogger,
+ recordEvidence,
+ analysisAdmission,
+ evidenceById,
+ );
registerFlutterTools(
server,
new FlutterBuildAnalysisService(flutter),
diff --git a/src/server/registerPcapTools.ts b/src/server/registerPcapTools.ts
new file mode 100644
index 000000000..b2ebd239a
--- /dev/null
+++ b/src/server/registerPcapTools.ts
@@ -0,0 +1,83 @@
+import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
+import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
+import { toolContract } from "../contracts/toolContracts.js";
+import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
+import type { Logger } from "pino";
+import {
+ createContractToolHandler,
+ createEvidenceToolHandler,
+} from "./contractToolHandler.js";
+import {
+ toolRegistrationOptions,
+ applicationValidatedInputSchema,
+} from "./toolRegistrationOptions.js";
+import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
+import {
+ requireAnalysisViewRetention,
+ summarizeRetainedAnalysis,
+} from "../application/analysisView/AnalysisViewService.js";
+import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
+import { err } from "../domain/result.js";
+
+/** Bind pcap handlers to their exact named contracts through admission. */
+export const registerPcapTools = (
+ server: EvidenceMcpServer,
+ service: PcapAnalysisService,
+ logger: Logger,
+ recordEvidence?: EvidenceWriter["recordEvidence"],
+ withAdmittedAnalysis?: WithAdmittedAnalysis,
+ evidenceById?: EvidenceLookup,
+): void => {
+ const handler = createContractToolHandler(
+ server,
+ service,
+ logger,
+ recordEvidence,
+ withAdmittedAnalysis,
+ );
+ const clientContract = toolContract("inspect_pcap_client");
+ server.registerTool(
+ clientContract.name,
+ toolRegistrationOptions(clientContract),
+ handler(clientContract),
+ );
+ const contract = toolContract("inspect_pcap_capture");
+ const registration = toolRegistrationOptions(contract);
+ const capture = createEvidenceToolHandler(
+ server,
+ async (rawInput, options) => {
+ const parsed = contract.inputSchema.safeParse(rawInput);
+ if (!parsed.success)
+ return err(
+ analysisInputErrorFromIssues(
+ contract.name,
+ parsed.error.issues,
+ rawInput,
+ { cause: parsed.error },
+ ),
+ );
+ const { detail, ...input } = parsed.data;
+ const retention = { recordEvidence, evidenceById };
+ if (detail === "summary") {
+ const ready = requireAnalysisViewRetention(contract.name, retention);
+ if (!ready.ok) return ready;
+ }
+ const result = await service.execute(contract.name, input, options);
+ return detail === "summary" && result.ok
+ ? summarizeRetainedAnalysis(result.value, retention)
+ : result;
+ },
+ logger,
+ recordEvidence,
+ withAdmittedAnalysis,
+ );
+ server.registerTool(
+ contract.name,
+ {
+ ...registration,
+ inputSchema: applicationValidatedInputSchema(registration.inputSchema),
+ },
+ capture(contract),
+ );
+};
diff --git a/tests/boundary/cli/pcapCli.test.ts b/tests/boundary/cli/pcapCli.test.ts
new file mode 100644
index 000000000..26e253455
--- /dev/null
+++ b/tests/boundary/cli/pcapCli.test.ts
@@ -0,0 +1,23 @@
+import { expect, it } from "vitest";
+
+import { serveCli } from "./cliServeHarness.js";
+
+it.each(["inspect-pcap-client", "inspect-pcap-capture"])(
+ "%s advertises a schema through the public CLI",
+ async (command) => {
+ const result = await serveCli([command, "--schema", "--json"]);
+ expect(result.exitCode).toBe(0);
+ expect(JSON.parse(result.stdout)).toBeTruthy();
+ },
+);
+
+it("reports missing tools with a capability refusal", async () => {
+ const result = await serveCli(
+ ["inspect-pcap-capture", "/nonexistent/capture.pcap", "--json"],
+ {
+ REA_TSHARK_COMMAND: "/nonexistent/tshark",
+ REA_CAPINFOS_COMMAND: "/nonexistent/capinfos",
+ },
+ );
+ expect(result.stdout).toContain("capability_unavailable");
+});
diff --git a/tests/boundary/mcp/pcapMcp.test.ts b/tests/boundary/mcp/pcapMcp.test.ts
new file mode 100644
index 000000000..d3a6d203d
--- /dev/null
+++ b/tests/boundary/mcp/pcapMcp.test.ts
@@ -0,0 +1,118 @@
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { expect, it, onTestFinished } from "vitest";
+
+import type { PcapAnalysisPort } from "../../../src/application/pcap/PcapAnalysisPort.js";
+import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
+import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { ok } from "../../../src/domain/result.js";
+import { createServer } from "../../../src/server/createServer.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+
+const identity = { id: "pcap", name: "Packet capture analysis", version: null };
+
+const clientIdentity = {
+ tshark_command: "/usr/bin/tshark",
+ tshark_source: "path",
+ tshark_version: "TShark (Wireshark) 4.6.6",
+ capinfos_command: "/usr/bin/capinfos",
+ capinfos_source: "path",
+ capinfos_version: "Capinfos (Wireshark) 4.6.6",
+};
+
+it("advertises exact valid pcap schemas and records inline evidence through MCP", async () => {
+ const provider: PcapAnalysisPort = {
+ async inspectAvailability() {
+ return { status: "available", code: null, reason: null, diagnostics: {} };
+ },
+ async close() {},
+ async execute(request) {
+ if (request.operation === "inspect_pcap_client")
+ return ok(
+ createAnalysisExecution({ client: clientIdentity }, identity),
+ );
+ return ok(
+ createAnalysisExecution(
+ {
+ client: clientIdentity,
+ target: {
+ path: "/captures/session.pcap",
+ bytes: 23279,
+ sha256: "a".repeat(64),
+ },
+ file_type: "Wireshark/tcpdump/... - pcap",
+ encapsulation: "Linux cooked-mode capture v2",
+ packet_count: 50,
+ capture_start: "2026-10-11 14:13:40.608505",
+ capture_end: "2026-10-11 14:13:40.981978",
+ capture_duration_seconds: 0.373473,
+ snapshot_length: 262144,
+ endpoints: [
+ { address: "192.168.1.100", port: null, packet_count: 30 },
+ { address: "8.8.8.8", port: 53, packet_count: 5 },
+ ],
+ endpoint_count: 2,
+ dns_queries: [
+ {
+ query: "example.com",
+ response_addresses: ["93.184.216.34"],
+ response_address_count: 1,
+ },
+ ],
+ dns_query_count: 1,
+ tls_sni_values: ["example.com"],
+ tls_sni_count: 1,
+ protocol_distribution: [
+ { protocol: "ip:tcp:tls", packet_count: 25 },
+ { protocol: "ip:udp:dns", packet_count: 5 },
+ ],
+ protocol_count: 2,
+ http_request_uris: ["http://example.com/api"],
+ http_request_uri_count: 1,
+ http_uri_redaction_count: 0,
+ unattributed_dns_addresses: [],
+ unattributed_dns_address_count: 0,
+ unattributed_dns_packet_count: 0,
+ coverage: "complete",
+ },
+ identity,
+ ),
+ );
+ },
+ };
+ const session = createTestBinarySession(() => {
+ throw new Error("deep provider must not start");
+ });
+ const server = createServer(
+ { kind: "session", session },
+ { pcapAnalysis: provider },
+ );
+ onTestFinished(() => server.close());
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ const client = new Client({ name: "pcap-boundary", version: "0.0.0" });
+ onTestFinished(() => client.close());
+ await client.connect(clientTransport);
+
+ const { tools } = await client.listTools();
+ assertAdvertisedMcpContracts(
+ ["inspect_pcap_client", "inspect_pcap_capture"],
+ tools,
+ );
+
+ const inspected = await client.callTool({
+ name: "inspect_pcap_capture",
+ arguments: { path: "/captures/session.pcap" },
+ });
+ expect(inspected.isError).not.toBe(true);
+ const text = (inspected.content as { readonly text?: string }[])
+ .map((part) => part.text ?? "")
+ .join("");
+ const evidence = parseEvidence(JSON.parse(text));
+ expect(evidence.normalized_result).toMatchObject({
+ packet_count: 50,
+ tls_sni_values: ["example.com"],
+ coverage: "complete",
+ });
+});
diff --git a/tests/boundary/pcap/pcapCommand.test.ts b/tests/boundary/pcap/pcapCommand.test.ts
new file mode 100644
index 000000000..cda8dbf77
--- /dev/null
+++ b/tests/boundary/pcap/pcapCommand.test.ts
@@ -0,0 +1,53 @@
+import { setTimeout as delay } from "node:timers/promises";
+import { expect, it, onTestFinished, vi } from "vitest";
+import { runPcapCommand } from "../../../src/pcap/PcapCommand.js";
+import * as ownership from "../../../src/process/ProcessOwnershipObservation.js";
+
+it("does not spend the tool execution deadline preparing native ownership", async () => {
+ const original = ownership.prepareProcessOwnershipInspection;
+ let entered!: () => void;
+ let release!: () => void;
+ const preparing = new Promise((resolve) => {
+ entered = resolve;
+ });
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ let first = true;
+ const prepare = vi
+ .spyOn(ownership, "prepareProcessOwnershipInspection")
+ .mockImplementation(async (signal) => {
+ await original(signal);
+ if (first) {
+ first = false;
+ entered();
+ await gate;
+ }
+ });
+ onTestFinished(() => {
+ prepare.mockRestore();
+ release();
+ });
+ const pending = runPcapCommand({
+ operation: "inspect_pcap_client",
+ command: process.execPath,
+ arguments: ["--version"],
+ limits: { timeoutMs: 1000, maxOutputBytes: 1024 },
+ environment: {},
+ signal: undefined,
+ retain: (owner) => {
+ onTestFinished(async () => {
+ expect((await owner.stop()).status).not.toBe("incomplete");
+ });
+ },
+ }).then(
+ (value) => ({ ok: true as const, value }),
+ (error: unknown) => ({ ok: false as const, error }),
+ );
+ await preparing;
+ await delay(1100);
+ release();
+ const result = await pending;
+ if (!result.ok) throw result.error;
+ expect(result.value.stdout.trim()).toBe(process.version);
+});
diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts
new file mode 100644
index 000000000..50204a479
--- /dev/null
+++ b/tests/boundary/pcap/pcapProvider.test.ts
@@ -0,0 +1,508 @@
+import { createHash } from "node:crypto";
+import { watch } from "node:fs";
+import { chmod, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, onTestFinished, test, vi } from "vitest";
+
+import { PcapProvider } from "../../../src/pcap/PcapProvider.js";
+import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
+import {
+ pcapResultSchemas,
+ type PcapRequest,
+} from "../../../src/domain/pcap/pcapAnalysis.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
+import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
+
+const it = test.skipIf(process.platform !== "linux");
+
+const CAPINFOS_OUTPUT = [
+ "File name: /work/capture.pcap",
+ "File type: Wireshark/tcpdump/... - pcap",
+ "File encapsulation: Ethernet",
+ "Packet size limit: file hdr: 262144 bytes",
+ "Number of packets: 3",
+ "Capture duration: 0.123 seconds",
+].join("\n");
+
+const TSHARK_JSON = JSON.stringify([
+ {
+ _source: {
+ layers: {
+ "frame.number": ["1"],
+ "frame.protocols": ["eth:ethertype:ip:udp:dns"],
+ "ip.src": ["192.168.1.100"],
+ "ip.dst": ["8.8.8.8"],
+ "dns.qry.name": ["api.example.com"],
+ "dns.flags.response": ["1"],
+ "dns.a": ["93.184.216.34"],
+ },
+ },
+ },
+ {
+ _source: {
+ layers: {
+ "frame.number": ["2"],
+ "frame.protocols": ["eth:ethertype:ip:tcp:tls"],
+ "ip.src": ["192.168.1.100"],
+ "ip.dst": ["93.184.216.34"],
+ "tls.handshake.extensions_server_name": ["api.example.com"],
+ },
+ },
+ },
+ {
+ _source: {
+ layers: {
+ "frame.number": ["3"],
+ "frame.protocols": ["eth:ethertype:ip:tcp:http"],
+ "ip.src": ["192.168.1.100"],
+ "ip.dst": ["93.184.216.34"],
+ "http.request.full_uri": ["http://api.example.com/v1/data"],
+ },
+ },
+ },
+]);
+
+const writeStubTools = async (
+ root: string,
+ options: { readonly failCapinfos?: boolean } = {},
+): Promise<{ tshark: string; capinfos: string }> => {
+ const tshark = join(root, "tshark");
+ const capinfos = join(root, "capinfos");
+ await writeFile(
+ tshark,
+ `#!/bin/sh
+if [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-stub\\n'; exit 0; fi
+[ "$1" = "-n" ] && [ "$2" = "-r" ] && [ "$4" = "-T" ] && [ "$5" = "json" ] || exit 2
+printf '%s' '${TSHARK_JSON.replace(/'/gu, "'\\''")}'
+exit 0
+`,
+ { mode: 0o755 },
+ );
+ await writeFile(
+ capinfos,
+ `#!/bin/sh
+if [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-stub\\n'; exit 0; fi
+[ "$1" = "-M" ] && [ "$2" = "--" ] && [ "$#" = "3" ] || exit 2
+if [ "${String(options.failCapinfos === true)}" = "true" ]; then
+ printf 'capinfos: The file is not a capture file' >&2; exit 1
+fi
+printf '%s\\n' '${CAPINFOS_OUTPUT.replace(/'/gu, "'\\''")}'
+exit 0
+`,
+ { mode: 0o755 },
+ );
+ await chmod(tshark, 0o755);
+ await chmod(capinfos, 0o755);
+ return { tshark, capinfos };
+};
+
+const providerWith = async (options: { failCapinfos?: boolean } = {}) => {
+ const root = await createTestTempDirectory("rea-pcap-");
+ const { tshark, capinfos } = await writeStubTools(root, options);
+ const provider = new PcapProvider({
+ environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
+ });
+ onTestFinished(() => provider.close());
+ return {
+ provider,
+ root,
+ };
+};
+
+const evidenceResult = async (
+ provider: PcapProvider,
+ request: PcapRequest,
+): Promise> => {
+ const result = await provider.execute(request);
+ if (!result.ok) throw new Error(result.error.message);
+ return pcapResultSchemas[request.operation].parse(result.value.result);
+};
+
+const captureFixture = async (root: string): Promise => {
+ const path = join(root, "capture.pcap");
+ await writeFile(path, Buffer.from("pcap-fixture-bytes"));
+ return path;
+};
+
+it("inspects both tools without reading a capture", async () => {
+ const { provider } = await providerWith();
+ const result = await evidenceResult(provider, {
+ operation: "inspect_pcap_client",
+ input: {},
+ });
+ expect(result.client).toMatchObject({
+ tshark_source: "environment",
+ tshark_version: "TShark (Wireshark) 4.6.6-stub",
+ capinfos_source: "environment",
+ capinfos_version: "Capinfos (Wireshark) 4.6.6-stub",
+ });
+});
+
+it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => {
+ const { provider, root } = await providerWith();
+ const path = await captureFixture(root);
+ const result = await evidenceResult(provider, {
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ expect(result.target).toMatchObject({
+ path,
+ bytes: 18,
+ sha256: createHash("sha256").update("pcap-fixture-bytes").digest("hex"),
+ });
+ expect(result.file_type).toBe("Wireshark/tcpdump/... - pcap");
+ expect(result.packet_count).toBe(3);
+ expect(result.endpoints).toEqual(
+ expect.arrayContaining([
+ { address: "192.168.1.100", port: null, packet_count: 3 },
+ { address: "8.8.8.8", port: null, packet_count: 1 },
+ ]),
+ );
+ expect(result.dns_queries).toEqual([
+ {
+ query: "api.example.com",
+ response_addresses: ["93.184.216.34"],
+ response_address_count: 1,
+ },
+ ]);
+ expect(result.tls_sni_values).toEqual(["api.example.com"]);
+ expect(result.http_request_uris).toEqual(["http://api.example.com/v1/data"]);
+ expect(result.coverage).toBe("complete");
+});
+
+it("reports a non-capture file as unsupported", async () => {
+ const { provider, root } = await providerWith({ failCapinfos: true });
+ const failPath = join(root, "fail.pcap");
+ await writeFile(failPath, "not a capture file");
+ const result = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path: failPath },
+ });
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "unsupported_target",
+ });
+});
+
+it("reports a missing tshark binary as a capability gap", async () => {
+ const provider = new PcapProvider({
+ environment: { REA_TSHARK_COMMAND: "/nonexistent/tshark" },
+ });
+ expect(await provider.inspectAvailability()).toMatchObject({
+ status: "unavailable",
+ code: "executable_missing",
+ });
+});
+
+it("rejects a capture that is not a readable file", async () => {
+ const { provider } = await providerWith();
+ const result = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path: "/nonexistent/capture.pcap" },
+ });
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "invalid_request",
+ });
+});
+
+it("cancels before spawning anything", async () => {
+ const { provider, root } = await providerWith();
+ await writeFile(join(root, "spawned"), "");
+ const controller = new AbortController();
+ controller.abort();
+ const result = await provider.execute(
+ {
+ operation: "inspect_pcap_capture",
+ input: { path: "/some/capture.pcap" },
+ },
+ { signal: controller.signal },
+ );
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "cancelled",
+ });
+});
+
+it("reports relative configuration and missing capinfos as capability gaps", async () => {
+ const root = await createTestTempDirectory("rea-pcap-config-");
+ const { tshark } = await writeStubTools(root);
+ for (const environment of [
+ { REA_TSHARK_COMMAND: "relative/tshark" },
+ {
+ REA_TSHARK_COMMAND: tshark,
+ REA_CAPINFOS_COMMAND: join(root, "missing-capinfos"),
+ },
+ ]) {
+ const provider = new PcapProvider({ environment });
+ onTestFinished(() => provider.close());
+ expect((await provider.inspectAvailability()).status).toBe("unavailable");
+ const result = await provider.execute({
+ operation: "inspect_pcap_client",
+ input: {},
+ });
+ if (result.ok) throw new Error("expected unavailable tools");
+ expect(projectAnalysisError(result.error).code).toBe(
+ "capability_unavailable",
+ );
+ }
+});
+
+const nodeTool = async (
+ root: string,
+ name: string,
+ body: string,
+): Promise => {
+ const path = join(root, name);
+ await writeFile(
+ path,
+ `#!/usr/bin/env -S ${process.execPath} --jitless --max-old-space-size=64\nconst fs = require('node:fs'); const crypto = require('node:crypto');\n${body}\n`,
+ { mode: 0o755 },
+ );
+ return path;
+};
+const versionProbe =
+ "if (process.argv[2] === '--version') { console.log((process.argv[1].includes('capinfos') ? 'Capinfos' : 'TShark') + ' (Wireshark) 4.6.6-test'); process.exit(0); }";
+
+it("refuses observations whose owned snapshot changed during dissection", async () => {
+ const root = await createTestTempDirectory("rea-pcap-mutated-");
+ const { capinfos } = await writeStubTools(root);
+ const path = await captureFixture(root);
+ const before = await readFile(path);
+ const tshark = await nodeTool(
+ root,
+ "tshark.cjs",
+ `${versionProbe}
+const selected = process.argv[process.argv.indexOf('-r') + 1];
+fs.chmodSync(selected, 0o600); fs.writeFileSync(selected, 'changed private snapshot');
+console.log(${JSON.stringify(TSHARK_JSON)});`,
+ );
+ const provider = new PcapProvider({
+ environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
+ });
+ onTestFinished(() => provider.close());
+ const result = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok)
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "artifact_changed",
+ });
+ expect(await readFile(path)).toEqual(before);
+});
+
+it("binds both tools to the snapshot when the original capture changes", async () => {
+ const root = await createTestTempDirectory("rea-pcap-identity-");
+ const path = join(root, "caller capture.pcap");
+ await writeFile(path, "192.0.2.1");
+ const capinfos = await nodeTool(
+ root,
+ "capinfos.cjs",
+ `${versionProbe}
+const selected = process.argv.at(-1); const bytes = fs.readFileSync(selected);
+console.log('File type: pcap\\nFile encapsulation: Raw IP\\nNumber of packets: 1\\nSHA256: ' + crypto.createHash('sha256').update(bytes).digest('hex'));
+fs.writeFileSync(${JSON.stringify(path)}, '192.0.2.2');`,
+ );
+ const tshark = await nodeTool(
+ root,
+ "tshark.cjs",
+ `${versionProbe}
+const selected = process.argv[process.argv.indexOf('-r') + 1];
+console.log(JSON.stringify([{_source:{layers:{'frame.number':['1'],'frame.protocols':['ip'],'ip.src':[fs.readFileSync(selected,'utf8')]}}}]));`,
+ );
+ const provider = new PcapProvider({
+ environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
+ });
+ onTestFinished(() => provider.close());
+ const result = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ if (!result.ok) throw result.error;
+ const value = pcapResultSchemas.inspect_pcap_capture.parse(
+ result.value.result,
+ );
+ expect(value.target.sha256).toBe(
+ createHash("sha256").update("192.0.2.1").digest("hex"),
+ );
+ expect(value.endpoints).toEqual([
+ { address: "192.0.2.1", port: null, packet_count: 1 },
+ ]);
+ expect(await readFile(path, "utf8")).toBe("192.0.2.2");
+});
+
+it.each(["probe", "capinfos", "tshark"] as const)(
+ "preserves in-flight %s cancellation and cleans up",
+ async (phase) => {
+ const root = await createTestTempDirectory("rea-pcap-cancel-");
+ const marker = join(root, "ready");
+ const slow = await nodeTool(
+ root,
+ `slow-${phase}.cjs`,
+ `${phase !== "probe" ? versionProbe : ""}
+fs.writeFileSync(${JSON.stringify(marker)}, 'ready'); setInterval(() => {}, 1000);`,
+ );
+ const { capinfos, tshark } = await writeStubTools(root);
+ const path = await captureFixture(root);
+ const provider = new PcapProvider({
+ environment: {
+ REA_TSHARK_COMMAND: phase === "capinfos" ? tshark : slow,
+ REA_CAPINFOS_COMMAND: phase === "capinfos" ? slow : capinfos,
+ },
+ });
+ onTestFinished(() => provider.close());
+ const controller = new AbortController();
+ let watcher: ReturnType | undefined;
+ let timer: ReturnType | undefined;
+ const ready = new Promise((resolve, reject) => {
+ watcher = watch(root, (_event, name) => {
+ if (name === "ready") resolve();
+ });
+ timer = setTimeout(() => {
+ controller.abort();
+ reject(new Error("tool did not start"));
+ }, 5000);
+ });
+ try {
+ const pending = provider.execute(
+ { operation: "inspect_pcap_capture", input: { path } },
+ { signal: controller.signal },
+ );
+ await ready;
+ controller.abort(new Error("caller cancellation"));
+ const result = await pending;
+ if (result.ok) throw new Error("expected cancellation");
+ expect(projectAnalysisError(result.error).code).toBe("cancelled");
+ await provider.close();
+ } finally {
+ watcher?.close();
+ clearTimeout(timer);
+ }
+ },
+);
+
+it.each(["change", "cancel"] as const)(
+ "preserves %s during snapshot acquisition",
+ async (action) => {
+ const { provider, root } = await providerWith();
+ const path = await captureFixture(root);
+ const controller = new AbortController();
+ const held = await holdFirstFileRead(path);
+ onTestFinished(() => {
+ vi.restoreAllMocks();
+ });
+ const pending = provider.execute(
+ { operation: "inspect_pcap_capture", input: { path } },
+ { signal: controller.signal },
+ );
+ try {
+ await held.firstRead;
+ if (action === "cancel") controller.abort();
+ else await writeFile(path, "changed source bytes");
+ } finally {
+ held.release();
+ }
+ const result = await pending;
+ if (result.ok) throw new Error("expected interrupted acquisition");
+ expect(result.error._tag).toBe(
+ action === "cancel"
+ ? "AnalysisCancelledError"
+ : "AnalysisArtifactChangedError",
+ );
+ },
+);
+
+it("admits one capture and drains it before close releases resources", async () => {
+ const { provider, root } = await providerWith();
+ const path = await captureFixture(root);
+ const held = await holdFirstFileRead(path);
+ onTestFinished(() => {
+ vi.restoreAllMocks();
+ });
+ const pending = provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ let closing: Promise | undefined;
+ try {
+ await held.firstRead;
+ const concurrent = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ expect(concurrent.ok).toBe(false);
+ if (!concurrent.ok)
+ expect(projectAnalysisError(concurrent.error)).toMatchObject({
+ code: "resource_constraint",
+ details: { resource: "capacity" },
+ });
+ let closed = false;
+ closing = provider.close().then(() => {
+ closed = true;
+ });
+ await Promise.resolve();
+ expect(closed).toBe(false);
+ } finally {
+ held.release();
+ }
+ expect((await pending).ok).toBe(true);
+ await closing;
+ const afterClose = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ expect(afterClose.ok).toBe(false);
+});
+
+it.each([false, true])(
+ "retains failed snapshot cleanup after an analysis failure=%s",
+ async (failCapinfos) => {
+ const { provider, root } = await providerWith({ failCapinfos });
+ const path = await captureFixture(root);
+ let retained: PrivateRuntimeRoot | undefined;
+ const spy = vi
+ .spyOn(PrivateRuntimeRoot.prototype, "close")
+ .mockImplementation(function (this: PrivateRuntimeRoot) {
+ retained = this;
+ return Promise.reject(new Error("controlled cleanup failure"));
+ });
+ const result = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path },
+ });
+ try {
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("Cleanup failure cannot return success");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "cleanup_incomplete",
+ });
+ if (failCapinfos)
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ details: {
+ diagnostics: { primary_error: { code: "unsupported_target" } },
+ },
+ });
+ else
+ expect(result.error.partialObservation).toMatchObject({
+ operation: "inspect_pcap_capture",
+ normalized_result: { packet_count: 3 },
+ });
+ expect(retained).toBeDefined();
+ await expect(provider.close()).rejects.toMatchObject({
+ cleanupIncomplete: true,
+ });
+ } finally {
+ spy.mockRestore();
+ }
+ await provider.close();
+ await expect(
+ readFile(join(retained!.path, "capture.pcap")),
+ ).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts
new file mode 100644
index 000000000..3d7f28b54
--- /dev/null
+++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Cross-platform pcap provider tests.
+ *
+ * POSIX hosts run the #!/bin/sh fixtures;
+ * Windows runs the .cmd stubs from tests/fixtures/pcap/. Both validate
+ * the same provider contract surface: tool probing, capture inspection,
+ * refusal paths, and cancellation before spawn.
+ */
+import { createHash } from "node:crypto";
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it, onTestFinished } from "vitest";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+import { PcapProvider } from "../../../src/pcap/PcapProvider.js";
+import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
+import type { PcapRequest } from "../../../src/domain/pcap/pcapAnalysis.js";
+
+const isWindows = process.platform === "win32";
+const hasNativeBound = process.platform === "linux" || isWindows;
+const stubDir = join(import.meta.dirname, "../../fixtures/pcap");
+const tsharkStub = isWindows
+ ? join(stubDir, "tshark-stub.cmd")
+ : join(stubDir, "tshark-stub.sh");
+const capinfosStub = isWindows
+ ? join(stubDir, "capinfos-stub.cmd")
+ : join(stubDir, "capinfos-stub.sh");
+
+const providerOver = (): PcapProvider => {
+ const provider = new PcapProvider({
+ environment: {
+ REA_TSHARK_COMMAND: tsharkStub,
+ REA_CAPINFOS_COMMAND: capinfosStub,
+ },
+ });
+ onTestFinished(() => provider.close());
+ return provider;
+};
+
+const evidenceResult = async (
+ provider: PcapProvider,
+ request: PcapRequest,
+): Promise> => {
+ const result = await provider.execute(request);
+ if (!result.ok) throw new Error(result.error.message);
+ return result.value.result as Record;
+};
+
+it("inspects both tools through cross-platform stubs", async () => {
+ const result = await evidenceResult(providerOver(), {
+ operation: "inspect_pcap_client",
+ input: {},
+ });
+ expect(result.client).toMatchObject({
+ tshark_source: "environment",
+ tshark_version: expect.stringContaining("4.6.6") as unknown,
+ capinfos_source: "environment",
+ capinfos_version: expect.stringContaining("4.6.6") as unknown,
+ });
+});
+
+it.runIf(hasNativeBound)(
+ "inspects a capture with metadata, endpoints, DNS, and protocol facts",
+ async () => {
+ const root = await createTestTempDirectory("rea-pcap-x-");
+ const capturePath = join(root, "capture.pcap");
+ const captureBytes = "cross-platform-pcap-fixture";
+ await writeFile(capturePath, captureBytes);
+
+ const result = await evidenceResult(providerOver(), {
+ operation: "inspect_pcap_capture",
+ input: { path: capturePath },
+ });
+
+ expect(result.target).toMatchObject({
+ path: capturePath,
+ bytes: captureBytes.length,
+ sha256: createHash("sha256").update(captureBytes).digest("hex"),
+ });
+ expect(result.file_type).toContain("pcap");
+ expect(result.packet_count).toBe(3);
+ expect((result.endpoints as unknown[]).length).toBeGreaterThan(0);
+ expect((result.protocol_distribution as unknown[]).length).toBeGreaterThan(
+ 0,
+ );
+ expect(result.coverage).toBe("complete");
+ },
+);
+
+it("rejects a path that is not a readable file", async () => {
+ const result = await providerOver().execute({
+ operation: "inspect_pcap_capture",
+ input: { path: "/nonexistent/definitely-missing.pcap" },
+ });
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "invalid_request",
+ });
+});
+
+it("reports a missing binary as a capability gap", async () => {
+ const provider = new PcapProvider({
+ environment: { REA_TSHARK_COMMAND: "/nonexistent/tshark" },
+ });
+ expect(await provider.inspectAvailability()).toMatchObject({
+ status: "unavailable",
+ code: "executable_missing",
+ });
+});
+
+it("cancels before spawning anything", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const result = await providerOver().execute(
+ { operation: "inspect_pcap_capture", input: { path: "/any/capture.pcap" } },
+ { signal: controller.signal },
+ );
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "cancelled",
+ });
+});
+
+it.runIf(!hasNativeBound)(
+ "reports an unsupported native memory boundary before reading the capture",
+ async () => {
+ const provider = providerOver();
+ expect(await provider.inspectAvailability()).toMatchObject({
+ status: "unavailable",
+ code: "unsupported_host",
+ });
+ const result = await provider.execute({
+ operation: "inspect_pcap_capture",
+ input: { path: "/not-opened/capture.pcap" },
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok)
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "capability_unavailable",
+ });
+ },
+);
diff --git a/tests/boundary/pcap/pcapRetainedView.test.ts b/tests/boundary/pcap/pcapRetainedView.test.ts
new file mode 100644
index 000000000..a3f890a37
--- /dev/null
+++ b/tests/boundary/pcap/pcapRetainedView.test.ts
@@ -0,0 +1,196 @@
+import { createHash } from "node:crypto";
+import { readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { expect, it } from "vitest";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
+
+const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
+
+const createPcapToolFixture = async (root: string) => {
+ const path = join(root, "caller capture.pcap");
+ const bytes = Buffer.from("recording fixture bytes");
+ const sha = createHash("sha256").update(bytes).digest("hex");
+ await writeFile(path, bytes);
+ const tshark = join(root, "tshark");
+ const capinfos = join(root, "capinfos");
+ const marker = join(root, "reads");
+ const frames = Array.from({ length: 132 }, (_, index) => ({
+ _source: {
+ layers: {
+ "frame.number": [String(index + 1)],
+ "frame.protocols": ["ip:udp:dns"],
+ "ip.src": ["192.0.2.1"],
+ "ip.dst": ["192.0.2.2"],
+ "dns.qry.name": ["many.example"],
+ "dns.flags.response": ["True"],
+ "dns.a": [`198.51.100.${index}`],
+ "tls.handshake.extensions_server_name": [`host${index}.example`],
+ "http.request.full_uri": [
+ "http://user:password@example.invalid/p?q=keep",
+ ],
+ },
+ },
+ }));
+ await writeFile(
+ tshark,
+ `#!/bin/sh\nif [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-emulated\\n'; exit 0; fi\nprintf 'read\\n' >> ${quote(marker)}\nprintf '%s' ${quote(JSON.stringify(frames))}\nprintf 'retained warning' >&2\n`,
+ { mode: 0o755 },
+ );
+ await writeFile(
+ capinfos,
+ `#!/bin/sh\nif [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-emulated\\n'; exit 0; fi\nprintf '%s\\n' ${quote(`File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: 132\nSHA256: ${sha}`)}\n`,
+ { mode: 0o755 },
+ );
+ const environment = {
+ REA_TSHARK_COMMAND: tshark,
+ REA_CAPINFOS_COMMAND: capinfos,
+ };
+ return { path, marker, environment };
+};
+
+// Native tools are emulated here; this proves the production CLI/MCP boundary,
+// retention and paging, not Wireshark's real dissector semantics.
+it.runIf(process.platform === "linux")(
+ "retains every fact and pages late DNS/SNI rows after source deletion without another tool launch",
+ async () => {
+ const root = await createTestTempDirectory("rea-pcap-view-");
+ const { path, marker, environment } = await createPcapToolFixture(root);
+ const complete = await promisify(execFile)(
+ process.execPath,
+ [
+ join(import.meta.dirname, "../../../scripts/rea.mjs"),
+ "inspect-pcap-capture",
+ path,
+ "--json",
+ ],
+ { env: { ...process.env, ...environment } },
+ );
+ const parent = parseEvidence(JSON.parse(complete.stdout));
+ expect(parent.normalized_result).toMatchObject({
+ tls_sni_count: 132,
+ tls_sni_values: expect.arrayContaining(["host131.example"]),
+ dns_queries: [{ query: "many.example", response_address_count: 132 }],
+ });
+ expect(JSON.stringify(parent)).not.toContain("user:password");
+ const { call } = await connectLocalToolsMcp(environment);
+ const response = await call("inspect_pcap_capture", {
+ path,
+ detail: "summary",
+ });
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ const summary = parseEvidence(response.structuredContent).normalized_result;
+ expect(summary).toMatchObject({
+ parent_evidence_id: parent.evidence_id,
+ summary: {
+ tls_sni_count: 132,
+ dns_query_count: 1,
+ retained_counts: { tls_sni_values: 132 },
+ diagnostics_code_units: { tshark: 16, capinfos: 0 },
+ },
+ });
+ expect(JSON.stringify(summary)).not.toContain("host131.example");
+ await rm(path);
+ const source = {
+ kind: "retained-evidence",
+ evidence_id: parent.evidence_id,
+ };
+ const before = await readFile(marker, "utf8");
+ const cases = [
+ [
+ { kind: "pcap", facet: "endpoints", limit: 1 },
+ { items: [{ address: "192.0.2.1", packet_count: 132 }] },
+ ],
+ [
+ { kind: "pcap", facet: "dns-queries", limit: 1 },
+ {
+ items: [
+ { query: "many.example", retained_response_address_count: 132 },
+ ],
+ },
+ ],
+ [
+ {
+ kind: "pcap",
+ facet: "dns-addresses",
+ query: "many.example",
+ offset: 130,
+ limit: 2,
+ },
+ { items: ["198.51.100.130", "198.51.100.131"] },
+ ],
+ [
+ { kind: "pcap", facet: "tls-sni", offset: 130, limit: 2 },
+ { items: ["host130.example", "host131.example"] },
+ ],
+ [
+ { kind: "pcap", facet: "protocols" },
+ { items: [{ protocol: "ip:udp:dns", packet_count: 132 }] },
+ ],
+ [
+ { kind: "pcap", facet: "http-uris" },
+ { items: ["http://example.invalid/p?q=keep"] },
+ ],
+ [{ kind: "pcap", facet: "unattributed-addresses" }, { items: [] }],
+ [
+ {
+ kind: "pcap",
+ facet: "diagnostics",
+ stream: "tshark",
+ offset: 9,
+ limit: 7,
+ },
+ { text: "warning", status: "retained" },
+ ],
+ ] as const;
+ for (const [view, expected] of cases) {
+ const response = await call("inspect_analysis_view", { source, view });
+ expect(response.isError, JSON.stringify(response)).not.toBe(true);
+ expect(
+ parseEvidence(response.structuredContent).normalized_result,
+ ).toMatchObject({ page: expected, source_coverage: "complete" });
+ }
+ const workflow = join(root, "portable.json");
+ const view = {
+ kind: "pcap",
+ facet: "dns-addresses",
+ query: "many.example",
+ offset: 130,
+ limit: 2,
+ };
+ await writeFile(
+ workflow,
+ JSON.stringify({ source: { kind: "inline", evidence: parent }, view }),
+ );
+ const portable = await promisify(execFile)(process.execPath, [
+ join(import.meta.dirname, "../../../scripts/rea.mjs"),
+ "inspect-analysis-view",
+ workflow,
+ "--json",
+ ]);
+ const retained = parseEvidence(
+ (await call("inspect_analysis_view", { source, view })).structuredContent,
+ );
+ expect(
+ parseEvidence(JSON.parse(portable.stdout)).normalized_result,
+ ).toEqual(retained.normalized_result);
+ expect(await readFile(marker, "utf8")).toBe(before);
+ expect(
+ parseMcpToolError(
+ await call("inspect_analysis_view", {
+ source,
+ view: {
+ kind: "pcap",
+ facet: "dns-addresses",
+ query: "missing.example",
+ },
+ }),
+ ),
+ ).toMatchObject({ error: { code: "invalid_request" } });
+ },
+ 60_000,
+);
diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts
index bfdb047f2..9f377440c 100644
--- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts
+++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts
@@ -26,6 +26,7 @@ import {
import {
createDarwinProcessRunTokenReader,
DarwinProcessOwnershipInspectionError,
+ SWIFTC_INTERRUPT,
} from "../../../src/process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
import {
@@ -1110,16 +1111,8 @@ it.skipIf(!onDarwin)(
try {
await execFileOutput(
"/usr/bin/xcrun",
- [
- "swiftc",
- "-module-cache-path",
- join(root, "modules"),
- core,
- fixture,
- "-o",
- executable,
- ],
- { timeout: 60_000 },
+ ["swiftc", core, fixture, "-o", executable],
+ { timeout: 120_000, stopSignal: SWIFTC_INTERRUPT },
);
const { stdout } = await execFileAsync(executable, [], {
encoding: "utf8",
@@ -1179,6 +1172,7 @@ it.skipIf(!onDarwin)(
await rm(root, { recursive: true, force: true });
}
},
+ 150_000,
);
it.skipIf(!onDarwin)(
@@ -1203,16 +1197,8 @@ it.skipIf(!onDarwin)(
try {
await execFileOutput(
"/usr/bin/xcrun",
- [
- "swiftc",
- "-module-cache-path",
- join(root, "modules"),
- core,
- fixture,
- "-o",
- executable,
- ],
- { timeout: 60_000 },
+ ["swiftc", core, fixture, "-o", executable],
+ { timeout: 120_000, stopSignal: SWIFTC_INTERRUPT },
);
const { stdout } = await execFileAsync(executable, [], {
encoding: "utf8",
@@ -1226,6 +1212,7 @@ it.skipIf(!onDarwin)(
await rm(root, { recursive: true, force: true });
}
},
+ 150_000,
);
it.skipIf(!onDarwin)(
diff --git a/tests/fixtures/localToolsMcp.ts b/tests/fixtures/localToolsMcp.ts
index 1e1e91909..bf9c7617a 100644
--- a/tests/fixtures/localToolsMcp.ts
+++ b/tests/fixtures/localToolsMcp.ts
@@ -34,7 +34,9 @@ const compileValidator = (schema: unknown): ValidateFunction => {
};
/** Start the built production server without configuring deep binary providers. */
-export async function connectLocalToolsMcp() {
+export async function connectLocalToolsMcp(
+ environment: Readonly> = {},
+) {
const transport = new StdioClientTransport({
command: process.execPath,
args: [
@@ -45,6 +47,7 @@ export async function connectLocalToolsMcp() {
PATH: process.env.PATH ?? "/usr/bin:/bin",
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
+ ...environment,
},
stderr: "pipe",
});
diff --git a/tests/fixtures/pcap/capinfos-stub.cmd b/tests/fixtures/pcap/capinfos-stub.cmd
new file mode 100644
index 000000000..6f783bcf5
--- /dev/null
+++ b/tests/fixtures/pcap/capinfos-stub.cmd
@@ -0,0 +1,19 @@
+@echo off
+rem capinfos stub for Windows: emulates capinfos --version and metadata output.
+
+if "%~1"=="--version" (
+ echo Capinfos ^(Wireshark^) 4.6.6-windows-stub
+ exit /b 0
+)
+
+if not "%~1"=="-M" exit /b 2
+if not "%~2"=="--" exit /b 2
+if "%~3"=="" exit /b 2
+
+echo File name: capture.pcap
+echo File type: Wireshark/tcpdump/... - pcap
+echo File encapsulation: Ethernet
+echo Packet size limit: file hdr: 262144 bytes
+echo Number of packets: 3
+echo Capture duration: 0.123 seconds
+exit /b 0
diff --git a/tests/fixtures/pcap/capinfos-stub.sh b/tests/fixtures/pcap/capinfos-stub.sh
new file mode 100755
index 000000000..47d61b1b1
--- /dev/null
+++ b/tests/fixtures/pcap/capinfos-stub.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+# POSIX capinfos stub (mirrors capinfos-stub.cmd for Windows parity).
+if [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-posix-stub\n'; exit 0; fi
+[ "$1" = "-M" ] && [ "$2" = "--" ] && [ "$#" = "3" ] || exit 2
+printf '%s\n' 'File name: capture.pcap'
+printf '%s\n' 'File type: Wireshark/tcpdump/... - pcap'
+printf '%s\n' 'File encapsulation: Ethernet'
+printf '%s\n' 'Packet size limit: file hdr: 262144 bytes'
+printf '%s\n' 'Number of packets: 3'
+printf '%s\n' 'Capture duration: 0.123 seconds'
+exit 0
diff --git a/tests/fixtures/pcap/tshark-stub.cmd b/tests/fixtures/pcap/tshark-stub.cmd
new file mode 100644
index 000000000..d157ed721
--- /dev/null
+++ b/tests/fixtures/pcap/tshark-stub.cmd
@@ -0,0 +1,17 @@
+@echo off
+rem Pcap tool stub for Windows: emulates tshark --version and field output.
+rem %1 is the first argument; the capture path arrives as %2 for -r/-c.
+
+if "%~1"=="--version" (
+ echo TShark ^(Wireshark^) 4.6.6-windows-stub
+ exit /b 0
+)
+
+if not "%~1"=="-n" exit /b 2
+if not "%~2"=="-r" exit /b 2
+if not "%~4"=="-T" exit /b 2
+if not "%~5"=="json" exit /b 2
+
+rem Emit the same JSON as the POSIX stub
+echo [{"_source":{"layers":{"frame.number":["1"],"frame.protocols":["eth:ethertype:ip:udp:dns"],"ip.src":["192.168.1.100"],"ip.dst":["8.8.8.8"],"dns.qry.name":["api.example.com"],"dns.flags.response":["1"],"dns.a":["93.184.216.34"]}}},{"_source":{"layers":{"frame.number":["2"],"frame.protocols":["eth:ethertype:ip:tcp:tls"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"tls.handshake.extensions_server_name":["api.example.com"]}}},{"_source":{"layers":{"frame.number":["3"],"frame.protocols":["eth:ethertype:ip:tcp:http"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"http.request.full_uri":["http://api.example.com/v1/data"]}}}]
+exit /b 0
diff --git a/tests/fixtures/pcap/tshark-stub.sh b/tests/fixtures/pcap/tshark-stub.sh
new file mode 100755
index 000000000..8633dd7c0
--- /dev/null
+++ b/tests/fixtures/pcap/tshark-stub.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+# POSIX pcap tool stub (mirrors tshark-stub.cmd for Windows parity).
+if [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-posix-stub\n'; exit 0; fi
+[ "$1" = "-n" ] && [ "$2" = "-r" ] && [ "$4" = "-T" ] && [ "$5" = "json" ] || exit 2
+printf '%s' '[{"_source":{"layers":{"frame.number":["1"],"frame.protocols":["eth:ethertype:ip:udp:dns"],"ip.src":["192.168.1.100"],"ip.dst":["8.8.8.8"],"dns.qry.name":["api.example.com"],"dns.flags.response":["1"],"dns.a":["93.184.216.34"]}}},{"_source":{"layers":{"frame.number":["2"],"frame.protocols":["eth:ethertype:ip:tcp:tls"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"tls.handshake.extensions_server_name":["api.example.com"]}}},{"_source":{"layers":{"frame.number":["3"],"frame.protocols":["eth:ethertype:ip:tcp:http"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"http.request.full_uri":["http://api.example.com/v1/data"]}}}]'
+exit 0
diff --git a/vitest.config.ts b/vitest.config.ts
index 74dbf5305..bfef8e139 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -51,7 +51,7 @@ const TEST_PROJECTS = [
name: "adapters",
include: [
"src/*.test.ts",
- "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
+ "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,
From a73a55f53cc2b2e5b7618effe6ecb5cee6e0cee2 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:54:22 +0800
Subject: [PATCH 016/159] fix(artifacts): preserve decode coverage and cleanup
ownership
---
src/firmware/FirmwareFiles.ts | 22 ++--
src/native/AppleDispatchCoverage.ts | 73 ++++++++++--
src/native/AppleDispatchMetadata.test.ts | 142 ++++++++++++++++++++++-
src/native/AppleMachoFixups.test.ts | 67 ++++++++++-
src/native/AppleMachoFixups.ts | 39 +++++--
src/native/AppleObjcCategories.ts | 4 +-
src/native/AppleObjcDispatchFacets.ts | 1 -
src/native/AppleSwiftVtables.test.ts | 2 +
8 files changed, 305 insertions(+), 45 deletions(-)
diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts
index 10201905e..d20226e76 100644
--- a/src/firmware/FirmwareFiles.ts
+++ b/src/firmware/FirmwareFiles.ts
@@ -213,19 +213,15 @@ export const inventoryFirmwareOutput = async (
});
if (handle === undefined) continue;
const names: string[] = [];
- try {
- for await (const entry of handle) {
- throwIfFirmwareInventoryCancelled(signal, operation);
- count++;
- if (count > maxEntries)
- throw new AnalysisOutputError(
- operation,
- `Staging entry budget exceeded (${maxEntries})`,
- );
- names.push(entry.name);
- }
- } finally {
- await handle.close().catch(() => undefined);
+ for await (const entry of handle) {
+ throwIfFirmwareInventoryCancelled(signal, operation);
+ count++;
+ if (count > maxEntries)
+ throw new AnalysisOutputError(
+ operation,
+ `Staging entry budget exceeded (${maxEntries})`,
+ );
+ names.push(entry.name);
}
for (const name of names.sort()) {
const path = join(directory, name);
diff --git a/src/native/AppleDispatchCoverage.ts b/src/native/AppleDispatchCoverage.ts
index c49e35acc..a6c68ca51 100644
--- a/src/native/AppleDispatchCoverage.ts
+++ b/src/native/AppleDispatchCoverage.ts
@@ -42,14 +42,62 @@ export const pushDispatchCoverage = (input: {
vtables,
swift,
} = input;
+ const classOwnedMethods = result.objc_dispatch_implementations.filter(
+ ({ category }) => category === undefined,
+ );
+ const classRecords = [
+ ...result.objc_classes.flatMap((record) =>
+ record.decode?.status === "decoded"
+ ? []
+ : [
+ `class ${record.name}: ${record.decode?.reason ?? record.decode?.status ?? "decode_status_missing"}`,
+ ],
+ ),
+ ...result.objc_protocol_records.flatMap((record) =>
+ record.decode.status === "decoded"
+ ? []
+ : [
+ `protocol ${record.name}: ${record.decode.reason ?? record.decode.status}`,
+ ],
+ ),
+ ...classOwnedMethods.flatMap((record) =>
+ record.decode.status === "decoded"
+ ? []
+ : [
+ `method ${record.class_name} ${record.method_type} ${record.selector}: ${record.decode.reason ?? record.decode.status}`,
+ ],
+ ),
+ ...result.objc_ivars.flatMap((record) =>
+ record.decode.status === "decoded"
+ ? []
+ : [
+ `ivar ${record.class_name}.${record.name}: ${record.decode.reason ?? record.decode.status}`,
+ ],
+ ),
+ ];
+ const classReasons = [
+ ...failures,
+ ...classRecords,
+ ...(truncated ? ["max_records_reached"] : []),
+ ];
+ const categoryMethods = result.objc_dispatch_implementations.filter(
+ ({ category }) => category !== undefined,
+ );
+ const categoryMethodReasons = categoryMethods.flatMap((record) =>
+ record.decode.status === "decoded"
+ ? []
+ : [
+ `category method ${record.category} ${record.class_name} ${record.method_type} ${record.selector}: ${record.decode.reason ?? record.decode.status}`,
+ ],
+ );
result.coverage.push({
facet: "objc_class_method_ivar_metadata",
- status: failures.length > 0 || truncated ? "partial" : "complete",
- reason:
- [...failures, ...(truncated ? ["max_records_reached"] : [])].join("; ") ||
- null,
+ status: classReasons.length > 0 ? "partial" : "complete",
+ reason: classReasons.join("; ") || null,
examined,
- decoded: result.objc_classes.length,
+ decoded: result.objc_classes.filter(
+ ({ decode }) => decode?.status === "decoded",
+ ).length,
});
result.coverage.push({
facet: "binary_relative_pointers",
@@ -79,13 +127,18 @@ export const pushDispatchCoverage = (input: {
status:
truncated ||
categoryFailures.length > 0 ||
- result.objc_categories.some(({ decode }) => decode.status !== "decoded")
+ result.objc_categories.some(
+ ({ decode }) => decode.status !== "decoded",
+ ) ||
+ categoryMethodReasons.length > 0
? "partial"
: "complete",
reason:
- [...categoryFailures, ...(truncated ? ["max_records_reached"] : [])].join(
- "; ",
- ) || null,
+ [
+ ...categoryFailures,
+ ...categoryMethodReasons,
+ ...(truncated ? ["max_records_reached"] : []),
+ ].join("; ") || null,
examined: categoriesExamined,
decoded: result.objc_categories.filter(
({ decode }) => decode.status === "decoded",
@@ -110,7 +163,7 @@ export const pushDispatchCoverage = (input: {
: "complete",
reason: fixupReason.length > 0 ? fixupReason : null,
examined: fixups.examined,
- decoded: fixups.issues.length > 0 ? 0 : fixups.examined,
+ decoded: fixups.decoded,
});
result.coverage.push({
facet: "swift_generic_resilient_witnesses_overrides_async_coroutines",
diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts
index 6791ba1b0..244a35aeb 100644
--- a/src/native/AppleDispatchMetadata.test.ts
+++ b/src/native/AppleDispatchMetadata.test.ts
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
const base = 0x100000000n;
-const fixture = (relative = false) => {
+const fixture = (
+ relative = false,
+ category?: "partial-method" | "unmapped-pointer",
+) => {
const bytes = Buffer.alloc(4096);
const u32 = (offset: number, value: number) =>
bytes.writeUInt32LE(value, offset);
@@ -11,19 +14,26 @@ const fixture = (relative = false) => {
u32(0, 0xfeedfacf);
u32(4, 0x0100000c);
u32(16, 1);
- u32(20, 152);
+ u32(20, category === undefined ? 152 : 232);
u32(32, 0x19);
- u32(36, 152);
+ u32(36, category === undefined ? 152 : 232);
bytes.write("__DATA", 40);
ptr(56, 0);
bytes.writeBigUInt64LE(4096n, 64);
bytes.writeBigUInt64LE(4096n, 80);
u32(92, 5);
- u32(96, 1);
+ u32(96, category === undefined ? 1 : 2);
bytes.write("__objc_classlist", 104);
bytes.write("__DATA", 120);
ptr(136, 0x180);
bytes.writeBigUInt64LE(8n, 144);
+ if (category !== undefined) {
+ bytes.write("__objc_catlist", 184);
+ bytes.write("__DATA", 200);
+ ptr(216, 0xb00);
+ bytes.writeBigUInt64LE(8n, 224);
+ u32(232, 0xb00);
+ }
u32(152, 0x180);
ptr(0x180, 0x200);
ptr(0x200, 0x280);
@@ -86,10 +96,58 @@ const fixture = (relative = false) => {
bytes.write("FixtureProtocol\0", 0x900);
bytes.write("requiredAction:\0", 0x920);
bytes.write("optionalAction:\0", 0x940);
+ if (category !== undefined) {
+ if (category === "unmapped-pointer") {
+ bytes.writeBigUInt64LE(0xffffffffffffffffn, 0xb00);
+ } else {
+ ptr(0xb00, 0xb40);
+ ptr(0xb40, 0xbc0);
+ ptr(0xb48, 0x200);
+ ptr(0xb50, 0xb80);
+ u32(0xb80, 24);
+ u32(0xb84, 1);
+ ptr(0xb88, 0x460);
+ ptr(0xb90, 0x5c0);
+ bytes.writeBigUInt64LE(0xffffffffffffffffn, 0xb98);
+ bytes.write("FixtureCategory\0", 0xbc0);
+ }
+ }
return bytes;
};
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
-describe("chained Apple dispatch binary metadata", () => {
+describe("Apple dispatch pointer fixup metadata", () => {
+ it("keeps decoded bindings and their coverage when an independent stream fails", () => {
+ const bytes = fixture();
+ bytes.writeUInt32LE(2, 16);
+ bytes.writeUInt32LE(200, 20);
+ bytes.writeUInt32LE(0x80000022, 184);
+ bytes.writeUInt32LE(48, 188);
+ const bindings = Buffer.from([
+ 0x40,
+ ...Buffer.from("_OBJC_CLASS_$_NSObject\0"),
+ 0x70,
+ 0x88,
+ 0x04,
+ 0x90,
+ 0x00,
+ ]);
+ bindings.copy(bytes, 0xc00);
+ bytes[0xc80] = 0xd0;
+ bytes.writeUInt32LE(0xc00, 200);
+ bytes.writeUInt32LE(bindings.length, 204);
+ bytes.writeUInt32LE(0xc80, 208);
+ bytes.writeUInt32LE(1, 212);
+
+ const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
+ expect(result.objc_classes[0]).toMatchObject({
+ name: "Fixture",
+ super_class: "NSObject",
+ });
+ expect(
+ result.coverage.find(({ facet }) => facet === "pointer_fixups"),
+ ).toMatchObject({ status: "partial", examined: 2, decoded: 1 });
+ });
+
it("keeps absolute metadata pointers between declared chained fixups", () => {
const bytes = fixture();
bytes.writeUInt32LE(2, 16);
@@ -120,7 +178,7 @@ describe("chained Apple dispatch binary metadata", () => {
});
expect(
result.coverage.find(({ facet }) => facet === "pointer_fixups"),
- ).toMatchObject({ status: "complete" });
+ ).toMatchObject({ status: "complete", examined: 1, decoded: 1 });
});
});
describe("Apple dispatch binary metadata", () => {
@@ -221,6 +279,9 @@ describe("Apple dispatch binary metadata", () => {
});
},
);
+});
+
+describe("Apple dispatch coverage ownership", () => {
it("keeps unsupported pointers and record truncation explicit", () => {
const bytes = fixture();
bytes.writeBigUInt64LE(0xffffffffffffffffn, 0x498);
@@ -229,6 +290,14 @@ describe("Apple dispatch binary metadata", () => {
implementation_address: null,
decode: { status: "partial" },
});
+ expect(
+ result.coverage.find(
+ ({ facet }) => facet === "objc_class_method_ivar_metadata",
+ ),
+ ).toMatchObject({
+ status: "partial",
+ reason: expect.stringContaining("method Fixture instance performAction:"),
+ });
const truncated = decodeAppleDispatchMetadata(fixture(), 1, provenance);
expect(truncated.coverage[0]?.reason).toContain("max_records_reached");
expect(
@@ -237,6 +306,67 @@ describe("Apple dispatch binary metadata", () => {
),
).toMatchObject({ status: "partial", reason: "max_records_reached" });
});
+ it("keeps partial category methods in category coverage only", () => {
+ const baseline = decodeAppleDispatchMetadata(fixture(), 100, provenance);
+ const result = decodeAppleDispatchMetadata(
+ fixture(false, "partial-method"),
+ 100,
+ provenance,
+ );
+
+ expect(result.objc_dispatch_implementations).toContainEqual(
+ expect.objectContaining({
+ category: "FixtureCategory",
+ decode: {
+ status: "partial",
+ reason: "implementation_pointer_unmapped_or_encoded",
+ },
+ }),
+ );
+ const baselineClassCoverage = baseline.coverage.find(
+ ({ facet }) => facet === "objc_class_method_ivar_metadata",
+ );
+ const classCoverage = result.coverage.find(
+ ({ facet }) => facet === "objc_class_method_ivar_metadata",
+ );
+ expect(baselineClassCoverage).toBeDefined();
+ expect(classCoverage).toEqual(baselineClassCoverage);
+ expect(
+ result.coverage.find(
+ ({ facet }) => facet === "objc_properties_categories",
+ ),
+ ).toMatchObject({
+ status: "partial",
+ reason: expect.stringContaining("category method FixtureCategory"),
+ });
+ });
+ it("keeps malformed category pointers out of class failure coverage", () => {
+ const baseline = decodeAppleDispatchMetadata(fixture(), 100, provenance);
+ const result = decodeAppleDispatchMetadata(
+ fixture(false, "unmapped-pointer"),
+ 100,
+ provenance,
+ );
+ const baselineClassCoverage = baseline.coverage.find(
+ ({ facet }) => facet === "objc_class_method_ivar_metadata",
+ );
+ const classCoverage = result.coverage.find(
+ ({ facet }) => facet === "objc_class_method_ivar_metadata",
+ );
+ expect(baselineClassCoverage).toBeDefined();
+ expect(classCoverage).toEqual(baselineClassCoverage);
+ expect(
+ result.coverage.find(
+ ({ facet }) => facet === "objc_properties_categories",
+ ),
+ ).toMatchObject({
+ status: "partial",
+ reason: expect.stringContaining("category_decode_failed"),
+ });
+ });
+});
+
+describe("malformed Apple dispatch metadata", () => {
it("rejects malformed command and section boundaries", () => {
const bytes = fixture();
bytes.writeUInt32LE(7, 36);
diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts
index 40c50c59b..bf4ae348f 100644
--- a/src/native/AppleMachoFixups.test.ts
+++ b/src/native/AppleMachoFixups.test.ts
@@ -149,6 +149,64 @@ const SEGMENTS: Segment[] = [
{ address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false },
];
+describe("independent chained segment coverage", () => {
+ it("counts successfully decoded chained segments alongside an unsupported segment", () => {
+ const starts = 28;
+ const startsSize = 4 + 2 * 4;
+ const firstInfo = starts + startsSize;
+ const secondInfo = firstInfo + 24;
+ const imports = secondInfo + 24;
+ const data = Buffer.alloc(0x3000);
+ data.writeUInt32LE(starts, 4);
+ data.writeUInt32LE(imports, 8);
+ data.writeUInt32LE(imports, 12);
+ data.writeUInt32LE(1, 20);
+ data.writeUInt32LE(2, starts);
+ data.writeUInt32LE(firstInfo - starts, starts + 4);
+ data.writeUInt32LE(secondInfo - starts, starts + 8);
+ for (const [info, format, segmentOffset] of [
+ [firstInfo, 6, 0],
+ [secondInfo, 99, 0x1000],
+ ] as const) {
+ data.writeUInt32LE(24, info);
+ data.writeUInt16LE(0x1000, info + 4);
+ data.writeUInt16LE(format, info + 6);
+ data.writeBigUInt64LE(BigInt(segmentOffset), info + 8);
+ data.writeUInt16LE(1, info + 20);
+ data.writeUInt16LE(8, info + 22);
+ }
+ const segments: Segment[] = [
+ { address: BASE, size: 0x1000n, offset: 0x1000, executable: false },
+ {
+ address: BASE + 0x1000n,
+ size: 0x1000n,
+ offset: 0x2000,
+ executable: false,
+ },
+ ];
+ const fixups = parsePointerFixups(
+ data,
+ {
+ chained: { offset: 0, size: imports },
+ binds: [],
+ rebases: [],
+ dylibs: [],
+ },
+ segments,
+ BASE,
+ );
+
+ expect(fixups).toMatchObject({ examined: 2, decoded: 1 });
+ expect(fixups.issues).toMatchObject([
+ { code: "unsupported_pointer_format" },
+ ]);
+ expect(fixups.decode(BASE + 8n, 0n)).toEqual({
+ kind: "rebase",
+ target: BASE,
+ });
+ });
+});
+
describe("pointer fixup tables", () => {
it("reports unsupported pointer formats even when no pointer is visited", () => {
const data = chainedData(1);
@@ -196,20 +254,27 @@ describe("pointer fixup tables", () => {
{
chained: { offset: 0, size: 128 },
binds: [],
- rebases: [],
+ rebases: [{ offset: 8, size: 12 }],
dylibs: [],
},
SEGMENTS,
BASE,
);
+ expect(fixups).toMatchObject({
+ examined: 1,
+ decoded: 1,
+ unreadRebaseBytes: 12,
+ });
expect(fixups.decode(BASE + 0x4008n, field(1n, 63))).toMatchObject({
kind: "bind",
symbol: "_OBJC_CLASS_$_NSObject",
});
expect(fixups.issues).toEqual([]);
+ expect(fixups.decoded).toBe(1);
expect(fixups.decode(BASE + 0x4008n, 1n | field(1n, 63))).toMatchObject({
kind: "unsupported",
});
+ expect(fixups.decoded).toBe(0);
expect(fixups.issues).toMatchObject([
{ code: "chained_pointer_decode_failed" },
]);
diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts
index 2ec711cc8..09080ec1d 100644
--- a/src/native/AppleMachoFixups.ts
+++ b/src/native/AppleMachoFixups.ts
@@ -42,6 +42,8 @@ export interface PointerFixups {
readonly issues: readonly DecodeIssue[];
/** Fixup segments or bind streams this reader walked. */
readonly examined: number;
+ /** Fully decoded fixup segments or bind streams, in the same units as `examined`. */
+ readonly decoded: number;
/** Rebase opcode bytes present in the load command and not decoded. */
readonly unreadRebaseBytes: number;
/** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */
@@ -260,6 +262,7 @@ const readChainedPage = (
const chainedFixups = (
image: FixupImage,
range: { readonly offset: number; readonly size: number },
+ unreadRebaseBytes: number,
): PointerFixups => {
const { bytes, segments, dylibs, baseAddress } = image;
const base = range.offset;
@@ -400,19 +403,25 @@ const chainedFixups = (
]
: [],
);
- const examinedViews = new Set();
- for (const chain of ranges) {
+ const examinedViews = new Map();
+ const decodedRanges = new Set();
+ for (const [chainIndex, chain] of ranges.entries()) {
// Unknown formats retain their compact starts table; only a queried page
// needs its NONE/presence value. Do not expand unsupported declarations.
if (POINTER_FORMATS[chain.format] === undefined) continue;
- const view = `${chain.pageStarts}:${chain.segment.offset}:${chain.segment.size}:${chain.segment.address}`;
- if (examinedViews.has(view)) continue;
- examinedViews.add(view);
+ const view = `${chain.pageStarts}:${chain.segment.offset}:${chain.segment.size}:${chain.segment.address}:${chain.format}:${chain.pageSize}:${chain.pageCount}`;
+ const prior = examinedViews.get(view);
+ if (prior !== undefined) {
+ if (prior) decodedRanges.add(chainIndex);
+ continue;
+ }
+ let complete = true;
for (let page = 0; page < chain.pageCount; page++) {
const start = bytes.readUInt16LE(chain.pageStarts + page * 2);
if (start === 0xffff) continue;
const facts = pageFacts(chain, page, start);
if (facts.failure !== undefined) {
+ complete = false;
issues.push(
issue(
"pointer_fixups",
@@ -427,6 +436,8 @@ const chainedFixups = (
break;
}
}
+ examinedViews.set(view, complete);
+ if (complete) decodedRanges.add(chainIndex);
}
const failures = issues.map(({ message }) => message);
return {
@@ -435,13 +446,17 @@ const chainedFixups = (
failures,
issues,
examined: Math.max(ranges.length, 1),
- unreadRebaseBytes: 0,
+ get decoded() {
+ return ranges.length === 0 ? 1 : decodedRanges.size;
+ },
+ unreadRebaseBytes,
decode(address, raw) {
- const chain = ranges.find(
+ const chainIndex = ranges.findIndex(
({ segment }) =>
address >= segment.address &&
address < segment.address + segment.size,
);
+ const chain = ranges[chainIndex];
// Pointers outside fixup segments, such as in __TEXT, are stored plainly.
if (chain === undefined) return { kind: "rebase", target: raw };
const within = Number(address - chain.segment.address);
@@ -486,6 +501,7 @@ const chainedFixups = (
);
failures.push(reason);
}
+ decodedRanges.delete(chainIndex);
return { kind: "unsupported", reason };
}
},
@@ -684,10 +700,7 @@ export const parsePointerFixups = (
0,
);
if (commands.chained !== null) {
- const chained = chainedFixups(image, commands.chained);
- return unreadRebaseBytes === 0
- ? chained
- : { ...chained, unreadRebaseBytes };
+ return chainedFixups(image, commands.chained, unreadRebaseBytes);
}
if (commands.binds.length === 0)
return {
@@ -696,14 +709,17 @@ export const parsePointerFixups = (
failures: [],
issues: [],
examined: 0,
+ decoded: 0,
unreadRebaseBytes,
decode: (_address, raw) => ({ kind: "rebase", target: raw }),
};
const binds: BindRun[] = [];
const failures: string[] = [];
+ let decoded = 0;
for (const stream of commands.binds)
try {
readBindStream(image, stream, binds);
+ decoded++;
} catch (cause: unknown) {
failures.push(
`${stream.stream} bind opcodes: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -717,6 +733,7 @@ export const parsePointerFixups = (
issue("pointer_fixups", "bind_stream_decode_failed", message),
),
examined: commands.binds.length,
+ decoded,
unreadRebaseBytes,
decode(address, raw) {
let match: BindRun | undefined;
diff --git a/src/native/AppleObjcCategories.ts b/src/native/AppleObjcCategories.ts
index bd936e0ce..7beb7fc03 100644
--- a/src/native/AppleObjcCategories.ts
+++ b/src/native/AppleObjcCategories.ts
@@ -23,7 +23,6 @@ export const decodeObjcCategories = (input: {
readonly sections: readonly Section[];
readonly read: ObjcDispatchReaders;
readonly result: ObjcSwiftMetadata;
- readonly failures: string[];
readonly issues: DecodeIssue[];
readonly admit: () => boolean;
/** Decode a method list into implementations attributed to the category. */
@@ -37,7 +36,7 @@ export const decodeObjcCategories = (input: {
/** Name of a class defined in this image, from its class_ro_t. */
readonly localClassName: (address: bigint) => string;
}): number => {
- const { read, result, failures } = input;
+ const { read, result } = input;
let examined = 0;
for (const section of input.sections.filter(
({ name }) => name === "__objc_catlist",
@@ -93,7 +92,6 @@ export const decodeObjcCategories = (input: {
});
} catch (cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause);
- failures.push(`Category at 0x${field.toString(16)}: ${message}`);
input.issues.push(
issue(
"objc_properties_categories",
diff --git a/src/native/AppleObjcDispatchFacets.ts b/src/native/AppleObjcDispatchFacets.ts
index 14f0b42b8..b52545840 100644
--- a/src/native/AppleObjcDispatchFacets.ts
+++ b/src/native/AppleObjcDispatchFacets.ts
@@ -335,7 +335,6 @@ export const decodeObjcDispatchFacets = (input: {
sections,
read,
result,
- failures,
issues: categoryIssues,
admit: () => budget.admit(),
methods,
diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts
index dbb1c12b5..fb1615820 100644
--- a/src/native/AppleSwiftVtables.test.ts
+++ b/src/native/AppleSwiftVtables.test.ts
@@ -67,6 +67,7 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => {
issues: [],
decode: (_address, raw) => ({ kind: "rebase", target: raw }),
examined: 0,
+ decoded: 0,
unreadRebaseBytes: 0,
},
swift: {
@@ -181,6 +182,7 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () =
reason: "Unsupported chained pointer format 99",
}),
examined: 0,
+ decoded: 0,
unreadRebaseBytes: 0,
},
swift: {
From c105391db53e2e2ab8b8624d5f3870c14aae3637 Mon Sep 17 00:00:00 2001
From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:59:22 +0800
Subject: [PATCH 017/159] fix(hopper): stop Linux demo children when the REA
owner exits (#2499)
Bind the owned demo helper to REA's private stdin stream and unwind child
cleanup on EOF during startup or analysis. Verify graceful and SIGKILL owner
exit through real CLI/MCP workflows and preserve unrelated processes.
Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
---
docs/installation.md | 19 ++-
scripts/hopper-demo-x11.py | 56 +++++++-
scripts/lib/real-hopper-owner-exit.mjs | 130 ++++++++++++++++++
scripts/verify-real-hopper.mjs | 9 ++
src/hopper/BridgeLauncher.ts | 3 +
.../providers/hopper/bridgeLauncher.test.ts | 58 ++++++++
.../hopper/linuxPrivateDisplay.test.ts | 20 +++
tests/fixtures/hopperLifetimeLaunch.py | 16 +++
tests/fixtures/hopperOwnerLifetime.py | 110 +++++++++++++++
9 files changed, 412 insertions(+), 9 deletions(-)
create mode 100644 scripts/lib/real-hopper-owner-exit.mjs
create mode 100644 tests/fixtures/hopperLifetimeLaunch.py
create mode 100644 tests/fixtures/hopperOwnerLifetime.py
diff --git a/docs/installation.md b/docs/installation.md
index b9285a7d4..0a8041898 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -317,8 +317,8 @@ The Hopper launcher action used by REA creates a document from an executable;
its supported command-line interface does not attach to an already-open
document. While another live REA session owns the same target and loader
profile, a second session reports the owning run ID instead of opening a
-duplicate document. Closing the owning REA session releases this guard; Hopper
-keeps its document open. A later REA session can therefore open another
+duplicate document. On macOS, closing the owning REA session releases this guard;
+Hopper keeps its document open. A later REA session can therefore open another
document for the same target. REA does not currently identify, focus, or reuse
that existing GUI document, and the supported launcher exposes no attach or
reuse action for it.
@@ -341,8 +341,14 @@ Hopper application per user across cooperating REA processes. A competing
CLI or MCP session receives the owning session ID before Hopper is launched,
for both the same target and a different target. Switch targets through that
MCP session, or close it before starting another session. Closing the owner
-releases the reservation. This does not attach to or claim ownership of
-manually opened Hopper applications.
+releases the reservation and stops REA's owned demo application and private
+display; the headless Linux demo does not retain documents after MCP shutdown.
+Its helper watches a private lifetime pipe, so an owner crash or SIGKILL also
+stops those children, without relying on a reusable PID or a process-name scan.
+A later session recovers the dead owner's stale lease after child cleanup;
+private runtime files can remain when REA itself was killed. This does not attach to
+or claim ownership of manually opened Hopper applications. An application left
+running by an older REA version must still be closed before starting a new session.
Stale lease recovery is serialized. If an interrupted recovery leaves its
reservation directory, the diagnostic identifies that exact path. Stop REA
@@ -435,8 +441,9 @@ For cursor navigation, string decoding, symbol-name limits, and regex matching,
see [Hopper analysis behavior](native-investigation.md#hopper-analysis-behavior).
Closing or switching a target closes its bound Hopper document, shuts down REA's
-bridge and removes its temporary socket directory while preserving the Hopper
-application and unrelated documents. A `cleanup_incomplete`
+bridge and removes its temporary socket directory. The macOS Hopper application
+and unrelated documents remain open; the owned Linux demo application stops.
+A `cleanup_incomplete`
result identifies resources whose cleanup could not be verified.
REA retains unresolved cleanup ownership and any confirmed shutdown phases.
After addressing the reported failure, retry `close_binary` on the same
diff --git a/scripts/hopper-demo-x11.py b/scripts/hopper-demo-x11.py
index 470c9fd28..da2fe884f 100644
--- a/scripts/hopper-demo-x11.py
+++ b/scripts/hopper-demo-x11.py
@@ -10,6 +10,7 @@ import json
import os
from pathlib import Path
import secrets
+import select
import stat
import subprocess
import tempfile
@@ -118,6 +119,7 @@ class XWindowAttributes(ctypes.Structure):
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--probe", action="store_true")
+ parser.add_argument("--owner-stdin", action="store_true")
parser.add_argument("--hopper")
parser.add_argument("--socket")
parser.add_argument(
@@ -133,6 +135,43 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
+class OwnerLifetime:
+ """The launcher's private pipe is a lifetime capability, independent of PIDs."""
+
+ def __init__(self, enabled: bool) -> None:
+ self._poll = select.poll() if enabled else None
+ if self._poll is not None:
+ # Node/libuv can implement piped stdio with a Unix socket pair.
+ mode = os.fstat(0).st_mode
+ if not (stat.S_ISFIFO(mode) or stat.S_ISSOCK(mode)):
+ raise AdapterFailure(
+ INVALID_LAUNCH_COMMAND,
+ "invalid_launch_command",
+ "owner lifetime requires a private stdin pipe",
+ )
+ self._poll.register(0, select.POLLIN | select.POLLHUP | select.POLLERR)
+
+ def alive(self) -> bool:
+ if self._poll is None:
+ return True
+ for _, events in self._poll.poll(0):
+ if events & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
+ return False
+ if events & select.POLLIN and not os.read(0, 4096):
+ return False
+ return True
+
+
+def wait_for_hopper(child: subprocess.Popen[bytes], owner: OwnerLifetime) -> int:
+ while owner.alive():
+ try:
+ return child.wait(timeout=0.1)
+ except subprocess.TimeoutExpired:
+ continue
+ # run_launch's finally owns cleanup of both direct, unreaped children.
+ return 0
+
+
def digest(path: Path) -> str:
value = hashlib.sha256()
with path.open("rb") as file:
@@ -572,6 +611,7 @@ def start_xvfb(
"-auth",
str(authority),
],
+ stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
@@ -627,6 +667,9 @@ def run_launch(args: argparse.Namespace) -> int:
"invalid_launch_command",
"missing Hopper launch coordinates",
)
+ owner = OwnerLifetime(args.owner_stdin)
+ if not owner.alive():
+ return 0
hopper = Path(args.hopper)
socket = Path(args.socket)
try:
@@ -654,6 +697,8 @@ def run_launch(args: argparse.Namespace) -> int:
child: subprocess.Popen[bytes] | None = None
display: int | None = None
try:
+ if not owner.alive():
+ return 0
hopper_environment = dict(os.environ)
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET"):
hopper_environment.pop(name, None)
@@ -666,7 +711,10 @@ def run_launch(args: argparse.Namespace) -> int:
"XDG_SESSION_TYPE": "x11",
}
)
- child = subprocess.Popen(command, env=hopper_environment)
+ # Children must not consume or retain REA's lifetime pipe.
+ child = subprocess.Popen(
+ command, env=hopper_environment, stdin=subprocess.DEVNULL
+ )
leader = os.getpid()
os.environ["XAUTHORITY"] = str(authority)
@@ -702,10 +750,12 @@ def run_launch(args: argparse.Namespace) -> int:
observed: list[tuple[int, int, int, int, int]] = []
dialog: tuple[int, int, int, int, int] | None = None
while time.monotonic() < deadline:
+ if not owner.alive():
+ return 0
if socket.exists():
x11.XCloseDisplay(display)
display = None
- return child.wait()
+ return wait_for_hopper(child, owner)
if not hopper_is_owned(leader, hopper):
if child.poll() is None:
time.sleep(0.1)
@@ -749,7 +799,7 @@ def run_launch(args: argparse.Namespace) -> int:
x11.XFlush(display)
x11.XCloseDisplay(display)
display = None
- return child.wait()
+ return wait_for_hopper(child, owner)
except AdapterFailure as error:
raise error.with_stderr(capture)
finally:
diff --git a/scripts/lib/real-hopper-owner-exit.mjs b/scripts/lib/real-hopper-owner-exit.mjs
new file mode 100644
index 000000000..300b35ada
--- /dev/null
+++ b/scripts/lib/real-hopper-owner-exit.mjs
@@ -0,0 +1,130 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { setTimeout as delay } from "node:timers/promises";
+import { promisify } from "node:util";
+
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+
+import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership.js";
+import {
+ observeProcessStartIdentity,
+ signalProcessWithStartIdentity,
+} from "../../dist/process/ProcessOwnershipObservation.js";
+import { startUnrelatedHopperSentinel } from "./unrelated-hopper-sentinel.mjs";
+import {
+ requireMcpEvidenceResult,
+ requireMcpLifecycleResult,
+} from "./mcp-verifier-results.mjs";
+
+const execFileAsync = promisify(execFile);
+
+/** Prove actual MCP owner exit releases the demo singleton for subsequent CLI/MCP sessions. */
+export async function verifyLinuxHopperOwnerExit({
+ primary,
+ secondary,
+ serverCommand,
+ serverArgs,
+ environment,
+}) {
+ const sentinel = await startUnrelatedHopperSentinel();
+ const outcomes = [];
+ try {
+ for (const mode of ["graceful", "SIGKILL"]) {
+ const transport = new StdioClientTransport({
+ command: serverCommand,
+ args: serverArgs,
+ env: environment,
+ stderr: "pipe",
+ });
+ // Drain launcher diagnostics even on the deliberately interrupted owner.
+ transport.stderr?.resume();
+ const client = new Client({ name: "hopper-owner-exit", version: "1" });
+ const options = { timeout: 180_000 };
+ try {
+ await client.connect(transport);
+ const ownerPid = transport.pid;
+ assert.ok(ownerPid !== null);
+ const owner = await observeProcessStartIdentity(ownerPid);
+ assert.equal(owner?.state, "readable");
+ requireMcpLifecycleResult(
+ await client.callTool(
+ {
+ name: "open_binary",
+ arguments: { path: primary },
+ },
+ options,
+ ),
+ "open_binary",
+ );
+ const procedures = requireMcpEvidenceResult(
+ await client.callTool(
+ { name: "list_procedures", arguments: {} },
+ options,
+ ),
+ "list_procedures",
+ );
+ assert.ok(procedures.length > 0);
+ const status = requireMcpLifecycleResult(
+ await client.callTool(
+ { name: "binary_session", arguments: {} },
+ options,
+ ),
+ "binary_session",
+ );
+ const runId = status.analysis_run?.run_id;
+ assert.equal(typeof runId, "string");
+ assert.equal(status.analysis_provider_binding?.provider.id, "hopper");
+ assert.equal((await verifyNoTokenOwnedProcesses(runId)).cleaned, false);
+ if (mode === "graceful") await client.close();
+ else {
+ assert.equal(
+ await signalProcessWithStartIdentity(
+ ownerPid,
+ owner.identity,
+ "SIGKILL",
+ ),
+ "signaled",
+ );
+ }
+ const deadline = Date.now() + 10_000;
+ let cleanup;
+ do {
+ cleanup = await verifyNoTokenOwnedProcesses(runId);
+ if (cleanup.cleaned) break;
+ await delay(100);
+ } while (Date.now() < deadline);
+ assert.equal(cleanup.cleaned, true, cleanup.reason);
+ // The SIGKILL case must recover the stale lease for both target identities.
+ for (const target of [primary, secondary]) {
+ const result = await execFileAsync(
+ process.execPath,
+ [
+ "scripts/rea.mjs",
+ "inspect",
+ target,
+ "--provider",
+ "hopper",
+ "--format",
+ "json",
+ ],
+ { env: environment, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
+ );
+ assert.ok(JSON.parse(result.stdout).normalized_result);
+ }
+ outcomes.push({
+ mode,
+ ownedProcessesStopped: true,
+ sameTargetReopened: true,
+ differentTargetReopened: true,
+ });
+ } finally {
+ await client.close();
+ await transport.close();
+ }
+ }
+ } finally {
+ assert.equal(await sentinel.verifyAndClose(), true);
+ }
+ return { outcomes, unrelatedHopperSurvived: true };
+}
diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs
index 12400cb94..8cad9a256 100644
--- a/scripts/verify-real-hopper.mjs
+++ b/scripts/verify-real-hopper.mjs
@@ -47,6 +47,7 @@ import {
} from "./lib/real-hopper-boundaries.mjs";
import { startUnrelatedHopperSentinel } from "./lib/unrelated-hopper-sentinel.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
+import { verifyLinuxHopperOwnerExit } from "./lib/real-hopper-owner-exit.mjs";
const execFileAsync = promisify(execFile);
const verifierRun = createVerifierRun();
const timeout = 180_000;
@@ -577,6 +578,14 @@ if (sessionsAfter.length > 0) {
}
if (summary === undefined)
throw new Error("Real-Hopper verification did not produce a summary");
+if (process.platform === "linux")
+ summary.ownerExit = await verifyLinuxHopperOwnerExit({
+ primary: targetA,
+ secondary: targetB,
+ serverCommand,
+ serverArgs,
+ environment: serverEnvironment,
+ });
const completedVerifierRun = await completeVerifierRun(verifierRun);
if (
completedVerifierRun.process_lineage.status !== "verified" ||
diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts
index 8201ee2df..043d394a4 100644
--- a/src/hopper/BridgeLauncher.ts
+++ b/src/hopper/BridgeLauncher.ts
@@ -413,6 +413,8 @@ const launchHopperProcess = async (input: {
runId: input.session.runId,
expectedCommand: ownershipCommand,
hostEnvironment: input.options.environment,
+ // The helper observes EOF when this REA process exits, including SIGKILL.
+ stdin: "pipe",
...(input.signal === undefined ? {} : { signal: input.signal }),
});
return spawn(input.argumentsForTarget);
@@ -434,6 +436,7 @@ export const linuxDemoLaunch = (
return undefined;
const helperArguments = [
options.demoHelperPath,
+ "--owner-stdin",
"--strategy",
strategy ?? "direct",
...(strategy === "user-mount-namespace" ? ["--mount-private-x11"] : []),
diff --git a/tests/boundary/providers/hopper/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts
index 722a25bed..542858a0d 100644
--- a/tests/boundary/providers/hopper/bridgeLauncher.test.ts
+++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts
@@ -84,6 +84,7 @@ describe("Hopper bridge launcher selection", () => {
"private",
"/usr/bin/python3",
"/rea/hopper-demo-x11.py",
+ "--owner-stdin",
"--strategy",
"user-mount-namespace",
"--mount-private-x11",
@@ -117,6 +118,7 @@ describe("Hopper bridge launcher selection", () => {
command: "/usr/bin/python3",
args: [
"/rea/hopper-demo-x11.py",
+ "--owner-stdin",
"--strategy",
"direct",
"--hopper",
@@ -202,3 +204,59 @@ it("launches Hopper helpers with the caller-selected environment without restori
await launched.value.releaseLease?.();
}
});
+
+it.skipIf(process.platform !== "linux")(
+ "gives the demo helper a live owner stream through Node's actual stdio transport",
+ async () => {
+ const directory = await createTestTempDirectory("rea-hopper-owner-stdio-");
+ const launcher = new HopperApplicationLauncher(
+ {
+ ...options(process.execPath),
+ launchMode: "verified_linux_demo",
+ demoHelperPath: fileURLToPath(
+ new URL("../../../fixtures/hopperLifetimeLaunch.py", import.meta.url),
+ ),
+ },
+ {
+ selectPrivateDisplay: async () => ({
+ ok: true,
+ strategy: "direct",
+ diagnostic: {
+ component: "hopper_private_display",
+ operation: "probe",
+ status: "ready",
+ failure_code: null,
+ reason: "ready",
+ socket_directory: "/tmp/.X11-unix",
+ socket_directory_mode: "1777",
+ mount_read_only: false,
+ effective_socket_directory_mode: "1777",
+ effective_mount_read_only: false,
+ wsl: false,
+ strategy: "direct",
+ fallback_reason: null,
+ xvfb_stderr_bytes: 0,
+ },
+ }),
+ },
+ );
+ const launched = await launcher.launch({
+ directory,
+ socketPath: join(directory, "bridge.sock"),
+ token: "fixture-token",
+ runId: randomUUID(),
+ });
+ if (!launched.ok) throw launched.error;
+ const supervisor = new ProviderProcessSupervisor(launched.value);
+ try {
+ expect(await supervisor.waitForOutputClose(5_000)).toBe(true);
+ expect(supervisor.snapshot().exitCode).toBe(0);
+ expect(JSON.parse(supervisor.snapshot().stdout.text)).toEqual({
+ owner_alive: true,
+ });
+ } finally {
+ await supervisor.stop();
+ await launched.value.releaseLease?.();
+ }
+ },
+);
diff --git a/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts
index f2c99980f..c5fd45c96 100644
--- a/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts
+++ b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts
@@ -31,6 +31,9 @@ const dialogDetectionFixturePath = fileURLToPath(
const cleanupTimeoutFixturePath = fileURLToPath(
new URL("../../../fixtures/hopperCleanupTimeout.py", import.meta.url),
);
+const ownerLifetimeFixturePath = fileURLToPath(
+ new URL("../../../fixtures/hopperOwnerLifetime.py", import.meta.url),
+);
type DiagnosticOverrides = Partial<
Omit
@@ -107,6 +110,23 @@ describe("Hopper demo dialog detection", () => {
});
describe("Hopper process cleanup", () => {
+ it.skipIf(process.platform !== "linux").each(["startup", "ready"])(
+ "reaps only its own children when the owner pipe closes during %s",
+ (phase) => {
+ const result = spawnSync(
+ "/usr/bin/python3",
+ [ownerLifetimeFixturePath, helperPath, phase],
+ { encoding: "utf8", timeout: 10_000 },
+ );
+ expect(result.status, result.stderr).toBe(0);
+ expect(JSON.parse(result.stdout)).toEqual({
+ phase,
+ children_cleaned: true,
+ sentinel_alive: true,
+ });
+ },
+ );
+
it.skipIf(process.platform !== "linux")(
"kills a child that ignores SIGTERM after the bounded timeout",
() => {
diff --git a/tests/fixtures/hopperLifetimeLaunch.py b/tests/fixtures/hopperLifetimeLaunch.py
new file mode 100644
index 000000000..68d65c9ff
--- /dev/null
+++ b/tests/fixtures/hopperLifetimeLaunch.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+"""Inspect the real Node launcher stdio with the production lifetime parser."""
+
+import importlib.util
+import json
+from pathlib import Path
+import sys
+
+spec = importlib.util.spec_from_file_location(
+ "hopper_demo_x11", Path(__file__).resolve().parents[2] / "scripts/hopper-demo-x11.py"
+)
+assert spec is not None and spec.loader is not None
+helper = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(helper)
+assert "--owner-stdin" in sys.argv
+print(json.dumps({"owner_alive": helper.OwnerLifetime(True).alive()}))
diff --git a/tests/fixtures/hopperOwnerLifetime.py b/tests/fixtures/hopperOwnerLifetime.py
new file mode 100644
index 000000000..741bffbd9
--- /dev/null
+++ b/tests/fixtures/hopperOwnerLifetime.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Exercise the production runner with real children and a simulated X11 surface."""
+
+import argparse
+import importlib.util
+import json
+from pathlib import Path
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+
+
+spec = importlib.util.spec_from_file_location("hopper_demo_x11", sys.argv[1])
+assert spec is not None and spec.loader is not None
+helper = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(helper)
+
+
+def child_command(receipt):
+ return [
+ sys.executable,
+ "-c",
+ "import os,pathlib,time;"
+ f"receipt=pathlib.Path({str(receipt)!r});"
+ "pending=receipt.with_suffix('.pending');"
+ "pending.write_text(str(os.getpid()));pending.replace(receipt);"
+ "time.sleep(100)",
+ ]
+
+
+if len(sys.argv) > 3:
+ phase, directory = sys.argv[2], Path(sys.argv[3])
+
+ def terminate_worker(*_):
+ raise SystemExit(143)
+
+ # Failed assertions in the parent must still unwind run_launch's finally.
+ signal.signal(signal.SIGTERM, terminate_worker)
+
+ class X11:
+ def XOpenDisplay(self, *_):
+ return 1
+
+ def XDefaultRootWindow(self, *_):
+ return 1
+
+ def XDisplayWidth(self, *_):
+ return 1280
+
+ def XDisplayHeight(self, *_):
+ return 1024
+
+ def XCloseDisplay(self, *_):
+ return 1
+
+ def start_xvfb(*_):
+ child = subprocess.Popen(
+ child_command(directory / "xvfb.pid"),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ )
+ return child, ":123", directory / "Xauthority", helper.CapturedStderr(child.stderr)
+
+ helper.digest = lambda _: next(iter(helper.SUPPORTED_HOPPER_SHA256))
+ helper.start_xvfb = start_xvfb
+ helper.ctypes.CDLL = lambda _: X11()
+ helper.configure_x11 = lambda *_: None
+ helper.windows = lambda *_: []
+ socket = directory / "bridge.sock"
+ if phase == "ready":
+ socket.touch()
+ raise SystemExit(helper.run_launch(argparse.Namespace(
+ owner_stdin=True,
+ hopper=sys.executable,
+ socket=str(socket),
+ command=child_command(directory / "hopper.pid"),
+ )))
+
+
+with tempfile.TemporaryDirectory(prefix="rea-owner-lifetime-") as root:
+ directory = Path(root)
+ sentinel = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(100)"])
+ worker = subprocess.Popen(
+ [sys.executable, __file__, sys.argv[1], sys.argv[2], root],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ try:
+ deadline = time.monotonic() + 5
+ receipts = [directory / "hopper.pid", directory / "xvfb.pid"]
+ while not all(receipt.exists() for receipt in receipts):
+ assert worker.poll() is None, worker.communicate()
+ assert time.monotonic() < deadline, "owned children did not start"
+ time.sleep(0.01)
+ pids = [int(receipt.read_text()) for receipt in receipts]
+ assert worker.poll() is None, "live owner did not retain the helper"
+ assert worker.stdin is not None
+ worker.stdin.close()
+ assert worker.wait(timeout=5) == 0, worker.stderr.read()
+ for pid in pids:
+ assert not Path(f"/proc/{pid}").exists(), f"owned child {pid} survived EOF"
+ assert sentinel.poll() is None, "unrelated process was stopped"
+ print(json.dumps({"phase": sys.argv[2], "children_cleaned": True, "sentinel_alive": True}))
+ finally:
+ helper.stop_process(worker)
+ helper.stop_process(sentinel)
From acf75e6700ace28bb1db25e54e56673ac15e0ed3 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:59:17 +0800
Subject: [PATCH 018/159] fix(evidence): reject unsupported metadata before
authentication
Close persisted Evidence, bundle, profile and unknown record shapes so parsing cannot silently remove caller metadata before checking semantic identity. Keep constructor provider projection explicit and JSON payload fields open.
Exercise actual MCP export/import with independent record and manifest mutations, unchanged state after rejection, and a valid round trip. Prune duplicate source assertions. Validation: focused domain/CLI/MCP/filesystem checks, converted JSON Schemas, docs and typecheck/lint.
---
docs/mcp-contracts.md | 6 +
src/domain/analysisProfile.test.ts | 7 +
src/domain/analysisProfile.ts | 10 +-
src/domain/evidence.test.ts | 24 ++
src/domain/evidence.ts | 21 +-
src/domain/evidenceBundle.ts | 19 +-
src/domain/residualUnknown.ts | 12 +-
.../boundary/mcp/evidenceBundleInline.test.ts | 258 ++++++++++++++++++
8 files changed, 331 insertions(+), 26 deletions(-)
diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md
index b6990b3c9..c4169dd73 100644
--- a/docs/mcp-contracts.md
+++ b/docs/mcp-contracts.md
@@ -21,6 +21,12 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
retained session history or an explicit bundle for transfer.
+Evidence and bundle imports reject unsupported fields in their envelopes,
+identities, manifests and other fixed metadata before authentication. Imports
+leave retained session state unchanged when validation fails. Keep application
+metadata in the supported JSON payloads (`parameters`, `raw_result`,
+`normalized_result`, or analysis-profile parameters), where keys are preserved.
+
Tool execution failures return `isError: true` and the complete canonical
`{ "error": ... }` diagnostic as JSON in text `content`. Read that diagnostic
for the error code, target details, partial observations, and remediation.
diff --git a/src/domain/analysisProfile.test.ts b/src/domain/analysisProfile.test.ts
index 1fb18f0ae..a39fb389d 100644
--- a/src/domain/analysisProfile.test.ts
+++ b/src/domain/analysisProfile.test.ts
@@ -65,4 +65,11 @@ describe("analysis profile commitments", () => {
analysisProfileSchema.parse({ ...profile, digest: "0".repeat(64) }),
).toThrow(/digest/u);
});
+
+ it("projects known provider identity fields at construction", () => {
+ const liveProvider = { ...PROVIDER, transient_status: "starting" };
+ const profile = createAnalysisProfile(liveProvider, { loader: "default" });
+ expect(profile.provider).toEqual(PROVIDER);
+ expect(analysisProfileSchema.parse(profile)).toEqual(profile);
+ });
});
diff --git a/src/domain/analysisProfile.ts b/src/domain/analysisProfile.ts
index 6719e2ccd..f6e6e0004 100644
--- a/src/domain/analysisProfile.ts
+++ b/src/domain/analysisProfile.ts
@@ -7,13 +7,13 @@ import { jsonObjectSchema, type JsonValue } from "./jsonValue.js";
import { digestSchema } from "./../domain/digests.js";
/** Concrete provider identity committed by an analysis profile. */
-export const committedProviderSchema = z.object({
+export const committedProviderSchema = z.strictObject({
id: z.string().min(1),
name: z.string().min(1),
version: z.string().min(1),
});
-const unsignedAnalysisProfileSchema = z.object({
+const unsignedAnalysisProfileSchema = z.strictObject({
provider: committedProviderSchema,
parameters: jsonObjectSchema,
});
@@ -40,7 +40,11 @@ export const createAnalysisProfile = (
parameters: Readonly>,
): AnalysisProfileCommitment => {
const unsigned = unsignedAnalysisProfileSchema.parse({
- provider,
+ provider: {
+ id: provider.id,
+ name: provider.name,
+ version: provider.version,
+ },
parameters,
});
return analysisProfileSchema.parse({
diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts
index c5898aecc..5cdf30a27 100644
--- a/src/domain/evidence.test.ts
+++ b/src/domain/evidence.test.ts
@@ -52,6 +52,30 @@ it("preserves omitted relationship diagnostics and authenticates reported diagno
).toThrow("semantic identifier");
});
+it("projects known provider fields and preserves open JSON payloads", () => {
+ const liveProvider = { ...PROVIDER, transient_status: "starting" };
+ const evidence = createEvidence(TARGET, liveProvider, {
+ operation: "inspect",
+ parameters: { caller_metadata: { note: "kept" } },
+ result: { producer_extension: { value: "kept" } },
+ analysisProfile: PROFILE,
+ environment: {
+ id: "fixture",
+ platform: "linux",
+ architecture: "x86_64",
+ isolation: "process",
+ },
+ locations: [{ kind: "address", address: "0x1000" }],
+ });
+
+ expect(evidence.provider).toEqual(PROVIDER);
+ expect(evidence.parameters).toEqual({ caller_metadata: { note: "kept" } });
+ expect(evidence.normalized_result).toEqual({
+ producer_extension: { value: "kept" },
+ });
+ expect(parseEvidence(evidence)).toEqual(evidence);
+});
+
it("preserves identity and bytes when reusing an owned immutable result", () => {
const value = { nested: { values: ["observed", 1] } };
const mutable = createEvidence(TARGET, PROVIDER, {
diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts
index 8e21686e5..8a450f478 100644
--- a/src/domain/evidence.ts
+++ b/src/domain/evidence.ts
@@ -33,14 +33,14 @@ import {
} from "./relationshipCoverage.js";
/** Provider identity schema shared by evidence-bearing persistence formats. */
-export const providerSchema = z.object({
+export const providerSchema = z.strictObject({
id: z.string().min(1),
name: z.string().min(1),
version: z.string().nullable(),
});
-const subjectSchema = z.object({
+const subjectSchema = z.strictObject({
name: z.string().min(1),
- digest: z.object({ sha256: digestSchema }),
+ digest: z.strictObject({ sha256: digestSchema }),
format: z.enum([
"hopper",
"analysis-database",
@@ -77,15 +77,18 @@ const subjectSchema = z.object({
/** Source location attached to an evidence observation. */
/** Source-location schema shared by evidence-bearing persistence formats. */
export const evidenceLocationSchema = z.discriminatedUnion("kind", [
- z.object({ kind: z.literal("address"), address: z.string().min(1) }),
- z.object({
+ z.strictObject({ kind: z.literal("address"), address: z.string().min(1) }),
+ z.strictObject({
kind: z.literal("address-range"),
start: z.string().min(1),
end: z.string().min(1),
}),
- z.object({ kind: z.literal("artifact-path"), path: z.string().min(1) }),
- z.object({ kind: z.literal("file-offset"), offset: z.number().int().min(0) }),
- z.object({
+ z.strictObject({ kind: z.literal("artifact-path"), path: z.string().min(1) }),
+ z.strictObject({
+ kind: z.literal("file-offset"),
+ offset: z.number().int().min(0),
+ }),
+ z.strictObject({
kind: z.literal("file-offset-range"),
start: z.number().int().min(0),
end: z.number().int().min(0),
@@ -100,7 +103,7 @@ const evidenceAuthoritySchema = z.enum([
"analyst-inference",
]);
-const executionEnvironmentSchema = z.object({
+const executionEnvironmentSchema = z.strictObject({
id: z.string().min(1),
platform: z.string().min(1),
architecture: z.string().min(1),
diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts
index c1fa3ce1c..7e6fd2b3b 100644
--- a/src/domain/evidenceBundle.ts
+++ b/src/domain/evidenceBundle.ts
@@ -16,24 +16,27 @@ import {
} from "./residualUnknown.js";
import { freezeJsonSnapshot } from "./immutableJson.js";
-const artifactManifestSchema = evidenceSchema.shape.subject.unwrap().pick({
- digest: true,
- format: true,
- architecture: true,
-});
+const artifactManifestSchema = evidenceSchema.shape.subject
+ .unwrap()
+ .pick({
+ digest: true,
+ format: true,
+ architecture: true,
+ })
+ .strict();
const providerManifestSchema = providerSchema;
const environmentManifestSchema = evidenceSchema.shape.environment.unwrap();
-const scenarioManifestSchema = z.object({
+const scenarioManifestSchema = z.strictObject({
evidence_id: evidenceSchema.shape.evidence_id,
operation: z.string().min(1),
authority: z.literal("controlled-replay"),
});
-const captureManifestSchema = z.object({
+const captureManifestSchema = z.strictObject({
evidence_id: evidenceSchema.shape.evidence_id,
predicate_type: z.string().min(1),
});
-export const evidenceBundleSchema = z.object({
+export const evidenceBundleSchema = z.strictObject({
artifacts: z.array(artifactManifestSchema),
providers: z.array(providerManifestSchema),
environments: z.array(environmentManifestSchema),
diff --git a/src/domain/residualUnknown.ts b/src/domain/residualUnknown.ts
index 0819a3964..e5db1cc54 100644
--- a/src/domain/residualUnknown.ts
+++ b/src/domain/residualUnknown.ts
@@ -12,7 +12,7 @@ import { functionComparisonDimensionNameSchema } from "./functionComparisonSchem
const evidenceIdSchema = prefixedDigestSchema("ev");
const unknownIdSchema = prefixedDigestSchema("unk");
const text = z.string().trim().min(1);
-const environmentRequirementSchema = z.object({
+const environmentRequirementSchema = z.strictObject({
id: z.string().min(1).nullable(),
platform: z.string().min(1).nullable(),
architecture: z.string().min(1).nullable(),
@@ -39,7 +39,7 @@ const stableUnknownId = (
)
.digest("hex")}`;
-const probeSchema = z.object({
+const probeSchema = z.strictObject({
operation: z.string().min(1),
rationale: text,
});
@@ -60,17 +60,17 @@ const comparisonConclusionScopeSchema = z
message: "Comparison dimensions must be sorted and unique",
});
});
-const relationshipSchema = z.object({
+const relationshipSchema = z.strictObject({
type: z.enum(["depends-on", "related-to", "contradicts"]),
unknown_id: unknownIdSchema,
});
const resolutionSchema = z.discriminatedUnion("disposition", [
- z.object({
+ z.strictObject({
disposition: z.literal("verified"),
rationale: text,
evidence_ids: z.array(evidenceIdSchema).min(1),
}),
- z.object({
+ z.strictObject({
disposition: z.enum(["withdrawn", "out-of-scope"]),
rationale: text,
evidence_ids: z.array(evidenceIdSchema),
@@ -78,7 +78,7 @@ const resolutionSchema = z.discriminatedUnion("disposition", [
]);
/** Strict durable record for one unresolved or evidence-qualified question. */
-const residualUnknownObjectSchema = z.object({
+const residualUnknownObjectSchema = z.strictObject({
unknown_id: unknownIdSchema,
revision: z.number().int().min(1),
previous_revision_digest: z
diff --git a/tests/boundary/mcp/evidenceBundleInline.test.ts b/tests/boundary/mcp/evidenceBundleInline.test.ts
index adb95ef8f..ae07a1be5 100644
--- a/tests/boundary/mcp/evidenceBundleInline.test.ts
+++ b/tests/boundary/mcp/evidenceBundleInline.test.ts
@@ -1,10 +1,20 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
import { ok as resultOk } from "../../../src/domain/result.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { observed } from "../../fixtures/analysisExecution.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
+import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createEvidence } from "../../../src/domain/evidence.js";
+import { ANALYSIS_SNAPSHOT_TARGET } from "../../../src/domain/analysisSnapshot.fixture.js";
+import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
+import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
+import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
import { createServer } from "../../../src/server/createServer.js";
it("returns the complete session Evidence bundle in the tool result", async () => {
@@ -44,3 +54,251 @@ it("returns the complete session Evidence bundle in the tool result", async () =
await Promise.allSettled([client.close(), server.close(), session.close()]);
}
});
+
+it("rejects bundle field stripping at MCP import without changing retained evidence", async () => {
+ const root = await createTestTempDirectory("rea-evidence-bundle-mcp-");
+ const sourceSession = createBundleSourceSession();
+ const targetSession = createTestBinarySession(() => ({
+ execute: () => Promise.resolve(observed(null)),
+ close: () => Promise.resolve(resultOk(null)),
+ }));
+ const source = await connectEvidenceMcp("bundle-source", sourceSession);
+ const target = await connectEvidenceMcp("bundle-target", targetSession);
+ const { client: sourceClient } = source;
+ const { client: targetClient } = target;
+ const bundlePath = join(root, "exported.json");
+ const targetExportPath = join(root, "round-trip.json");
+
+ try {
+ const { sourceJson, records, evidenceIndex } = await exportFixtureBundle(
+ sourceClient,
+ bundlePath,
+ );
+ const initial = structured(
+ await targetClient.callTool({
+ name: "get_evidence_bundle",
+ arguments: {},
+ }),
+ ).result;
+ for (const [label, path] of unsupportedBundleFields(evidenceIndex)) {
+ const candidate = structuredClone(sourceJson);
+ setPath(candidate, path, "unsupported-metadata");
+ const malformedPath = join(root, `${label.replaceAll(" ", "-")}.json`);
+ await writeFile(malformedPath, JSON.stringify(candidate));
+ const rejected = await targetClient.callTool({
+ name: "import_evidence_bundle",
+ arguments: { path: malformedPath },
+ });
+ expect(parseMcpToolError(rejected), label).toMatchObject({
+ error: { code: "evidence_integrity_mismatch" },
+ });
+ expect(
+ structured(
+ await targetClient.callTool({
+ name: "get_evidence_bundle",
+ arguments: {},
+ }),
+ ).result,
+ label,
+ ).toEqual(initial);
+ }
+
+ const imported = await targetClient.callTool({
+ name: "import_evidence_bundle",
+ arguments: { path: bundlePath },
+ });
+ if (imported.isError === true)
+ throw new Error(JSON.stringify(parseMcpToolError(imported)));
+ await targetClient.callTool({
+ name: "export_evidence_bundle",
+ arguments: { path: targetExportPath },
+ });
+ expect(JSON.parse(await readFile(targetExportPath, "utf8"))).toEqual(
+ sourceJson,
+ );
+ const observedRecord = records[evidenceIndex]!;
+ expect(observedRecord.parameters).toEqual({ caller: { note: "preserve" } });
+ expect(observedRecord.normalized_result).toEqual({
+ producer: { extension: "preserve" },
+ });
+ expect(object(observedRecord.analysis_profile).parameters).toEqual({
+ loader: { mode: "full" },
+ });
+ } finally {
+ await Promise.allSettled([
+ sourceClient.close(),
+ source.server.close(),
+ sourceSession.close(),
+ targetClient.close(),
+ target.server.close(),
+ targetSession.close(),
+ ]);
+ }
+});
+
+const createBundleSourceSession = () => {
+ const provider = { id: "fixture", name: "Fixture", version: "1" };
+ const evidence = createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, {
+ operation: "observe",
+ parameters: { caller: { note: "preserve" } },
+ result: { producer: { extension: "preserve" } },
+ analysisProfile: createAnalysisProfile(provider, {
+ loader: { mode: "full" },
+ }),
+ environment: {
+ id: "fixture-linux",
+ platform: "linux",
+ architecture: "x86_64",
+ isolation: "process",
+ },
+ locations: [{ kind: "address", address: "0x1000" }],
+ });
+ const session = createTestBinarySession(() => ({
+ execute: () => Promise.resolve(observed(null)),
+ close: () => Promise.resolve(resultOk(null)),
+ }));
+ expect(session.recordEvidence(evidence).ok).toBe(true);
+ for (const extra of [
+ createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, {
+ operation: "controlled_replay",
+ parameters: {},
+ result: { completed: true },
+ authority: "controlled-replay",
+ }),
+ createEvidence(undefined, PROCESS_PROVIDER, {
+ predicateType: "rea.process-capture",
+ operation: "capture_process_scenario",
+ parameters: {},
+ result: jsonValueSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE),
+ confidence: "observed",
+ authority: "controlled-replay",
+ }),
+ ])
+ expect(session.recordEvidence(extra).ok).toBe(true);
+ return session;
+};
+
+const exportFixtureBundle = async (
+ client: Client,
+ path: string,
+): Promise<{
+ readonly sourceJson: unknown;
+ readonly records: Record[];
+ readonly evidenceIndex: number;
+}> => {
+ const unknown = await client.callTool({
+ name: "record_unknown",
+ arguments: {
+ question: "Does this remain unknown?",
+ severity: "medium",
+ domain: "bundle-format",
+ required_authority: null,
+ required_confidence: "derived",
+ required_environment: null,
+ recommended_probes: [{ operation: "observe", rationale: "Inspect it." }],
+ relationships: [],
+ },
+ });
+ expect(unknown.isError).not.toBe(true);
+ const exported = await client.callTool({
+ name: "export_evidence_bundle",
+ arguments: { path },
+ });
+ expect(exported.isError).not.toBe(true);
+ const sourceJson = JSON.parse(await readFile(path, "utf8")) as unknown;
+ const records = array(object(sourceJson).records).map(object);
+ const evidenceIndex = records.findIndex(
+ (record) => record.operation === "observe",
+ );
+ expect(evidenceIndex).toBeGreaterThanOrEqual(0);
+ return { sourceJson, records, evidenceIndex };
+};
+
+const unsupportedBundleFields = (
+ evidenceIndex: number,
+): readonly [string, readonly (string | number)[]][] => [
+ ["bundle envelope", ["schema_version"]],
+ ["record provider", ["records", evidenceIndex, "provider", "future_field"]],
+ ["provider manifest", ["providers", 0, "future_field"]],
+ ["record subject", ["records", evidenceIndex, "subject", "future_field"]],
+ [
+ "subject digest",
+ ["records", evidenceIndex, "subject", "digest", "future_field"],
+ ],
+ ["artifact manifest", ["artifacts", 0, "future_field"]],
+ [
+ "record environment",
+ ["records", evidenceIndex, "environment", "future_field"],
+ ],
+ ["environment manifest", ["environments", 0, "future_field"]],
+ [
+ "source location",
+ ["records", evidenceIndex, "locations", 0, "future_field"],
+ ],
+ ["scenario manifest", ["scenarios", 0, "future_field"]],
+ ["capture manifest", ["captures", 0, "future_field"]],
+ [
+ "analysis profile",
+ ["records", evidenceIndex, "analysis_profile", "future_field"],
+ ],
+ [
+ "profile provider",
+ ["records", evidenceIndex, "analysis_profile", "provider", "future_field"],
+ ],
+ ["unknown revision", ["unknowns", 0, "future_field"]],
+ ["unknown probe", ["unknowns", 0, "recommended_probes", 0, "future_field"]],
+];
+
+const connectEvidenceMcp = async (
+ name: string,
+ session: ReturnType,
+) => {
+ const server = createServer({ kind: "session", session });
+ const client = new Client({ name, version: "1" });
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ return { client, server };
+};
+
+const object = (value: unknown): Record => {
+ if (typeof value !== "object" || value === null || Array.isArray(value))
+ throw new Error("Expected object in exported Evidence bundle");
+ return value as Record;
+};
+
+const array = (value: unknown): unknown[] => {
+ if (!Array.isArray(value))
+ throw new Error("Expected array in Evidence bundle");
+ return value;
+};
+
+const setPath = (
+ root: unknown,
+ path: readonly (string | number)[],
+ value: unknown,
+): void => {
+ const [last, ...parents] = [...path].reverse();
+ if (last === undefined) throw new Error("Expected a nonempty bundle path");
+ const target = parents
+ .reverse()
+ .reduce(
+ (current, segment) =>
+ typeof segment === "number"
+ ? array(current)[segment]
+ : object(current)[segment],
+ root,
+ );
+ object(target)[String(last)] = value;
+};
+
+const structured = (result: Awaited>) => {
+ if (result.isError === true) throw parseMcpToolError(result);
+ if (
+ typeof result.structuredContent !== "object" ||
+ result.structuredContent === null
+ )
+ throw new Error("Missing structured MCP result");
+ return result.structuredContent as Record;
+};
From 10935b7b1136accab2a43b308ab380749da0f7ae Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:59:44 +0800
Subject: [PATCH 019/159] fix(process): reject deep trace cycles without
recursive expansion
Use iterative cycle detection, memoized reachability and compact unordered-group membership. Preserve declaration diagnostics on cyclic input while skipping checks requiring a valid partial order. Report duplicate group declarations once instead of repeating pair errors.
Replace redundant source cycle/missing-pair assertions with public CLI/MCP coverage including 6000 events, dense not-before constraints and a large unordered group. Validate acyclic branching and mixed relation semantics. Document bounded Flameox CPU/latency gains and higher observed peak memory; agent/model costs remain unmeasured.
---
.../process-trace-validation.md | 59 +++++++
.../process/processTraceSpecification.test.ts | 99 +++++++-----
.../process/processTraceSpecification.ts | 148 ++++++++++++------
.../processComparisonDiagnosticsMcp.test.ts | 50 ++++++
4 files changed, 265 insertions(+), 91 deletions(-)
create mode 100644 experiments/analysis-efficiency/process-trace-validation.md
diff --git a/experiments/analysis-efficiency/process-trace-validation.md b/experiments/analysis-efficiency/process-trace-validation.md
new file mode 100644
index 000000000..0a4f9b0f6
--- /dev/null
+++ b/experiments/analysis-efficiency/process-trace-validation.md
@@ -0,0 +1,59 @@
+# Process trace validation
+
+A 6,000-event directed cycle previously overflowed the JavaScript stack inside
+the schema refinement. Cycle detection now uses iterative topological traversal.
+Reachability is computed once per queried origin; cyclic inputs retain
+independent declaration diagnostics without expanding all event pairs. Unordered
+declarations retain group membership, with one diagnostic per overlapping group,
+instead of allocating a pair set and repeating the same error for every pair.
+Valid partial orders still receive complete pair validation.
+
+## Bounded comparison
+
+Flameox ran three randomized paired blocks (seed 917), each limited to 20 seconds
+and 512 MiB. Node 24.18.0 used a 384-MiB old-space limit on macOS arm64. Each
+process validated the same 2,500-event acyclic chain four times, then checked the
+known 6,000-event cycle failure with per-event not-before constraints and one
+unordered group containing every event. All six executions passed the embedded answer
+digest and expected rejection checks.
+
+The baseline source is from `bb540826d`; the candidate includes this trace fix.
+Both modules had TypeScript annotations removed with Node's identical built-in
+transform and used the same copied runtime dependencies. This isolates schema
+validation rather than comparing complete packaged CLI/MCP processes.
+
+| Median measurement | Baseline | Candidate |
+| -------------------------------------------- | -------: | --------: |
+| First validation elapsed milliseconds | 335.80 | 177.25 |
+| Warm validation elapsed milliseconds | 313.36 | 165.23 |
+| First validation CPU milliseconds | 366.85 | 218.39 |
+| Warm validation CPU milliseconds | 335.32 | 194.08 |
+| Whole-process elapsed milliseconds | 1,344.13 | 787.00 |
+| Process CPU milliseconds before final report | 1,457.67 | 941.05 |
+| Peak process-family MiB | 328.36 | 395.52 |
+
+The input is identical: 233,095 JSON bytes, estimated at 77,010 tokens by tokenx
+1.3.0. The valid parsed result digest is
+`28bd99ce83f0da8e45600ef20f114cb52bb26b5f5e6bfc1601ca3e712eaf3147`.
+Actual agent tokens, catalog injection, transport, model reasoning and provider
+costs were not measured. This is a component measurement, not an end-to-end
+investigation speed claim.
+
+First validation excludes module import and fixture preparation; warm runs reuse
+the process but create fresh per-validation graph state. Whole-process elapsed
+includes startup, imports, all four validations, the cyclic-input check, token
+estimation and reporting. CPU includes startup through metrics publication;
+peak RSS covers the complete single-process workload, with no children. Host
+caches were not flushed. Peak memory increased in this cohort. The remaining
+valid-order closure and pair validation can still require quadratic resources.
+Three pairs describe this fixture only.
+
+Preserved Flameox evidence:
+`d6211639446e8b6ec2e82636913eaf471def96673e28f03e9c7f98bec07f8e13`;
+analysis `e663b6bd8f5693975542d5b5e2b18437b3c167a0be939bb7386eb68549936231`.
+An earlier cohort lost modules during another checkout build; its three failed
+executions and timing results are excluded. The successful cohort uses an
+isolated runtime copy. Source-module SHA-256 values are
+`a7729faa2183b0e493b9ac4fc55f6feaee5b2e5294be13d1b29b57a44b899788`
+(baseline) and `725409ce84902125941251037a8a214ccc3c1eec577e484c164c361ca580d693`
+(candidate).
diff --git a/src/domain/process/processTraceSpecification.test.ts b/src/domain/process/processTraceSpecification.test.ts
index aeedf4d31..9484e9075 100644
--- a/src/domain/process/processTraceSpecification.test.ts
+++ b/src/domain/process/processTraceSpecification.test.ts
@@ -39,27 +39,61 @@ const specification = (): ProcessTraceSpecification => ({
});
describe("process trace specification", () => {
- it("summarizes an incomplete language with its exact missing pair count", () => {
- const events = Array.from({ length: 200 }, (_, index) => ({
- id: `e${String(index)}`,
- source: "lifecycle" as const,
- exact: { fixture: index },
- cardinality: { kind: "optional" as const },
- }));
+ it("reports independent declaration errors alongside a cycle", () => {
+ const base = specification();
const result = processTraceSpecificationSchema.safeParse({
- events,
- language: { kind: "partial_order" },
+ ...base,
+ language: {
+ ...base.language,
+ happens_before: [
+ { before: "ready", after: "worker" },
+ { before: "worker", after: "ready" },
+ ],
+ not_before: [{ event: "missing", anchor: "ready" }],
+ unordered_groups: [{ events: ["missing", "missing"] }],
+ prefix: ["missing"],
+ suffix: ["missing"],
+ },
});
- expect(result.success).toBe(false);
- if (result.success)
- throw new Error("Expected incomplete pair declarations");
- expect(result.error.issues).toEqual([
- expect.objectContaining({
- path: ["language"],
- message:
- "19900 event pairs must be explicitly ordered or unordered; first undeclared pair: e0/e1",
- }),
- ]);
+ if (result.success) throw new Error("Expected invalid trace declarations");
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ message: "happens-before graph must be acyclic",
+ }),
+ expect.objectContaining({
+ path: ["language", "not_before", 0, "event"],
+ }),
+ expect.objectContaining({
+ path: ["language", "unordered_groups", 0, "events"],
+ }),
+ expect.objectContaining({ path: ["language", "prefix", 0] }),
+ expect.objectContaining({ path: ["language", "suffix", 0] }),
+ ]),
+ );
+ });
+
+ it("accepts an acyclic order with shared successors and explicit unordered peers", () => {
+ const result = processTraceSpecificationSchema.parse({
+ events: ["a", "b", "c", "d"].map((id) => ({
+ id,
+ source: "lifecycle",
+ exact: { marker: id },
+ })),
+ language: {
+ kind: "partial_order",
+ happens_before: [
+ { before: "a", after: "b" },
+ { before: "a", after: "c" },
+ { before: "b", after: "d" },
+ { before: "c", after: "d" },
+ ],
+ unordered_groups: [{ events: ["b", "c"] }],
+ prefix: ["a"],
+ suffix: ["d"],
+ },
+ });
+ expect(result.language.kind).toBe("partial_order");
});
it("counts only undeclared pairs after transitive, not-before and unordered relations", () => {
@@ -91,7 +125,7 @@ describe("process trace specification", () => {
]);
});
- it("retains duplicate unordered-pair diagnostics for reversed declarations", () => {
+ it("reports one duplicate-pair diagnostic per overlapping group", () => {
const result = processTraceSpecificationSchema.safeParse({
events: ["z", "a", "b"].map((id) => ({
id,
@@ -101,7 +135,10 @@ describe("process trace specification", () => {
})),
language: {
kind: "partial_order",
- unordered_groups: [{ events: ["z", "a", "b"] }, { events: ["a", "z"] }],
+ unordered_groups: [
+ { events: ["z", "a", "b"] },
+ { events: ["b", "a", "z"] },
+ ],
},
});
expect(result.success).toBe(false);
@@ -114,26 +151,8 @@ describe("process trace specification", () => {
]);
});
- it("rejects cycles, implicit ordering gaps, invalid ignores, and unsatisfiable variants", () => {
+ it("rejects invalid ignores and unsatisfiable variants", () => {
const base = specification();
- expect(
- processTraceSpecificationSchema.safeParse({
- ...base,
- language: {
- kind: "partial_order",
- happens_before: [
- { before: "ready", after: "worker" },
- { before: "worker", after: "ready" },
- ],
- },
- }).success,
- ).toBe(false);
- expect(
- processTraceSpecificationSchema.safeParse({
- ...base,
- language: { kind: "partial_order" },
- }).success,
- ).toBe(false);
expect(
processTraceSpecificationSchema.safeParse({
events: [
diff --git a/src/domain/process/processTraceSpecification.ts b/src/domain/process/processTraceSpecification.ts
index 61ace7ca1..8e4e6fca6 100644
--- a/src/domain/process/processTraceSpecification.ts
+++ b/src/domain/process/processTraceSpecification.ts
@@ -105,6 +105,7 @@ export type ProcessTraceSpecification = z.infer<
typeof specificationShapeSchema
>;
type PartialOrderLanguage = z.infer;
+type Reachability = (origin: string) => ReadonlySet;
export const canonicalTraceJson = (value: unknown): string => {
const serialized = canonicalize(value);
@@ -170,7 +171,6 @@ const validateNotBefore = (
language: PartialOrderLanguage,
context: z.RefinementCtx,
known: ReadonlySet,
- reachable: ReadonlyMap>,
): ReadonlySet => {
const pairs = new Set();
for (const [index, constraint] of language.not_before.entries()) {
@@ -191,63 +191,81 @@ const validateNotBefore = (
path,
});
pairs.add(key);
- if (reachable.get(constraint.event)?.has(constraint.anchor) === true)
+ }
+ return pairs;
+};
+
+const validateNotBeforeOrder = (
+ language: PartialOrderLanguage,
+ context: z.RefinementCtx,
+ reachable: Reachability,
+): void => {
+ for (const [index, constraint] of language.not_before.entries())
+ if (reachable(constraint.event).has(constraint.anchor))
context.addIssue({
code: "custom",
message: "not-before constraint conflicts with happens-before order",
- path,
+ path: ["language", "not_before", index],
});
- }
- return pairs;
};
const graphHasCycle = (
ids: readonly string[],
adjacency: ReadonlyMap>,
): boolean => {
- const active = new Set();
- const complete = new Set();
- const visit = (current: string): boolean => {
- if (active.has(current)) return true;
- if (complete.has(current)) return false;
- active.add(current);
- const cyclic = [...(adjacency.get(current) ?? [])].some(visit);
- active.delete(current);
- complete.add(current);
- return cyclic;
- };
- return ids.some(visit);
+ const incoming = new Map(ids.map((id) => [id, 0]));
+ for (const successors of adjacency.values())
+ for (const successor of successors) {
+ const count = incoming.get(successor);
+ if (count !== undefined) incoming.set(successor, count + 1);
+ }
+ const ready = [...incoming].flatMap(([id, count]) =>
+ count === 0 ? [id] : [],
+ );
+ let examined = 0;
+ while (ready.length > 0) {
+ const current = ready.pop();
+ if (current === undefined) break;
+ examined++;
+ for (const successor of adjacency.get(current) ?? []) {
+ const count = incoming.get(successor);
+ if (count === undefined) continue;
+ incoming.set(successor, count - 1);
+ if (count === 1) ready.push(successor);
+ }
+ }
+ return examined !== incoming.size;
};
-const computeReachability = (
- ids: readonly string[],
+const createReachability = (
adjacency: ReadonlyMap>,
-): ReadonlyMap> => {
+): Reachability => {
const reachable = new Map>();
- for (const origin of ids) {
+ return (origin) => {
+ const cached = reachable.get(origin);
+ if (cached !== undefined) return cached;
const seen = new Set();
const pending = [...(adjacency.get(origin) ?? [])];
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined || seen.has(current)) continue;
seen.add(current);
- pending.push(...(adjacency.get(current) ?? []));
+ for (const successor of adjacency.get(current) ?? [])
+ pending.push(successor);
}
reachable.set(origin, seen);
- }
- return reachable;
+ return seen;
+ };
};
const isOrdered = (
- reachable: ReadonlyMap>,
+ reachable: Reachability,
left: string,
right: string,
-): boolean =>
- reachable.get(left)?.has(right) === true ||
- reachable.get(right)?.has(left) === true;
+): boolean => reachable(left).has(right) || reachable(right).has(left);
type OrderingRelations = {
- readonly reachable: ReadonlyMap>;
+ readonly reachable: Reachability;
readonly notBeforePairs: ReadonlySet;
};
@@ -255,12 +273,12 @@ const validateUnorderedGroups = (
language: PartialOrderLanguage,
context: z.RefinementCtx,
known: ReadonlySet,
- relations: OrderingRelations,
-): ReadonlySet => {
- const pairs = new Set();
+): ((left: string, right: string) => boolean) => {
+ const memberships = new Map>();
for (const [groupIndex, group] of language.unordered_groups.entries()) {
const path = ["language", "unordered_groups", groupIndex];
- if (new Set(group.events).size !== group.events.length)
+ const uniqueIds = new Set(group.events);
+ if (uniqueIds.size !== group.events.length)
context.addIssue({
code: "custom",
message: "unordered groups must not repeat event ids",
@@ -268,6 +286,35 @@ const validateUnorderedGroups = (
});
for (const [eventIndex, id] of group.events.entries())
addReferenceIssue(context, known, id, [...path, "events", eventIndex]);
+ const overlaps = new Map();
+ for (const id of uniqueIds) {
+ const priorGroups = memberships.get(id) ?? new Set();
+ for (const prior of priorGroups)
+ overlaps.set(prior, (overlaps.get(prior) ?? 0) + 1);
+ priorGroups.add(groupIndex);
+ memberships.set(id, priorGroups);
+ }
+ if ([...overlaps.values()].some((count) => count > 1))
+ context.addIssue({
+ code: "custom",
+ message: "unordered event pairs must be declared once",
+ path,
+ });
+ }
+ return (left, right) => {
+ const rightGroups = memberships.get(right);
+ for (const group of memberships.get(left) ?? [])
+ if (rightGroups?.has(group) === true) return true;
+ return false;
+ };
+};
+
+const validateUnorderedOrder = (
+ language: PartialOrderLanguage,
+ context: z.RefinementCtx,
+ relations: OrderingRelations,
+): void => {
+ for (const [groupIndex, group] of language.unordered_groups.entries())
for (const [leftIndex, leftId] of group.events.entries()) {
for (
let rightIndex = leftIndex + 1;
@@ -277,13 +324,6 @@ const validateUnorderedGroups = (
const rightId = group.events[rightIndex];
if (rightId === undefined) continue;
const key = pairKey(leftId, rightId);
- if (pairs.has(key))
- context.addIssue({
- code: "custom",
- message: "unordered event pairs must be declared once",
- path,
- });
- pairs.add(key);
if (
isOrdered(relations.reachable, leftId, rightId) ||
relations.notBeforePairs.has(key)
@@ -291,18 +331,16 @@ const validateUnorderedGroups = (
context.addIssue({
code: "custom",
message: "ordered events cannot also be declared unordered",
- path,
+ path: ["language", "unordered_groups", groupIndex],
});
}
}
- }
- return pairs;
};
const validateCompletePairDeclarations = (
ids: readonly string[],
relations: OrderingRelations,
- unorderedPairs: ReadonlySet,
+ unorderedPair: (left: string, right: string) => boolean,
context: z.RefinementCtx,
): void => {
let missingPairCount = 0;
@@ -317,7 +355,10 @@ const validateCompletePairDeclarations = (
if (rightId === undefined) continue;
if (isOrdered(relations.reachable, leftId, rightId)) continue;
const key = pairKey(leftId, rightId);
- if (!relations.notBeforePairs.has(key) && !unorderedPairs.has(key)) {
+ if (
+ !relations.notBeforePairs.has(key) &&
+ !unorderedPair(leftId, rightId)
+ ) {
if (missingPairCount === 0) firstMissingPair = `${leftId}/${rightId}`;
missingPairCount++;
}
@@ -342,27 +383,32 @@ const validatePartialOrder = (
const ids = specification.events.map(({ id }) => id);
const known = new Set(ids);
const adjacency = buildAdjacency(specification.language, context, known);
- if (graphHasCycle(ids, adjacency))
+ const cyclic = graphHasCycle(ids, adjacency);
+ if (cyclic)
context.addIssue({
code: "custom",
message: "happens-before graph must be acyclic",
path: ["language"],
});
- const reachable = computeReachability(ids, adjacency);
+ const reachable = createReachability(adjacency);
const notBeforePairs = validateNotBefore(
specification.language,
context,
known,
- reachable,
);
const relations = { reachable, notBeforePairs };
- const unorderedPairs = validateUnorderedGroups(
+ const unorderedPair = validateUnorderedGroups(
specification.language,
context,
known,
- relations,
);
- validateCompletePairDeclarations(ids, relations, unorderedPairs, context);
+ // Preserve independent declaration diagnostics on cyclic input. Computing
+ // all pair relations cannot establish a valid partial order in that case.
+ if (!cyclic) {
+ validateNotBeforeOrder(specification.language, context, reachable);
+ validateUnorderedOrder(specification.language, context, relations);
+ validateCompletePairDeclarations(ids, relations, unorderedPair, context);
+ }
for (const [kind, values] of [
["prefix", specification.language.prefix],
["suffix", specification.language.suffix],
diff --git a/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts b/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts
index 69fdc3944..bda4672de 100644
--- a/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts
+++ b/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts
@@ -252,6 +252,56 @@ it("reports CLI trace specification issues like the MCP trace_spec input", async
});
});
+it("rejects a deep trace cycle as an input error through CLI and MCP", async () => {
+ const events = Array.from({ length: 6000 }, (_, index) => ({
+ id: `e${String(index)}`,
+ source: "lifecycle",
+ exact: { marker: index },
+ }));
+ const trace = {
+ events,
+ language: {
+ kind: "partial_order",
+ happens_before: events.map((event, index) => ({
+ before: event.id,
+ after: events[(index + 1) % events.length]!.id,
+ })),
+ not_before: events.map((event, index) => ({
+ event: event.id,
+ anchor: events[(index + 1) % events.length]!.id,
+ })),
+ unordered_groups: [{ events: events.map(({ id }) => id) }],
+ },
+ };
+ const issue = {
+ path: ["trace_spec", "language"],
+ message: "happens-before graph must be acyclic",
+ };
+ const capture = captureEvidence("left");
+ const root = await createTestTempDirectory("rea-process-trace-cycle-");
+ const capturePath = join(root, "capture.json");
+ const specPath = join(root, "spec.json");
+ await writeFile(capturePath, JSON.stringify(capture));
+ await writeFile(specPath, JSON.stringify(trace));
+ expect(
+ await compareProcessEvidenceFiles(capturePath, capturePath, specPath),
+ ).toMatchObject({
+ code: "invalid_request",
+ details: { issues: [issue] },
+ });
+
+ const client = await connect();
+ const response = await client.callTool({
+ name: "compare_process_captures",
+ arguments: { left: capture, right: capture, trace_spec: trace },
+ });
+ expect(response.isError).toBe(true);
+ expect(response.content).toEqual([
+ { type: "text", text: expect.stringContaining(issue.message) },
+ ]);
+ await client.ping();
+});
+
it("names the side and constraint when Evidence is not a usable capture", async () => {
const client = await connect();
const capture = captureEvidence("right");
From 8b181716aca2cb467be499cba226abfb5ae19141 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 12:59:52 +0800
Subject: [PATCH 020/159] fix(install): honor redirected setup diagnostics
Require terminal stderr before launching the automatic setup wizard, matching the CLI's interactive admission rule. Print the manual setup command when diagnostics are redirected.
Exercise a real PTY with stderr redirected to a fixture file and verify the installed command is not launched for setup. Installer lane: 33 tests passed; targeted regression, typecheck/lint and npm pack dry-run passed. Full package verifier was not run under the requested focused-check resource policy.
---
install.sh | 2 +-
.../boundary/filesystem/installScript.test.ts | 68 ++++++++++++++++++-
2 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/install.sh b/install.sh
index 46260fe71..c18726611 100755
--- a/install.sh
+++ b/install.sh
@@ -167,7 +167,7 @@ main() {
[ "$installed_version" = "$version" ] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
printf 'REA %s is installed.\n' "$version"
- if [ "$start_setup" = true ] && [ -r /dev/tty ] && [ -w /dev/tty ] && [ -t 1 ]; then
+ if [ "$start_setup" = true ] && [ -r /dev/tty ] && [ -w /dev/tty ] && [ -t 1 ] && [ -t 2 ]; then
printf 'Starting REA setup...\n'
"$install_bin" setup /dev/tty
else
diff --git a/tests/boundary/filesystem/installScript.test.ts b/tests/boundary/filesystem/installScript.test.ts
index 3fbcf7a3c..0f2e14730 100644
--- a/tests/boundary/filesystem/installScript.test.ts
+++ b/tests/boundary/filesystem/installScript.test.ts
@@ -11,6 +11,7 @@ import {
import { join } from "node:path";
import { promisify } from "node:util";
+import { spawn as spawnPty } from "@lydell/node-pty";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -185,6 +186,69 @@ describe("curl installer scenarios", { timeout: 20_000 }, () => {
});
});
+describe("installer TTY fallback", { timeout: 20_000 }, () => {
+ it("skips automatic setup when stderr is redirected", async () => {
+ const fixture = await createFixture();
+ const script = join(process.cwd(), "install.sh");
+ const wrapper = `
+ const { spawnSync } = require("node:child_process");
+ const { openSync, closeSync } = require("node:fs");
+ const stderr = openSync(process.argv[2], "w");
+ const child = spawnSync("/bin/bash", [process.argv[1], ...process.argv.slice(3)], {
+ stdio: ["inherit", "inherit", stderr],
+ env: process.env,
+ });
+ closeSync(stderr);
+ process.exit(child.status ?? 1);
+ `;
+ let output = "";
+ const terminal = spawnPty(
+ process.execPath,
+ ["-e", wrapper, script, fixture.installerStderrLog, "--version", "0.3.0"],
+ {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ HOME: fixture.home,
+ TMPDIR: fixture.temporary,
+ PATH: `${fixture.bin}:/usr/bin:/bin`,
+ FAKE_NPM_LOG: fixture.npmLog,
+ FAKE_REA_LOG: fixture.reaLog,
+ FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
+ FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
+ TERM: "xterm-256color",
+ },
+ name: "xterm-256color",
+ cols: 120,
+ rows: 40,
+ },
+ );
+ let timedOut = false;
+ const exitCode = await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ terminal.kill();
+ }, 10_000);
+ terminal.onData((data) => {
+ output += data;
+ });
+ terminal.onExit(({ exitCode: code }) => {
+ clearTimeout(timeout);
+ if (timedOut)
+ reject(new Error(`Installer PTY timed out after output: ${output}`));
+ else resolve(code);
+ });
+ });
+
+ expect(exitCode).toBe(0);
+ expect(output).toContain(
+ `Run ${join(fixture.home, ".local/bin/rea")} setup to configure Hopper and agents.`,
+ );
+ expect(await readFile(fixture.reaLog, "utf8")).toBe("--version\n");
+ expect(await readFile(fixture.installerStderrLog, "utf8")).toBe("");
+ });
+});
+
describe("truncated installer download", { timeout: 20_000 }, () => {
it("runs nothing from a download truncated after the npm install line", async () => {
const fixture = await createFixture();
@@ -433,6 +497,7 @@ interface InstallerFixture {
readonly temporary: string;
readonly npmLog: string;
readonly reaLog: string;
+ readonly installerStderrLog: string;
}
const getProcessOutput = (
@@ -456,6 +521,7 @@ const createFixture = async (): Promise => {
const temporary = join(root, "tmp");
const npmLog = join(root, "npm.log");
const reaLog = join(root, "rea.log");
+ const installerStderrLog = join(root, "installer.stderr.log");
await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]);
await Promise.all([
executable(
@@ -507,7 +573,7 @@ exit 0
symlink(`/usr/bin/${command}`, join(bin, command)),
),
]);
- return { home, bin, temporary, npmLog, reaLog };
+ return { home, bin, temporary, npmLog, reaLog, installerStderrLog };
};
const executable = async (path: string, source: string): Promise => {
From 7a2cd1ea0a63e8c44166860b7ee9c7a1aa43492b Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:12:06 +0800
Subject: [PATCH 021/159] fix(domain): preserve derived byte counts and
uncertain identity
---
.../ManagedReconstructionService.test.ts | 20 +++++++++++++
src/domain/functionComparison.test.ts | 28 +++++++++++++++++++
src/domain/functionComparisonNormalization.ts | 21 ++++++--------
src/domain/managed/managedReconstruction.ts | 3 +-
4 files changed, 59 insertions(+), 13 deletions(-)
diff --git a/src/application/managed/ManagedReconstructionService.test.ts b/src/application/managed/ManagedReconstructionService.test.ts
index 56b9d9442..793951ae9 100644
--- a/src/application/managed/ManagedReconstructionService.test.ts
+++ b/src/application/managed/ManagedReconstructionService.test.ts
@@ -22,6 +22,26 @@ const exampleMethod = () => {
};
describe("managed decompiler reconstruction import", () => {
+ it("reports UTF-8 byte totals while preserving UTF-16 text lengths", () => {
+ const text = 'return "é"; // 😀';
+ const method = exampleMethod();
+ const result = importManagedReconstructionEvidence({
+ ...MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE,
+ methods: [
+ {
+ ...method,
+ reconstruction: { ...method.reconstruction, text },
+ },
+ ],
+ });
+ if (!result.ok) throw result.error;
+
+ expect(result.value.normalized_result).toMatchObject({
+ summary: { total_text_bytes: 20 },
+ methods: [{ reconstruction: { text, text_length: 17 } }],
+ });
+ });
+
it("rejects reconstruction text hash drift", () => {
expect(() =>
importManagedReconstruction({
diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts
index acb505dac..e03a27748 100644
--- a/src/domain/functionComparison.test.ts
+++ b/src/domain/functionComparison.test.ts
@@ -119,6 +119,34 @@ const observe = (
);
describe("function comparison normalized identity", () => {
+ it("keeps differing generated names ambiguous and stable names mismatched", () => {
+ const named = (base: "0x1000" | "0x2000", name: string) =>
+ functionDossierSchema.parse({
+ ...dossier("return 0;", base),
+ procedure: {
+ ...procedure(base, name),
+ signature: "int helper(void)",
+ locals: [],
+ },
+ });
+ const generated = compareFunctions(
+ observe("1", named("0x1000", "sub_1000")),
+ observe("2", named("0x2000", "sub_2000")),
+ );
+ expect(generated.function_match.status).toBe("ambiguous");
+ expect(generated.status).toBe("unknown");
+ expect(
+ generated.dimensions.find(({ dimension }) => dimension === "identity"),
+ ).toMatchObject({ status: "unknown" });
+
+ const stable = compareFunctions(
+ observe("3", named("0x1000", "firstFunction")),
+ observe("4", named("0x2000", "secondFunction")),
+ );
+ expect(stable.function_match.status).toBe("mismatched");
+ expect(stable.status).toBe("changed");
+ });
+
it.each(["sub_deallocate", "sub_deadbeef_handler", "fcn.dispatch"])(
"recognizes the explicit symbol %s without a generated-name prefix false positive",
(name) => {
diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts
index a676e1e49..5282272e1 100644
--- a/src/domain/functionComparisonNormalization.ts
+++ b/src/domain/functionComparisonNormalization.ts
@@ -16,24 +16,21 @@ export const functionMatch = (
left: FunctionSnapshot,
right: FunctionSnapshot,
): FunctionComparisonResult["function_match"] => {
- if (
- !isAutoName(left.dossier.procedure.name) &&
- left.dossier.procedure.name === right.dossier.procedure.name
- )
+ const leftName = left.dossier.procedure.name;
+ const rightName = right.dossier.procedure.name;
+ const stableNames = !isAutoName(leftName) && !isAutoName(rightName);
+ if (stableNames && leftName === rightName)
return {
status: "matched",
method: "symbol",
- left_name: left.dossier.procedure.name,
- right_name: right.dossier.procedure.name,
+ left_name: leftName,
+ right_name: rightName,
};
return {
- status:
- left.dossier.procedure.name === right.dossier.procedure.name
- ? "ambiguous"
- : "mismatched",
+ status: stableNames ? "mismatched" : "ambiguous",
method: "explicit",
- left_name: left.dossier.procedure.name,
- right_name: right.dossier.procedure.name,
+ left_name: leftName,
+ right_name: rightName,
};
};
diff --git a/src/domain/managed/managedReconstruction.ts b/src/domain/managed/managedReconstruction.ts
index e10be0ffd..9600166ae 100644
--- a/src/domain/managed/managedReconstruction.ts
+++ b/src/domain/managed/managedReconstruction.ts
@@ -204,7 +204,8 @@ const buildReconstructionSummary = (
({ reconstruction }) => reconstruction.kind === "semantic-pseudocode",
).length,
total_text_bytes: methods.reduce(
- (total, { reconstruction }) => total + reconstruction.text_length,
+ (total, { reconstruction }) =>
+ total + Buffer.byteLength(reconstruction.text, "utf8"),
0,
),
});
From 1447fd2f990c5d9820337dfd4b8a170f8cad17cd Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:12:12 +0800
Subject: [PATCH 022/159] fix(evaluation): share completed transcript facts
across graders
---
src/evaluation/CodexAgentEval.ts | 66 +++-----
src/evaluation/KnownAnswerEvaluation.ts | 148 +++++++++++------
.../model-evals/codexAgentEval.test.ts | 154 +++++++++++++++---
3 files changed, 257 insertions(+), 111 deletions(-)
diff --git a/src/evaluation/CodexAgentEval.ts b/src/evaluation/CodexAgentEval.ts
index bac8d8191..55ec8333e 100644
--- a/src/evaluation/CodexAgentEval.ts
+++ b/src/evaluation/CodexAgentEval.ts
@@ -1,10 +1,11 @@
import {
- deliveredCodexEvidence,
evaluateKnownAnswers,
+ parseCodexToolCall,
type FixtureClaimExpectation,
type KnownAnswerAssessment,
+ type ParsedCodexToolCall,
} from "./KnownAnswerEvaluation.js";
-import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
+import { canonicalJson } from "../domain/comparisonSemantics.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
/** One REA MCP invocation observed in a Codex JSONL transcript. */
@@ -57,17 +58,6 @@ const textValue = (value: unknown): string | undefined =>
const numberValue = (value: unknown): number =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
-const canonicalValue = (value: unknown): unknown => {
- if (Array.isArray(value)) return value.map(canonicalValue);
- const object = record(value);
- if (object === undefined) return value;
- return Object.fromEntries(
- Object.entries(object)
- .sort(([left], [right]) => compareUnicodeCodePoints(left, right))
- .map(([key, child]) => [key, canonicalValue(child)]),
- );
-};
-
const toolResultError = (
item: Record,
): Record | undefined => {
@@ -113,29 +103,18 @@ const toolResultFailed = (
);
};
-const callFromEvent = (
- event: Record,
+const callFromParsed = (
+ parsed: ParsedCodexToolCall | undefined,
): CodexMcpCall | undefined => {
- const item = record(
- event.type === "item.completed"
- ? event.item
- : event.type === "mcp_tool_call"
- ? event
- : undefined,
- );
- if (item?.type !== "mcp_tool_call") return undefined;
- const tool = textValue(item.tool) ?? textValue(item.name);
- if (tool === undefined) return undefined;
- const error = toolResultError(item);
+ if (parsed === undefined) return undefined;
+ const error = toolResultError(parsed.item);
return {
- id: textValue(item.id) ?? null,
- server: textValue(item.server) ?? textValue(item.server_name) ?? null,
- tool,
- arguments: item.arguments ?? item.input ?? {},
- evidenceIds: deliveredCodexEvidence(event).map(
- ({ evidence_id }) => evidence_id,
- ),
- error: toolResultFailed(item, error),
+ id: parsed.id,
+ server: parsed.server,
+ tool: parsed.tool,
+ arguments: parsed.arguments,
+ evidenceIds: parsed.evidence.map(({ evidence_id }) => evidence_id),
+ error: toolResultFailed(parsed.item, error),
errorCode: textValue(error?.code) ?? null,
};
};
@@ -163,16 +142,20 @@ interface EvaluationState {
outputTokens: number;
}
-const consumeEvent = (state: EvaluationState, value: unknown): void => {
+const consumeEvent = (
+ state: EvaluationState,
+ value: unknown,
+ parsedCall: ParsedCodexToolCall | undefined,
+): void => {
const event = record(value);
if (event === undefined) return;
const completedItem =
event.type === "item.completed" ? event.item : undefined;
- const call = callFromEvent(event);
+ const call = callFromParsed(parsedCall);
if (call !== undefined) {
const identity =
call.id ??
- `${call.server ?? ""}:${call.tool}:${JSON.stringify(canonicalValue(call.arguments))}`;
+ `${call.server ?? ""}:${call.tool}:${canonicalJson(call.arguments)}`;
if (!state.seenCallIds.has(identity)) {
state.seenCallIds.add(identity);
state.calls.push(call);
@@ -216,7 +199,10 @@ export const evaluateCodexEvents = (
cachedInputTokens: 0,
outputTokens: 0,
};
- for (const event of events) consumeEvent(state, event);
+ const parsedCalls = events.map(parseCodexToolCall);
+ events.forEach((event, index) =>
+ consumeEvent(state, event, parsedCalls[index]),
+ );
const reaCalls = state.calls.filter(
({ server }) => server === null || server.toLowerCase() === "rea",
@@ -228,7 +214,7 @@ export const evaluateCodexEvents = (
// useful verification, not a duplicate analysis or unchanged failed retry.
const context =
call.tool === "binary_session" ? `${String(targetRevision)}:` : "";
- const signature = `${context}${call.tool}:${JSON.stringify(canonicalValue(call.arguments))}`;
+ const signature = `${context}${call.tool}:${canonicalJson(call.arguments)}`;
signatures.set(signature, (signatures.get(signature) ?? 0) + 1);
if (
!call.error &&
@@ -271,7 +257,7 @@ export const evaluateCodexEvents = (
finalMessage,
);
const factualAssessment = evaluateKnownAnswers(
- events,
+ parsedCalls,
finalMessage,
options.fixtureClaims,
);
diff --git a/src/evaluation/KnownAnswerEvaluation.ts b/src/evaluation/KnownAnswerEvaluation.ts
index 807308b59..c6e4af72b 100644
--- a/src/evaluation/KnownAnswerEvaluation.ts
+++ b/src/evaluation/KnownAnswerEvaluation.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
import { parseEvidence, type Evidence } from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
-import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
+import { canonicalJson } from "../domain/comparisonSemantics.js";
/** One closed fixture assertion and the producer facts that must support it. */
export interface FixtureClaimExpectation {
@@ -104,23 +104,13 @@ const isRecord = (value: unknown): value is Record =>
typeof value === "object" && value !== null && !Array.isArray(value);
const record = (value: unknown): Record | undefined =>
isRecord(value) ? value : undefined;
-const canonicalValue = (value: JsonValue): JsonValue => {
- if (Array.isArray(value)) return value.map(canonicalValue);
- if (value === null || typeof value !== "object") return value;
- return Object.fromEntries(
- Object.entries(value)
- .sort(([left], [right]) => compareUnicodeCodePoints(left, right))
- .map(([key, child]) => [key, canonicalValue(child)]),
- );
-};
const sameJson = (left: unknown, right: unknown): boolean => {
const parsedLeft = jsonValueSchema.safeParse(left);
const parsedRight = jsonValueSchema.safeParse(right);
return (
parsedLeft.success &&
parsedRight.success &&
- JSON.stringify(canonicalValue(parsedLeft.data)) ===
- JSON.stringify(canonicalValue(parsedRight.data))
+ canonicalJson(parsedLeft.data) === canonicalJson(parsedRight.data)
);
};
@@ -168,6 +158,16 @@ interface DeliveredEvidence {
readonly conflicts: Set;
}
+/** One completed tool call, with aliases normalized and successful Evidence authenticated. */
+export interface ParsedCodexToolCall {
+ readonly item: Record;
+ readonly id: string | null;
+ readonly server: string | null;
+ readonly tool: string;
+ readonly arguments: JsonValue;
+ readonly evidence: readonly Evidence[];
+}
+
const consistentAliases = (
value: Record,
left: string,
@@ -177,30 +177,14 @@ const consistentAliases = (
value[right] === undefined ||
value[left] === value[right];
-const completedCall = (event: unknown): Record | undefined => {
- const value = record(event);
- const item =
- value?.type === "item.completed"
- ? record(value.item)
- : value?.type === "mcp_tool_call" && value.status === "completed"
- ? value
- : undefined;
- if (item?.type !== "mcp_tool_call") return undefined;
- const server = item.server ?? item.server_name;
- if (
- typeof server !== "string" ||
- server.toLowerCase() !== "rea" ||
- (item.status !== undefined && item.status !== "completed") ||
- (item.error !== undefined && item.error !== null)
- )
- return undefined;
- if (
- !consistentAliases(item, "server", "server_name") ||
- !consistentAliases(item, "tool", "name")
- )
- return undefined;
- return item;
-};
+const consistentJsonFields = (
+ value: Record,
+ left: string,
+ right: string,
+): boolean =>
+ value[left] === undefined ||
+ value[right] === undefined ||
+ sameJson(value[left], value[right]);
const consistentJsonAliases = (
value: Record,
@@ -215,6 +199,7 @@ const consistentJsonAliases = (
const evidenceFromCall = (
item: Record,
+ tool: string,
): Evidence | undefined => {
if (!consistentJsonAliases(item, "result", "output")) return undefined;
const result = record(item.result ?? item.output);
@@ -254,22 +239,42 @@ const evidenceFromCall = (
// Transcript output is untrusted; invalid Evidence cannot support a claim.
return undefined;
}
- if ((item.tool ?? item.name) !== evidence.operation) return undefined;
+ if (tool !== evidence.operation) return undefined;
return evidence;
};
-/** Authenticate completed REA deliveries for both citations and fixture grading. */
-export const deliveredCodexEvidence = (event: unknown): readonly Evidence[] => {
- const item = completedCall(event);
- if (item === undefined) return [];
- const evidence = evidenceFromCall(item);
- if (evidence === undefined) return [];
+const completedToolItem = (
+ event: unknown,
+): Record | undefined => {
+ const value = record(event);
+ const wrapped = value?.type === "item.completed";
+ const item = wrapped
+ ? record(value.item)
+ : value?.type === "mcp_tool_call"
+ ? value
+ : undefined;
+ if (item?.type !== "mcp_tool_call") return undefined;
+ const terminalStatus =
+ item.status === "completed" || item.status === "failed";
+ if (
+ (wrapped
+ ? item.status !== undefined && !terminalStatus
+ : !terminalStatus) ||
+ !consistentAliases(item, "server", "server_name") ||
+ !consistentAliases(item, "tool", "name") ||
+ !consistentJsonFields(item, "arguments", "input")
+ )
+ return undefined;
+ return item;
+};
+
+const artifactDelivery = (evidence: Evidence): readonly Evidence[] => {
const records = [evidence];
- // Artifact inspection delivers its inventory Evidence inline. Bare links and
- // arbitrary IDs in normalized facts are references, not delivered records.
if (evidence.operation !== "inspect_artifact") return records;
const substeps = record(evidence.normalized_result)?.substeps;
if (!Array.isArray(substeps)) return records;
+ // Artifact inspection delivers linked inventory Evidence inline; bare IDs in
+ // normalized facts are references, not authenticated deliveries.
for (const value of substeps) {
const substep = record(value);
if (
@@ -293,13 +298,56 @@ export const deliveredCodexEvidence = (event: unknown): readonly Evidence[] => {
return records;
};
+/** Parse one completed transcript call and authenticate any successful REA Evidence. */
+export const parseCodexToolCall = (
+ event: unknown,
+): ParsedCodexToolCall | undefined => {
+ const item = completedToolItem(event);
+ if (item === undefined) return undefined;
+ const status = item.status;
+ const server = item.server ?? item.server_name;
+ const tool = item.tool ?? item.name;
+ if (
+ ((item.server !== undefined || item.server_name !== undefined) &&
+ typeof server !== "string") ||
+ typeof tool !== "string" ||
+ tool.length === 0
+ )
+ return undefined;
+ const argumentsValue =
+ item.arguments !== undefined
+ ? item.arguments
+ : item.input !== undefined
+ ? item.input
+ : {};
+ const parsedArguments = jsonValueSchema.safeParse(argumentsValue);
+ if (!parsedArguments.success) return undefined;
+ const parsed: ParsedCodexToolCall = {
+ item,
+ id: typeof item.id === "string" && item.id.length > 0 ? item.id : null,
+ server: typeof server === "string" ? server : null,
+ tool,
+ arguments: parsedArguments.data,
+ evidence: [],
+ };
+ if (
+ parsed.server?.toLowerCase() !== "rea" ||
+ status === "failed" ||
+ (item.error !== undefined && item.error !== null)
+ )
+ return parsed;
+ const evidence = evidenceFromCall(item, tool);
+ if (evidence === undefined) return parsed;
+ return { ...parsed, evidence: artifactDelivery(evidence) };
+};
+
const collectDeliveredEvidence = (
- events: readonly unknown[],
+ calls: readonly (ParsedCodexToolCall | undefined)[],
): DeliveredEvidence => {
const records = new Map();
const conflicts = new Set();
- for (const event of events) {
- for (const evidence of deliveredCodexEvidence(event)) {
+ for (const call of calls) {
+ for (const evidence of call?.evidence ?? []) {
const previous = records.get(evidence.evidence_id);
if (previous !== undefined && !sameJson(previous, evidence))
conflicts.add(evidence.evidence_id);
@@ -401,7 +449,7 @@ const assessClaim = (
/** Grade closed answers only against configured fixture truth and delivered authenticated Evidence. */
export const evaluateKnownAnswers = (
- events: readonly unknown[],
+ calls: readonly (ParsedCodexToolCall | undefined)[],
finalMessage: string,
expectations: readonly FixtureClaimExpectation[] | undefined,
): KnownAnswerAssessment => {
@@ -441,7 +489,7 @@ export const evaluateKnownAnswers = (
manifestFailures.push(`unknown_claim:${claim.id}`);
submitted.set(claim.id, claim);
}
- const delivered = collectDeliveredEvidence(events);
+ const delivered = collectDeliveredEvidence(calls);
const claims = expectations.map((expected) =>
assessClaim(expected, submitted.get(expected.id), delivered),
);
diff --git a/tests/evaluation/model-evals/codexAgentEval.test.ts b/tests/evaluation/model-evals/codexAgentEval.test.ts
index d96265202..6b46d9b1d 100644
--- a/tests/evaluation/model-evals/codexAgentEval.test.ts
+++ b/tests/evaluation/model-evals/codexAgentEval.test.ts
@@ -134,6 +134,114 @@ describe("Codex agent release evaluation", () => {
});
});
+describe("Codex completed tool call parsing", () => {
+ it("does not let an unfinished direct event reserve a completed call identity", () => {
+ const evidence = observation();
+ const metrics = evaluateCodexEvents(
+ [
+ {
+ type: "mcp_tool_call",
+ id: "same-call",
+ status: "in_progress",
+ server: "rea",
+ tool: evidence.operation,
+ arguments: {},
+ },
+ {
+ type: "item.completed",
+ item: {
+ id: "same-call",
+ type: "mcp_tool_call",
+ status: "completed",
+ server: "rea",
+ tool: evidence.operation,
+ arguments: {},
+ result: { structuredContent: evidence },
+ },
+ },
+ {
+ type: "item.completed",
+ item: {
+ type: "agent_message",
+ text: `Observed Evidence ${evidence.evidence_id} supports the artifact claim; runtime behavior remains unknown without runtime authority.`,
+ },
+ },
+ ],
+ evidence.operation,
+ { requireEvidence: true },
+ );
+
+ expect(metrics).toMatchObject({
+ naturalUse: true,
+ correctFirstTool: true,
+ reaCalls: [{ id: "same-call" }],
+ evidenceIds: [evidence.evidence_id],
+ finalCitesEvidence: true,
+ answerHeuristicsMet: true,
+ });
+ });
+
+ it.each([
+ { server: "rea", server_name: "other" },
+ { tool: "inspect_managed_artifact", name: "other_tool" },
+ { server: 42 },
+ { arguments: { value: 1 }, input: { value: 2 } },
+ ])(
+ "rejects completed calls with invalid or conflicting fields: %j",
+ (aliases) => {
+ const evidence = observation();
+ const metrics = evaluateCodexEvents(
+ [
+ {
+ type: "item.completed",
+ item: {
+ type: "mcp_tool_call",
+ status: "completed",
+ server: "rea",
+ tool: evidence.operation,
+ arguments: {},
+ result: { structuredContent: evidence },
+ ...aliases,
+ },
+ },
+ ],
+ evidence.operation,
+ { requiredToolSubsequence: [evidence.operation] },
+ );
+
+ expect(metrics).toMatchObject({
+ naturalUse: false,
+ correctFirstTool: false,
+ requiredToolSubsequenceMet: false,
+ evidenceIds: [],
+ });
+ },
+ );
+
+ it("preserves legacy completed calls with no server field", () => {
+ const metrics = evaluateCodexEvents(
+ [
+ {
+ type: "item.completed",
+ item: {
+ type: "mcp_tool_call",
+ status: "completed",
+ tool: "inspect_managed_artifact",
+ arguments: {},
+ },
+ },
+ ],
+ "inspect_managed_artifact",
+ );
+
+ expect(metrics).toMatchObject({
+ naturalUse: true,
+ correctFirstTool: true,
+ reaCalls: [{ server: null }],
+ });
+ });
+});
+
describe("Codex Evidence delivery", () => {
it("rejects diagnostic IDs and failed results as delivered Evidence", () => {
const evidenceId = `ev_${"a".repeat(64)}`;
@@ -524,28 +632,32 @@ describe("Codex agent completion evaluation", () => {
});
});
- it("recognizes Codex MCP failed status when the error field is null", () => {
- const metrics = evaluateCodexEvents(
- [
- {
- type: "item.completed",
- item: {
- id: "item-failed",
- type: "mcp_tool_call",
- server: "rea",
- tool: "analyze_javascript_application",
- arguments: { input_path: "/tmp/app.asar" },
- result: { structured_content: { error: { code: "denied" } } },
- error: null,
- status: "failed",
- },
- },
- ],
- "analyze_javascript_application",
- );
+ it.each(["item.completed", "mcp_tool_call"] as const)(
+ "counts a failed completed invocation from %s without delivering Evidence",
+ (type) => {
+ const item = {
+ id: "item-failed",
+ type: "mcp_tool_call",
+ server: "rea",
+ tool: "analyze_javascript_application",
+ arguments: { input_path: "/tmp/app.asar" },
+ result: { structured_content: { error: { code: "denied" } } },
+ error: null,
+ status: "failed",
+ };
+ const metrics = evaluateCodexEvents(
+ [type === "item.completed" ? { type, item } : item],
+ "analyze_javascript_application",
+ );
- expect(metrics.reaCalls[0]?.error).toBe(true);
- });
+ expect(metrics).toMatchObject({
+ naturalUse: true,
+ correctFirstTool: true,
+ evidenceIds: [],
+ reaCalls: [{ error: true }],
+ });
+ },
+ );
});
describe("Codex agent workflow evaluation", () => {
From 16f5ded4468d7100d3640f874f33ddbaa953a787 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:12:17 +0800
Subject: [PATCH 023/159] test(cli): consolidate development runner workflow
coverage
---
tests/boundary/cli/developmentTests.test.ts | 243 ++++++++++++------
.../filesystem/developmentTests.test.ts | 110 --------
2 files changed, 159 insertions(+), 194 deletions(-)
delete mode 100644 tests/boundary/filesystem/developmentTests.test.ts
diff --git a/tests/boundary/cli/developmentTests.test.ts b/tests/boundary/cli/developmentTests.test.ts
index 21d8c5127..2987206c8 100644
--- a/tests/boundary/cli/developmentTests.test.ts
+++ b/tests/boundary/cli/developmentTests.test.ts
@@ -1,100 +1,175 @@
-import { execFile } from "node:child_process";
-import { mkdir, symlink, writeFile } from "node:fs/promises";
-import { join, resolve } from "node:path";
-import { promisify } from "node:util";
+import {
+ copyFile,
+ mkdir,
+ readFile,
+ symlink,
+ writeFile,
+} from "node:fs/promises";
+import { join } from "node:path";
import { describe, expect, it } from "vitest";
+import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
-const execute = promisify(execFile);
-const runner = resolve("scripts/run-development-tests.mjs");
-
-describe("development test runner", () => {
- it("rejects nonexistent explicit paths instead of returning a zero-test success", async () => {
- await expect(
- execute(process.execPath, [
- runner,
- "focused",
- "src/missing.test.ts",
- "--dry-run",
- ]),
- ).rejects.toMatchObject({ code: 1 });
+const run = (root: string, command: string, arguments_: readonly string[]) =>
+ execFileOutput(command, arguments_, {
+ cwd: root,
+ stopSignal: "SIGTERM",
+ timeout: 30_000,
+ maxBuffer: 1024 * 1024,
});
- it("reports actionable remediation when the comparison base is unavailable", async () => {
- await expect(
- execute(process.execPath, [
- runner,
- "changed",
- "--base",
- "missing-test-base",
- "--dry-run",
+const git = (root: string, ...arguments_: string[]) =>
+ run(root, "git", arguments_);
+
+const commit = (root: string, message: string) =>
+ git(
+ root,
+ "-c",
+ "user.name=REA fixture",
+ "-c",
+ "user.email=fixture@example.test",
+ "-c",
+ "commit.gpgSign=false",
+ "commit",
+ "--quiet",
+ "-m",
+ message,
+ );
+
+const fixture = async () => {
+ const root = await createTestTempDirectory("rea-development-tests-");
+ await mkdir(join(root, "scripts", "lib"), { recursive: true });
+ await mkdir(join(root, "src"));
+ for (const path of [
+ "scripts/run-development-tests.mjs",
+ "scripts/lib/development-tests.mjs",
+ ])
+ await copyFile(join(process.cwd(), path), join(root, path));
+ await symlink(
+ join(process.cwd(), "node_modules"),
+ join(root, "node_modules"),
+ process.platform === "win32" ? "junction" : undefined,
+ );
+ await writeFile(join(root, "package.json"), '{"type":"module"}\n');
+ await writeFile(join(root, ".gitignore"), "node_modules\nselected.txt\n");
+ await writeFile(
+ join(root, "vitest.config.mjs"),
+ 'export default { test: { projects: [{ test: { name: "domain", include: ["src/**/*.test.ts"] } }] } };\n',
+ );
+ await writeFile(join(root, "src/value.ts"), "export const value = 1;\n");
+ await writeFile(
+ join(root, "src/value.test.ts"),
+ 'import { expect, it } from "vitest"; import { writeFileSync } from "node:fs"; import { value } from "./value.js"; it("checks the selected source", () => { expect(value).toBe(1); writeFileSync("selected.txt", String(value)); });\n',
+ );
+ await git(root, "init", "--quiet", "-b", "main");
+ await git(root, "add", ".");
+ await commit(root, "fixture");
+ return root;
+};
+
+const testCommand = (root: string, ...arguments_: string[]) =>
+ run(root, process.execPath, [
+ "scripts/run-development-tests.mjs",
+ ...arguments_,
+ ]);
+
+describe("development test runner public CLI", () => {
+ it("runs an explicitly selected source test on a clean tree without build artifacts", async () => {
+ const root = await fixture();
+ const { stdout: status } = await git(root, "status", "--porcelain");
+ expect(status).toBe("");
+ await testCommand(root, "local", "src/value.test.ts");
+ expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1");
+ });
+
+ it("plans an exact local test without building", async () => {
+ const root = await fixture();
+ const { stdout } = await testCommand(
+ root,
+ "local",
+ "src/value.test.ts",
+ "--dry-run",
+ );
+ expect(JSON.parse(stdout)).toMatchObject({
+ needsBuild: false,
+ artifactTasks: [],
+ vitestArguments: expect.arrayContaining([
+ "run",
+ "--project",
+ "domain",
+ "--project",
+ "services",
+ "--project",
+ "adapters",
+ "--project",
+ "composition",
+ "--project",
+ "conformance",
+ "--project",
+ "evaluation",
+ "src/value.test.ts",
]),
+ });
+ });
+
+ it("uses committed dependency changes to select source tests and reports failures", async () => {
+ const root = await fixture();
+ const { stdout: base } = await git(root, "rev-parse", "HEAD");
+ await writeFile(join(root, "src/value.ts"), "export const value = 2;\n");
+ await git(root, "add", "src/value.ts");
+ await commit(root, "change dependency");
+ await expect(
+ testCommand(root, "changed", "--base", base.trim()),
+ ).rejects.toMatchObject({
+ code: 1,
+ stdout: expect.stringContaining("src/value.test.ts"),
+ stderr: expect.stringContaining("expected 2 to be 1"),
+ });
+ });
+
+ it("runs changed and focused tests from a committed branch regression", async () => {
+ const root = await fixture();
+ const { stdout: baseline } = await git(root, "rev-parse", "HEAD");
+ await git(root, "update-ref", "refs/remotes/origin/main", baseline.trim());
+ await git(root, "switch", "--quiet", "-c", "regression");
+ await writeFile(join(root, "src/value.ts"), "export const value = 2;\n");
+ await writeFile(
+ join(root, "src/value.test.ts"),
+ 'import { expect, it } from "vitest"; import { value } from "./value.js"; it("checks current source", () => expect(value).toBe(2));\n',
+ );
+ await git(root, "add", "src");
+ await commit(root, "regression fix");
+ expect((await git(root, "status", "--porcelain")).stdout).toBe("");
+
+ const changed = await testCommand(root, "changed");
+ expect(changed.stdout).toContain("1 passed");
+ const focused = await testCommand(root, "focused", "src/value.test.ts");
+ expect(focused.stdout).toContain("1 passed");
+ }, 60_000);
+
+ it("reports a missing comparison base and rejects missing or compiled-boundary paths", async () => {
+ const root = await fixture();
+ await expect(
+ testCommand(root, "changed", "--base", "missing-test-base", "--dry-run"),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"Fetch origin/main or pass --base REVISION",
),
});
- });
-
- it("runs a committed branch regression through real Git and Vitest without dist", async () => {
- const root = await createTestTempDirectory("rea-development-tests-");
- await mkdir(join(root, "src"));
- await symlink(
- resolve("node_modules"),
- join(root, "node_modules"),
- "junction",
- );
- await writeFile(
- join(root, "package.json"),
- JSON.stringify({ type: "module" }),
- );
- await writeFile(join(root, ".gitignore"), "node_modules\n");
- await writeFile(
- join(root, "vitest.config.ts"),
- `export default { test: { projects: [{ test: { name: "domain", include: ["src/**/*.test.ts"] } }] } };`,
- );
- await writeFile(join(root, "src/value.ts"), "export const value = 1;\n");
- await writeFile(
- join(root, "src/value.test.ts"),
- `import { it, expect } from "vitest"; import { value } from "./value"; it("checks current source", () => expect(value).toBe(1));\n`,
- );
- const git = (arguments_: string[]) =>
- execute("git", arguments_, { cwd: root });
- await git(["init", "-b", "main"]);
- await git(["add", ".gitignore", "package.json", "vitest.config.ts", "src"]);
- const commit = [
- "-c",
- "user.name=REA fixture",
- "-c",
- "user.email=fixture@example.test",
- "commit",
- "-m",
- "fixture",
- ];
- await git(commit);
- const { stdout: baseline } = await git(["rev-parse", "HEAD"]);
- await git(["update-ref", "refs/remotes/origin/main", baseline.trim()]);
- await git(["switch", "-c", "regression"]);
- await writeFile(join(root, "src/value.ts"), "export const value = 2;\n");
- await writeFile(
- join(root, "src/value.test.ts"),
- `import { it, expect } from "vitest"; import { value } from "./value"; it("checks current source", () => expect(value).toBe(2));\n`,
- );
- await git(["add", "src"]);
- await git(commit);
- expect((await git(["status", "--porcelain"])).stdout).toBe("");
- const { stdout } = await execute(process.execPath, [runner, "changed"], {
- cwd: root,
+ await expect(
+ testCommand(root, "focused", "src/missing.test.ts"),
+ ).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining("ENOENT"),
});
- expect(stdout).toContain("1 passed");
- const explicit = await execute(
- process.execPath,
- [runner, "focused", "src/value.test.ts"],
- { cwd: root },
- );
- expect(explicit.stdout).toContain("1 passed");
- }, 30_000);
+ await expect(
+ testCommand(root, "local", "tests/acceptance/runtime.test.ts"),
+ ).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining("Use test:focused"),
+ });
+ });
});
diff --git a/tests/boundary/filesystem/developmentTests.test.ts b/tests/boundary/filesystem/developmentTests.test.ts
deleted file mode 100644
index 1b52bb475..000000000
--- a/tests/boundary/filesystem/developmentTests.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import {
- copyFile,
- mkdir,
- readFile,
- symlink,
- writeFile,
-} from "node:fs/promises";
-import { join } from "node:path";
-
-import { expect, it } from "vitest";
-
-import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
-import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
-
-const run = (root: string, command: string, arguments_: readonly string[]) =>
- execFileOutput(command, arguments_, {
- cwd: root,
- stopSignal: "SIGTERM",
- timeout: 20_000,
- });
-
-const commit = (root: string, message: string) =>
- run(root, "git", [
- "-c",
- "user.name=REA fixture",
- "-c",
- "user.email=fixture@example.invalid",
- "-c",
- "commit.gpgSign=false",
- "commit",
- "--quiet",
- "-m",
- message,
- ]);
-
-const fixture = async () => {
- const root = await createTestTempDirectory("rea-development-tests-");
- await mkdir(join(root, "scripts", "lib"), { recursive: true });
- await mkdir(join(root, "src"));
- for (const path of [
- "scripts/run-development-tests.mjs",
- "scripts/lib/development-tests.mjs",
- ])
- await copyFile(join(process.cwd(), path), join(root, path));
- await symlink(
- join(process.cwd(), "node_modules"),
- join(root, "node_modules"),
- process.platform === "win32" ? "junction" : undefined,
- );
- await writeFile(join(root, "package.json"), '{"type":"module"}\n');
- await writeFile(join(root, ".gitignore"), "node_modules\nselected.txt\n");
- await writeFile(
- join(root, "vitest.config.mjs"),
- 'export default { test: { projects: [{ test: { name: "domain", include: ["src/**/*.test.ts"] } }] } };\n',
- );
- await writeFile(join(root, "src", "value.ts"), "export const value = 1;\n");
- await writeFile(
- join(root, "src", "value.test.ts"),
- 'import { expect, it } from "vitest"; import { writeFileSync } from "node:fs"; import { value } from "./value.js"; it("checks the selected source", () => { expect(value).toBe(1); writeFileSync("selected.txt", String(value)); });\n',
- );
- await run(root, "git", ["init", "--quiet"]);
- await run(root, "git", ["add", "."]);
- await commit(root, "fixture");
- return root;
-};
-
-const testCommand = (root: string, ...arguments_: string[]) =>
- run(root, process.execPath, [
- "scripts/run-development-tests.mjs",
- ...arguments_,
- ]);
-
-it("executes an explicitly selected source test on a clean tree without build artifacts", async () => {
- const root = await fixture();
- const { stdout: status } = await run(root, "git", ["status", "--porcelain"]);
- expect(status).toBe("");
- await testCommand(root, "local", "src/value.test.ts");
- expect(await readFile(join(root, "selected.txt"), "utf8")).toBe("1");
-});
-
-it("uses committed dependency changes since the requested Git base to select source tests", async () => {
- const root = await fixture();
- const { stdout: base } = await run(root, "git", ["rev-parse", "HEAD"]);
- await writeFile(join(root, "src", "value.ts"), "export const value = 2;\n");
- await run(root, "git", ["add", "src/value.ts"]);
- await commit(root, "change dependency");
- await expect(
- testCommand(root, "changed", "--base", base.trim()),
- ).rejects.toMatchObject({
- code: 1,
- stdout: expect.stringContaining("src/value.test.ts"),
- stderr: expect.stringContaining("expected 2 to be 1"),
- });
-});
-
-it("rejects a compiled boundary in the source-only command and a missing exact test", async () => {
- const root = await fixture();
- await expect(
- testCommand(root, "local", "tests/acceptance/runtime.test.ts"),
- ).rejects.toMatchObject({
- code: 1,
- stderr: expect.stringContaining("Use test:focused"),
- });
- await expect(
- testCommand(root, "local", "src/missing.test.ts"),
- ).rejects.toMatchObject({
- code: 1,
- stderr: expect.stringContaining("ENOENT"),
- });
-});
From 63e796d50ea323ae8dcfef80f86c8154bb106790 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:17:44 +0800
Subject: [PATCH 024/159] fix(process): bound retained trace ordering state
Prove total orders with Kahn traversal and use ranks; stream forward and reverse reachability for general partial orders instead of retaining every closure. Aggregate unordered conflicts by declaration while preserving missing-pair counts and independent malformed-input diagnostics.
Validation: 33 focused trace domain and CLI/MCP boundary tests passed. Six bounded Flameox executions preserved answer digests and cycle rejection; combined median peak RSS fell from 391.13 to 127.72 MiB. General partial-order traversal costs more CPU; the experiment records that tradeoff. Full suite and real-provider lanes were not run.
---
.../process-trace-validation.md | 72 ++++-
.../process/processTraceSpecification.test.ts | 123 ++++++-
.../process/processTraceSpecification.ts | 304 ++++++++++++------
3 files changed, 394 insertions(+), 105 deletions(-)
diff --git a/experiments/analysis-efficiency/process-trace-validation.md b/experiments/analysis-efficiency/process-trace-validation.md
index 0a4f9b0f6..a32848596 100644
--- a/experiments/analysis-efficiency/process-trace-validation.md
+++ b/experiments/analysis-efficiency/process-trace-validation.md
@@ -2,13 +2,19 @@
A 6,000-event directed cycle previously overflowed the JavaScript stack inside
the schema refinement. Cycle detection now uses iterative topological traversal.
-Reachability is computed once per queried origin; cyclic inputs retain
-independent declaration diagnostics without expanding all event pairs. Unordered
-declarations retain group membership, with one diagnostic per overlapping group,
-instead of allocating a pair set and repeating the same error for every pair.
-Valid partial orders still receive complete pair validation.
+Cyclic inputs retain independent declaration diagnostics without expanding all
+event pairs. Unordered declarations retain group membership, with one diagnostic
+per overlapping or conflicting group, instead of allocating a pair set and
+repeating the same error for every pair.
-## Bounded comparison
+A unique topological order proves that every event pair is ordered, allowing
+rank comparisons without retaining transitive closure. Other acyclic graphs
+retain only one origin's forward and backward reachability sets at a time.
+Complete pair validation preserves the missing count and first declaration-order
+missing pair. General partial orders still require quadratic pair examination
+and repeated graph traversal; they trade CPU time for bounded retained state.
+
+## Initial cycle fix comparison
Flameox ran three randomized paired blocks (seed 917), each limited to 20 seconds
and 512 MiB. Node 24.18.0 used a 384-MiB old-space limit on macOS arm64. Each
@@ -17,7 +23,8 @@ known 6,000-event cycle failure with per-event not-before constraints and one
unordered group containing every event. All six executions passed the embedded answer
digest and expected rejection checks.
-The baseline source is from `bb540826d`; the candidate includes this trace fix.
+The baseline source is from `bb540826d`; the candidate is the initial cycle fix
+in `10935b7b1`, which still memoized reachability sets for every queried origin.
Both modules had TypeScript annotations removed with Node's identical built-in
transform and used the same copied runtime dependencies. This isolates schema
validation rather than comparing complete packaged CLI/MCP processes.
@@ -44,8 +51,8 @@ the process but create fresh per-validation graph state. Whole-process elapsed
includes startup, imports, all four validations, the cyclic-input check, token
estimation and reporting. CPU includes startup through metrics publication;
peak RSS covers the complete single-process workload, with no children. Host
-caches were not flushed. Peak memory increased in this cohort. The remaining
-valid-order closure and pair validation can still require quadratic resources.
+caches were not flushed. Peak memory increased in this cohort. Its retained
+valid-order closure still required quadratic memory, prompting the follow-up.
Three pairs describe this fixture only.
Preserved Flameox evidence:
@@ -57,3 +64,50 @@ isolated runtime copy. Source-module SHA-256 values are
`a7729faa2183b0e493b9ac4fc55f6feaee5b2e5294be13d1b29b57a44b899788`
(baseline) and `725409ce84902125941251037a8a214ccc3c1eec577e484c164c361ca580d693`
(candidate).
+
+## Bounded retention follow-up
+
+Flameox compared `10935b7b1` with the rank/streaming implementation in three
+randomized paired blocks (seed 624), under the same host, Node, heap, memory and
+20-second execution bounds. Each execution validated the chain above four times,
+then a 1,200-event partial order with 600 unordered two-event layers and all four
+edges between consecutive layers four times, then the same cyclic failure.
+All six executions passed both parsed-answer digests and the cycle diagnostic.
+Runtime dependencies were copied and isolated from concurrent checkout builds.
+
+| Median measurement | Memoized closure | Rank/streaming |
+| ---------------------------------------- | ---------------: | -------------: |
+| Chain first elapsed milliseconds | 166.11 | 11.37 |
+| Chain warm elapsed milliseconds | 154.25 | 5.32 |
+| Chain first CPU milliseconds | 210.13 | 24.38 |
+| Chain warm CPU milliseconds | 184.76 | 16.84 |
+| Partial order first elapsed milliseconds | 49.44 | 116.54 |
+| Partial order warm elapsed milliseconds | 50.67 | 110.29 |
+| Partial order first CPU milliseconds | 72.47 | 137.69 |
+| Partial order warm CPU milliseconds | 70.65 | 121.77 |
+| Capture process elapsed milliseconds | 901.24 | 557.30 |
+| Process CPU milliseconds before report | 1,101.12 | 665.03 |
+| Peak process-family MiB | 391.13 | 127.72 |
+
+First samples exclude module import and fixture preparation. Partial-order first
+samples follow four chain validations, so they are first use of that fixture
+within an already running process. Warm medians pool the three subsequent samples
+from each of three processes. CPU and peak RSS cover the combined workload;
+capture elapsed additionally includes Flameox capture startup and overhead.
+Host caches were not flushed. The partial-order fixture is slower after this
+change; these results establish a retention tradeoff, not universal acceleration.
+
+The partial-order input is 165,590 JSON bytes, estimated at 52,783 tokens by tokenx
+1.3.0; its parsed-answer SHA-256 is
+`41d06e593813e497221c36793bf00d5fece7055081895b5f6c123ba6ad9a8227`.
+Chain bytes, token estimate and answer digest remain unchanged. Actual agent
+tokens, transport, catalog injection, model reasoning and provider costs remain
+unmeasured. Both cohorts are component measurements, not complete investigations.
+
+Preserved evidence:
+`e29086af99afd8e67cbbd92e43d960ea40f73b4d318ea0cbca60f493575462b8`;
+analysis `086133516f2acf031032d357a3ddb1ac611ce4f12556a24065a2becd567c5f7b`.
+The baseline module SHA-256 is
+`725409ce84902125941251037a8a214ccc3c1eec577e484c164c361ca580d693`;
+the candidate module SHA-256 is
+`2109f1096406e9ecac8bbf4f7ae8abb5113404790a9382487285d2831fcddf26`.
diff --git a/src/domain/process/processTraceSpecification.test.ts b/src/domain/process/processTraceSpecification.test.ts
index 9484e9075..ab649d2aa 100644
--- a/src/domain/process/processTraceSpecification.test.ts
+++ b/src/domain/process/processTraceSpecification.test.ts
@@ -95,8 +95,54 @@ describe("process trace specification", () => {
});
expect(result.language.kind).toBe("partial_order");
});
+});
- it("counts only undeclared pairs after transitive, not-before and unordered relations", () => {
+describe("process trace ordering validation", () => {
+ it("uses a unique topological order for transitive declarations and conflicts", () => {
+ const events = ["a", "b", "c"].map((id) => ({
+ id,
+ source: "lifecycle" as const,
+ exact: { marker: id },
+ cardinality: { kind: "optional" as const },
+ }));
+ const accepted = processTraceSpecificationSchema.safeParse({
+ events,
+ language: {
+ kind: "partial_order",
+ happens_before: [
+ { before: "a", after: "b" },
+ { before: "b", after: "c" },
+ ],
+ },
+ });
+ expect(accepted.success).toBe(true);
+
+ const rejected = processTraceSpecificationSchema.safeParse({
+ events,
+ language: {
+ kind: "partial_order",
+ happens_before: [
+ { before: "a", after: "b" },
+ { before: "b", after: "c" },
+ ],
+ not_before: [{ event: "a", anchor: "c" }],
+ unordered_groups: [{ events: ["a", "b", "c"] }],
+ },
+ });
+ if (rejected.success) throw new Error("Expected ordered pair conflicts");
+ expect(rejected.error.issues).toEqual([
+ expect.objectContaining({
+ path: ["language", "not_before", 0],
+ message: "not-before constraint conflicts with happens-before order",
+ }),
+ expect.objectContaining({
+ path: ["language", "unordered_groups", 0],
+ message: "ordered events cannot also be declared unordered",
+ }),
+ ]);
+ });
+
+ it("counts only undeclared pairs across transitive and disconnected relations", () => {
const events = ["a", "b", "c", "d"].map((id) => ({
id,
source: "lifecycle" as const,
@@ -150,7 +196,82 @@ describe("process trace specification", () => {
}),
]);
});
+});
+describe("malformed process trace declarations", () => {
+ it("preserves independent diagnostics when event ids are duplicated", () => {
+ const result = processTraceSpecificationSchema.safeParse({
+ events: [
+ {
+ id: "a",
+ source: "lifecycle",
+ exact: { fixture: "first" },
+ cardinality: { kind: "optional" },
+ },
+ {
+ id: "a",
+ source: "lifecycle",
+ exact: { fixture: "second" },
+ cardinality: { kind: "optional" },
+ },
+ ],
+ language: {
+ kind: "partial_order",
+ unordered_groups: [{ events: ["a", "b"] }],
+ },
+ });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("Expected a duplicate event id");
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ["events", 1, "id"],
+ message: "event ids must be unique",
+ }),
+ expect.objectContaining({
+ path: ["language", "unordered_groups", 0, "events", 1],
+ message: "unknown event id: b",
+ }),
+ ]),
+ );
+ });
+
+ it("keeps reachability diagnostics for unknown referenced successors", () => {
+ const result = processTraceSpecificationSchema.safeParse({
+ events: ["a", "b"].map((id) => ({
+ id,
+ source: "lifecycle",
+ exact: { fixture: id },
+ cardinality: { kind: "optional" },
+ })),
+ language: {
+ kind: "partial_order",
+ happens_before: [{ before: "a", after: "missing" }],
+ not_before: [{ event: "a", anchor: "missing" }],
+ },
+ });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("Expected an unknown event reference");
+ expect(result.error.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: ["language", "happens_before", 0, "after"],
+ message: "unknown event id: missing",
+ }),
+ expect.objectContaining({
+ path: ["language", "not_before", 0, "anchor"],
+ message: "unknown event id: missing",
+ }),
+ expect.objectContaining({
+ path: ["language", "not_before", 0],
+ message: "not-before constraint conflicts with happens-before order",
+ }),
+ ]),
+ );
+ });
+});
+
+describe("finite trace specification validation", () => {
it("rejects invalid ignores and unsatisfiable variants", () => {
const base = specification();
expect(
diff --git a/src/domain/process/processTraceSpecification.ts b/src/domain/process/processTraceSpecification.ts
index 8e4e6fca6..09c14cf10 100644
--- a/src/domain/process/processTraceSpecification.ts
+++ b/src/domain/process/processTraceSpecification.ts
@@ -105,7 +105,17 @@ export type ProcessTraceSpecification = z.infer<
typeof specificationShapeSchema
>;
type PartialOrderLanguage = z.infer;
-type Reachability = (origin: string) => ReadonlySet;
+
+type AcyclicGraphFacts =
+ | {
+ readonly kind: "total-order";
+ readonly rank: ReadonlyMap;
+ }
+ | {
+ readonly kind: "partial-order";
+ readonly reverseAdjacency: ReadonlyMap>;
+ };
+type GraphFacts = { readonly kind: "cyclic" } | AcyclicGraphFacts;
export const canonicalTraceJson = (value: unknown): string => {
const serialized = canonicalize(value);
@@ -195,38 +205,28 @@ const validateNotBefore = (
return pairs;
};
-const validateNotBeforeOrder = (
- language: PartialOrderLanguage,
- context: z.RefinementCtx,
- reachable: Reachability,
-): void => {
- for (const [index, constraint] of language.not_before.entries())
- if (reachable(constraint.event).has(constraint.anchor))
- context.addIssue({
- code: "custom",
- message: "not-before constraint conflicts with happens-before order",
- path: ["language", "not_before", index],
- });
-};
-
-const graphHasCycle = (
+const classifyGraph = (
ids: readonly string[],
adjacency: ReadonlyMap>,
-): boolean => {
+): GraphFacts => {
const incoming = new Map(ids.map((id) => [id, 0]));
+ let hasUnknownSuccessor = false;
for (const successors of adjacency.values())
for (const successor of successors) {
const count = incoming.get(successor);
- if (count !== undefined) incoming.set(successor, count + 1);
+ if (count === undefined) hasUnknownSuccessor = true;
+ else incoming.set(successor, count + 1);
}
const ready = [...incoming].flatMap(([id, count]) =>
count === 0 ? [id] : [],
);
- let examined = 0;
+ const order: string[] = [];
+ let unique = true;
while (ready.length > 0) {
+ if (ready.length !== 1) unique = false;
const current = ready.pop();
- if (current === undefined) break;
- examined++;
+ if (current === undefined) continue;
+ order.push(current);
for (const successor of adjacency.get(current) ?? []) {
const count = incoming.get(successor);
if (count === undefined) continue;
@@ -234,50 +234,67 @@ const graphHasCycle = (
if (count === 1) ready.push(successor);
}
}
- return examined !== incoming.size;
+ if (order.length !== incoming.size) return { kind: "cyclic" };
+ if (unique && order.length === ids.length && !hasUnknownSuccessor) {
+ return {
+ kind: "total-order",
+ rank: new Map(order.map((id, index) => [id, index])),
+ };
+ }
+ const reverseAdjacency = new Map(
+ [...incoming.keys()].map((id) => [id, new Set()]),
+ );
+ for (const [before, successors] of adjacency)
+ for (const after of successors) reverseAdjacency.get(after)?.add(before);
+ return { kind: "partial-order", reverseAdjacency };
};
-const createReachability = (
+const collectReachable = (
+ origin: string,
adjacency: ReadonlyMap>,
-): Reachability => {
- const reachable = new Map>();
- return (origin) => {
- const cached = reachable.get(origin);
- if (cached !== undefined) return cached;
- const seen = new Set();
- const pending = [...(adjacency.get(origin) ?? [])];
- while (pending.length > 0) {
- const current = pending.pop();
- if (current === undefined || seen.has(current)) continue;
- seen.add(current);
- for (const successor of adjacency.get(current) ?? [])
- pending.push(successor);
- }
- reachable.set(origin, seen);
- return seen;
- };
-};
-
-const isOrdered = (
- reachable: Reachability,
- left: string,
- right: string,
-): boolean => reachable(left).has(right) || reachable(right).has(left);
-
-type OrderingRelations = {
- readonly reachable: Reachability;
- readonly notBeforePairs: ReadonlySet;
+): ReadonlySet => {
+ const seen = new Set();
+ const pending = [...(adjacency.get(origin) ?? [])];
+ while (pending.length > 0) {
+ const current = pending.pop();
+ if (current === undefined || seen.has(current)) continue;
+ seen.add(current);
+ for (const successor of adjacency.get(current) ?? [])
+ pending.push(successor);
+ }
+ return seen;
};
const validateUnorderedGroups = (
language: PartialOrderLanguage,
context: z.RefinementCtx,
known: ReadonlySet,
-): ((left: string, right: string) => boolean) => {
+): {
+ readonly groups: readonly ReadonlySet[];
+ hasPair(left: string, right: string): boolean;
+ markDeclaredPairConflicts(
+ pairs: ReadonlySet,
+ conflicts: boolean[],
+ ): void;
+ markSharedGroups(left: string, right: string, conflicts: boolean[]): void;
+} => {
const memberships = new Map>();
+ const groups: Set[] = [];
+ const repeatedMemberships = new Map>();
for (const [groupIndex, group] of language.unordered_groups.entries()) {
const path = ["language", "unordered_groups", groupIndex];
- const uniqueIds = new Set(group.events);
+ const uniqueIds = new Set();
+ const repeatedIds = new Set();
+ for (const id of group.events) {
+ if (uniqueIds.has(id)) repeatedIds.add(id);
+ else uniqueIds.add(id);
+ }
+ groups.push(uniqueIds);
+ for (const id of repeatedIds) {
+ const groupIndexes = repeatedMemberships.get(id) ?? new Set();
+ groupIndexes.add(groupIndex);
+ repeatedMemberships.set(id, groupIndexes);
+ }
if (uniqueIds.size !== group.events.length)
context.addIssue({
code: "custom",
@@ -301,51 +318,121 @@ const validateUnorderedGroups = (
path,
});
}
- return (left, right) => {
+ const markSharedGroups = (
+ left: string,
+ right: string,
+ conflicts: boolean[],
+ ): void => {
+ if (left === right) {
+ for (const groupIndex of repeatedMemberships.get(left) ?? [])
+ conflicts[groupIndex] = true;
+ return;
+ }
const rightGroups = memberships.get(right);
for (const group of memberships.get(left) ?? [])
- if (rightGroups?.has(group) === true) return true;
- return false;
+ if (rightGroups?.has(group) === true) conflicts[group] = true;
+ };
+ return {
+ groups,
+ hasPair(left, right) {
+ if (left === right) return memberships.has(left);
+ const rightGroups = memberships.get(right);
+ for (const group of memberships.get(left) ?? [])
+ if (rightGroups?.has(group) === true) return true;
+ return false;
+ },
+ markDeclaredPairConflicts(pairs, conflicts) {
+ for (const key of pairs) {
+ const separator = key.indexOf("\0");
+ const left = key.slice(0, separator);
+ const right = key.slice(separator + 1);
+ markSharedGroups(left, right, conflicts);
+ }
+ },
+ markSharedGroups,
};
};
-const validateUnorderedOrder = (
+const validateTotalOrderRelations = (
language: PartialOrderLanguage,
+ graph: Extract,
+ unorderedGroups: ReturnType,
+ notBeforePairs: ReadonlySet,
context: z.RefinementCtx,
- relations: OrderingRelations,
): void => {
- for (const [groupIndex, group] of language.unordered_groups.entries())
- for (const [leftIndex, leftId] of group.events.entries()) {
- for (
- let rightIndex = leftIndex + 1;
- rightIndex < group.events.length;
- rightIndex++
- ) {
- const rightId = group.events[rightIndex];
- if (rightId === undefined) continue;
- const key = pairKey(leftId, rightId);
- if (
- isOrdered(relations.reachable, leftId, rightId) ||
- relations.notBeforePairs.has(key)
- )
- context.addIssue({
- code: "custom",
- message: "ordered events cannot also be declared unordered",
- path: ["language", "unordered_groups", groupIndex],
- });
+ for (const [index, constraint] of language.not_before.entries()) {
+ const before = graph.rank.get(constraint.event);
+ const after = graph.rank.get(constraint.anchor);
+ if (before !== undefined && after !== undefined && before < after)
+ context.addIssue({
+ code: "custom",
+ message: "not-before constraint conflicts with happens-before order",
+ path: ["language", "not_before", index],
+ });
+ }
+
+ const conflicts = new Array(language.unordered_groups.length).fill(
+ false,
+ );
+ for (const [groupIndex, group] of unorderedGroups.groups.entries()) {
+ let comparableEvents = 0;
+ for (const id of group)
+ if (graph.rank.has(id) && ++comparableEvents === 2) {
+ conflicts[groupIndex] = true;
+ break;
}
- }
+ }
+ unorderedGroups.markDeclaredPairConflicts(notBeforePairs, conflicts);
+ addUnorderedConflictIssues(conflicts, context);
};
-const validateCompletePairDeclarations = (
+const addUnorderedConflictIssues = (
+ conflicts: readonly boolean[],
+ context: z.RefinementCtx,
+): void => {
+ for (const [groupIndex, conflict] of conflicts.entries())
+ if (conflict)
+ context.addIssue({
+ code: "custom",
+ message: "ordered events cannot also be declared unordered",
+ path: ["language", "unordered_groups", groupIndex],
+ });
+};
+
+const validatePartialOrderRelations = (
ids: readonly string[],
- relations: OrderingRelations,
- unorderedPair: (left: string, right: string) => boolean,
+ language: PartialOrderLanguage,
+ adjacency: ReadonlyMap>,
+ graph: Extract,
+ unorderedGroups: ReturnType,
+ notBeforePairs: ReadonlySet,
context: z.RefinementCtx,
): void => {
let missingPairCount = 0;
let firstMissingPair = "";
+ const groupConflicts = new Array(
+ language.unordered_groups.length,
+ ).fill(false);
+ unorderedGroups.markDeclaredPairConflicts(notBeforePairs, groupConflicts);
+ const notBeforeConflictFlags = new Uint8Array(language.not_before.length);
+ const constraintsBySource = new Map();
+ for (const [index, constraint] of language.not_before.entries()) {
+ const indexes = constraintsBySource.get(constraint.event) ?? [];
+ indexes.push(index);
+ constraintsBySource.set(constraint.event, indexes);
+ }
+
+ // Keep only one origin's forward and backward closure live at a time. Pair
+ // iteration stays in declaration order so the count and first missing pair
+ // retain the established diagnostic semantics.
for (const [leftIndex, leftId] of ids.entries()) {
+ const forward = collectReachable(leftId, adjacency);
+ const backward = collectReachable(leftId, graph.reverseAdjacency);
+ for (const constraintIndex of constraintsBySource.get(leftId) ?? []) {
+ const constraint = language.not_before[constraintIndex];
+ if (constraint !== undefined && forward.has(constraint.anchor))
+ notBeforeConflictFlags[constraintIndex] = 1;
+ }
for (
let rightIndex = leftIndex + 1;
rightIndex < ids.length;
@@ -353,17 +440,32 @@ const validateCompletePairDeclarations = (
) {
const rightId = ids[rightIndex];
if (rightId === undefined) continue;
- if (isOrdered(relations.reachable, leftId, rightId)) continue;
+ const sameEvent = leftId === rightId;
+ const ordered =
+ !sameEvent && (forward.has(rightId) || backward.has(rightId));
const key = pairKey(leftId, rightId);
- if (
- !relations.notBeforePairs.has(key) &&
- !unorderedPair(leftId, rightId)
- ) {
+ const notBefore = notBeforePairs.has(key);
+ const unordered = unorderedGroups.hasPair(leftId, rightId);
+ if (ordered || notBefore) {
+ if (!sameEvent)
+ unorderedGroups.markSharedGroups(leftId, rightId, groupConflicts);
+ }
+ if (!ordered && !notBefore && !unordered) {
if (missingPairCount === 0) firstMissingPair = `${leftId}/${rightId}`;
missingPairCount++;
}
}
}
+
+ for (const [index, conflict] of notBeforeConflictFlags.entries())
+ if (conflict === 1)
+ context.addIssue({
+ code: "custom",
+ message: "not-before constraint conflicts with happens-before order",
+ path: ["language", "not_before", index],
+ });
+ addUnorderedConflictIssues(groupConflicts, context);
+
if (missingPairCount > 0)
context.addIssue({
code: "custom",
@@ -383,31 +485,43 @@ const validatePartialOrder = (
const ids = specification.events.map(({ id }) => id);
const known = new Set(ids);
const adjacency = buildAdjacency(specification.language, context, known);
- const cyclic = graphHasCycle(ids, adjacency);
- if (cyclic)
+ const graph = classifyGraph(ids, adjacency);
+ if (graph.kind === "cyclic")
context.addIssue({
code: "custom",
message: "happens-before graph must be acyclic",
path: ["language"],
});
- const reachable = createReachability(adjacency);
const notBeforePairs = validateNotBefore(
specification.language,
context,
known,
);
- const relations = { reachable, notBeforePairs };
- const unorderedPair = validateUnorderedGroups(
+ const unorderedGroups = validateUnorderedGroups(
specification.language,
context,
known,
);
// Preserve independent declaration diagnostics on cyclic input. Computing
// all pair relations cannot establish a valid partial order in that case.
- if (!cyclic) {
- validateNotBeforeOrder(specification.language, context, reachable);
- validateUnorderedOrder(specification.language, context, relations);
- validateCompletePairDeclarations(ids, relations, unorderedPair, context);
+ if (graph.kind === "total-order") {
+ validateTotalOrderRelations(
+ specification.language,
+ graph,
+ unorderedGroups,
+ notBeforePairs,
+ context,
+ );
+ } else if (graph.kind === "partial-order") {
+ validatePartialOrderRelations(
+ ids,
+ specification.language,
+ adjacency,
+ graph,
+ unorderedGroups,
+ notBeforePairs,
+ context,
+ );
}
for (const [kind, values] of [
["prefix", specification.language.prefix],
From 3c57e1a8b21b379fca55032d177078e1d8426e64 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:23:03 +0800
Subject: [PATCH 025/159] fix(artifacts): authenticate file subjects across
provider and view boundaries
Narrow WASM and EVM execution ports to file subjects and preserve supplied subject types at the shared execution factory. Authenticate format with path and digest before recording or projecting Evidence, using the existing WASM identity owner in both consumers.
Validation: focused WASM/EVM service and public MCP workflows passed, including malformed provider subjects and self-contained retained Evidence with contradictory format. Global typecheck/lint and documentation checks passed. Real WABT/Evmole execution and the full suite were not run.
---
src/application/AnalysisProvider.ts | 74 +++++++++++--------
src/application/evm/EvmInterfacePort.ts | 8 +-
.../evm/EvmInterfaceService.test.ts | 4 +-
src/application/evm/EvmInterfaceService.ts | 1 +
src/application/wasm/WasmArtifactPort.ts | 8 +-
src/domain/analysisView/evmInterfaceView.ts | 1 +
src/domain/analysisView/wasmArtifactView.ts | 1 +
src/domain/wasm/wasmArtifact.ts | 18 ++++-
src/evm/EvmoleInterfaceProvider.ts | 12 +--
src/wasm/WabtArtifactProvider.ts | 14 ++--
tests/boundary/mcp/analysisViewMcp.test.ts | 61 +++++++++++++++
tests/boundary/mcp/evmInterfaceMcp.test.ts | 24 ++++++
tests/boundary/mcp/wasmArtifactMcp.test.ts | 25 +++++++
tests/fixtures/evm/interface.ts | 8 +-
tests/fixtures/wasm/artifact.ts | 8 +-
15 files changed, 214 insertions(+), 53 deletions(-)
diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts
index cc17cac82..f3b51257a 100644
--- a/src/application/AnalysisProvider.ts
+++ b/src/application/AnalysisProvider.ts
@@ -54,6 +54,15 @@ export interface AnalysisExecution {
readonly subject: EvidenceSubjectTarget | null;
}
+interface CreateAnalysisExecutionOptions {
+ readonly rawResult?: unknown;
+ readonly analysisProfile?: AnalysisProfileCommitment;
+ readonly limitations?: readonly string[];
+ readonly relationshipCoverage?: RelationshipCoverage;
+ readonly locations?: readonly EvidenceLocation[];
+ readonly subject?: EvidenceSubjectTarget;
+}
+
/**
* Build a validated atomic provider observation at an adapter boundary.
*
@@ -61,38 +70,43 @@ export interface AnalysisExecution {
* `result`. Omitting it records `null` rather than a second complete copy of
* the normalized result in every Evidence record and tool response.
*/
-export const createAnalysisExecution = (
+export function createAnalysisExecution(
result: unknown,
provider: ProviderIdentity,
- options: {
- readonly rawResult?: unknown;
- readonly analysisProfile?: AnalysisProfileCommitment;
- readonly limitations?: readonly string[];
- readonly relationshipCoverage?: RelationshipCoverage;
- readonly locations?: readonly EvidenceLocation[];
- readonly subject?: EvidenceSubjectTarget;
- } = {},
-): AnalysisExecution => ({
- result: jsonValueSchema.parse(result),
- rawResult:
- options.rawResult === undefined
- ? null
- : jsonValueSchema.parse(options.rawResult),
- provider,
- ...(options.analysisProfile === undefined
- ? {}
- : { analysisProfile: structuredClone(options.analysisProfile) }),
- limitations: [...(options.limitations ?? [])],
- ...(options.relationshipCoverage === undefined
- ? {}
- : {
- relationshipCoverage: relationshipCoverageSchema.parse(
- options.relationshipCoverage,
- ),
- }),
- locations: [...(options.locations ?? [])],
- subject: options.subject ?? null,
-});
+ options: CreateAnalysisExecutionOptions & { readonly subject: Subject },
+): Omit & { readonly subject: Subject };
+export function createAnalysisExecution(
+ result: unknown,
+ provider: ProviderIdentity,
+ options?: CreateAnalysisExecutionOptions,
+): AnalysisExecution;
+export function createAnalysisExecution(
+ result: unknown,
+ provider: ProviderIdentity,
+ options: CreateAnalysisExecutionOptions = {},
+): AnalysisExecution {
+ return {
+ result: jsonValueSchema.parse(result),
+ rawResult:
+ options.rawResult === undefined
+ ? null
+ : jsonValueSchema.parse(options.rawResult),
+ provider,
+ ...(options.analysisProfile === undefined
+ ? {}
+ : { analysisProfile: structuredClone(options.analysisProfile) }),
+ limitations: [...(options.limitations ?? [])],
+ ...(options.relationshipCoverage === undefined
+ ? {}
+ : {
+ relationshipCoverage: relationshipCoverageSchema.parse(
+ options.relationshipCoverage,
+ ),
+ }),
+ locations: [...(options.locations ?? [])],
+ subject: options.subject ?? null,
+ };
+}
/** Provider-neutral application capability for one analysis operation. */
export interface AnalysisOperationPort {
diff --git a/src/application/evm/EvmInterfacePort.ts b/src/application/evm/EvmInterfacePort.ts
index e9e29b756..a297e092c 100644
--- a/src/application/evm/EvmInterfacePort.ts
+++ b/src/application/evm/EvmInterfacePort.ts
@@ -4,13 +4,19 @@ import type {
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { InspectEvmInterfaceInput } from "../../domain/evm/evmInterface.js";
+import type { EvidenceSubjectTarget } from "../../domain/evidence.js";
import type { Result } from "../../domain/result.js";
+/** Successful EVM interface recovery must identify the selected local file. */
+export type EvmInterfaceExecution = Omit & {
+ readonly subject: EvidenceSubjectTarget & { readonly format: "file" };
+};
+
/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */
export interface EvmInterfacePort {
close?(): Promise;
inspect(
input: InspectEvmInterfaceInput,
options?: ExecutionOptions,
- ): Promise>;
+ ): Promise>;
}
diff --git a/src/application/evm/EvmInterfaceService.test.ts b/src/application/evm/EvmInterfaceService.test.ts
index 08cf15487..03f501742 100644
--- a/src/application/evm/EvmInterfaceService.test.ts
+++ b/src/application/evm/EvmInterfaceService.test.ts
@@ -1,5 +1,6 @@
import { expect, it } from "vitest";
import { EvmInterfaceService } from "./EvmInterfaceService.js";
+import type { EvmInterfaceExecution } from "./EvmInterfacePort.js";
import {
evmInterfaceExecution,
evmInterfaceFixture,
@@ -43,7 +44,8 @@ it.each(["carrier", "encoding", "digest", "no-subject"])(
},
};
const service = new EvmInterfaceService({
- inspect: () => Promise.resolve(ok(execution)),
+ inspect: () =>
+ Promise.resolve(ok(execution as unknown as EvmInterfaceExecution)),
});
expect(
await service.inspect({
diff --git a/src/application/evm/EvmInterfaceService.ts b/src/application/evm/EvmInterfaceService.ts
index eac1986d3..86ac77865 100644
--- a/src/application/evm/EvmInterfaceService.ts
+++ b/src/application/evm/EvmInterfaceService.ts
@@ -67,6 +67,7 @@ export class EvmInterfaceService {
!report.success ||
report.data.artifact.path !== input.data.path ||
report.data.artifact.encoding !== input.data.encoding ||
+ execution.subject?.format !== "file" ||
execution.subject?.path !== input.data.path ||
execution.subject.sha256 !== report.data.artifact.sha256
)
diff --git a/src/application/wasm/WasmArtifactPort.ts b/src/application/wasm/WasmArtifactPort.ts
index 6cf6de352..90daab4b1 100644
--- a/src/application/wasm/WasmArtifactPort.ts
+++ b/src/application/wasm/WasmArtifactPort.ts
@@ -4,8 +4,14 @@ import type {
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { InspectWasmArtifactInput } from "../../domain/wasm/wasmArtifact.js";
+import type { EvidenceSubjectTarget } from "../../domain/evidence.js";
import type { Result } from "../../domain/result.js";
+/** Successful WASM inspection must identify the selected local file. */
+export type WasmArtifactExecution = Omit & {
+ readonly subject: EvidenceSubjectTarget & { readonly format: "file" };
+};
+
/** Replaceable offline WASM inspection engine, independent of target execution and network clients. */
export interface WasmArtifactPort {
/** Retry removal of retained workspaces; failures preserve ownership for another attempt. */
@@ -13,5 +19,5 @@ export interface WasmArtifactPort {
inspect(
input: InspectWasmArtifactInput,
options?: ExecutionOptions,
- ): Promise