diff --git a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md
index 90cf08eeb3..fe542fa90d 100644
--- a/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md
+++ b/.agents/skills/reverse-engineer-anything/references/evidence-workflows.md
@@ -104,3 +104,14 @@ each plan states why in its `reason`. It replays the capture instead with
`--replay-only` or when the state is unavailable (not derivable, or a catalog
over the discovery budget), and its stopped document's `state` says which
applied.
+
+For a PE/ELF export question, `inspect_native_exports` accepts `detail: "summary"`
+and retains the complete table. Query `inspect_analysis_view` with
+`view: {kind: "native-exports", collection: "exports", names: ["selected_name"],
+fields: ["name"], offset: 0, limit: 32}` and the reported parent Evidence ID.
+All same-name versions and aliases survive; use `source_index` with the parent ID
+for stable identity. `collection: "issues"` pages interpretation failures. Page
+coverage describes selected retained rows; `summary.coverage` or `exports.coverage`
+still describes the source table, so an empty partial page does not prove absence.
+CLI `inspect-native-exports --detail summary --evidence_output PATH` saves the
+complete bundle for later `inspect-analysis-view --evidence-bundle PATH` requests.
diff --git a/docs/agent-environments.md b/docs/agent-environments.md
index 5d03d9fca2..a929c1abf8 100644
--- a/docs/agent-environments.md
+++ b/docs/agent-environments.md
@@ -777,11 +777,27 @@ two collections can reuse a live id, and an equal pair stays in that collection.
environment answers, a path parameter by the collection the live path names (a nested collection keeps its parent values, each decoded once, and a literal route segment stays), except a query value that pages a list, such as `limit`
or `page`. Each call is checked against the served tool's input schema first, as the MCP server checks it. After mapping, two clock values compare by format only (ISO-8601 strings, and epoch numbers under
`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms).
-Numeric array elements have no time-named field and remain literal measurements.
-Different string values compare as independently generated only at entity fields
-whose executed served write plan declares a unique token or UUID/token identity
-generator. UUID-like or token-like spelling alone does not establish generation;
-other business strings compare literally. The report counts each kind of normalised value.
+Numeric arrays retain that containing field's clock context through nested
+arrays; an object's own fields establish their own context. Numeric values at
+the body root, in root arrays, or under measurement fields remain literal.
+
+Generated strings normalize only where this confirmed, served write's entity
+response rules identify generation. A `unique` rule or UUID identity rule must
+still compare two UUIDs; a `renewed` rule applies only at its recorded pointers,
+with `*` matching actual array items. Token/sequence strings retain the existing
+format check: equal length of at least eight, the same character classes, and
+a digit. Numeric sequences continue to compare literally unless an ID mapping
+applies. String shape and names such as `token` or `sku` alone do not establish
+generation. Read and replay responses without these facts stay literal except
+for supported clocks and learned IDs. A missing write target replays an absence
+response and does not inherit the successful entity response's generation rules.
+Admitted generated values must also preserve aliases and distinct identities
+within that response. UUID identity uses the generator's case-insensitive
+matching; token and sequence strings retain their literal identity. This
+correspondence ends with the answer and is not inferred across calls.
+The report counts each kind of normalised
+value; the generation rules remain model-derived facts, rather than new
+observations of the application.
ISO clock offsets are accepted only within RFC 3339's `00`–`23` hour and `00`–`59` minute ranges.
Probe ordering retains every recorded fractional-second digit in ISO timestamps
with a four-digit year, `T`, and a `Z` or `±HH:MM` suffix. Equal instants use ordinal order. Other native-Date
diff --git a/docs/apk-signature.md b/docs/apk-signature.md
index f161969e2b..ffdcfc0e10 100644
--- a/docs/apk-signature.md
+++ b/docs/apk-signature.md
@@ -221,9 +221,9 @@ Not yet read: v4 `.apk.idsig` (a sibling file), verity (`0x0421`/`0x0423`/`0x042
## Bounds and errors
The archive is read once, bounded at 512 MiB, without following symlinks and
-with change detection. Every size and offset in the ZIP tail and signing block
-is checked against its containing section before use, including the complete
-central-directory extent before ZIP end records. A process-wide queue permits
+with change detection. ZIP tail offsets, the central-directory size and signing
+block spans used by the reader are checked against their containing sections,
+including the complete central-directory extent before ZIP end records. A process-wide queue permits
one active whole-file inspection and eight pending scans across provider
instances; pending scans retain no APK bytes and can be cancelled with their
original reason. Further requests receive a capacity error. The active slot
@@ -250,8 +250,17 @@ A file that is not a ZIP is refused as an unsupported target. A damaged ZIP
(local headers but no end-of-central-directory), a central directory that
cannot be walked or whose declared record count does not account for all of
its bytes, or a malformed signing block or signer is an input error naming the
-scheme and reason. Signer framing includes signature records, the public-key
-field and matching signed/outer v3 SDK ranges. Suffix bytes beyond known
+offset or scheme and reason. Zip64 tail damage includes a missing required
+locator, an impossible locator disk/count pair, a fixed end record that does
+not fit before its locator, or an incorrect end-record signature. These are
+input errors, not evidence of an unsupported format. Coherent multi-disk
+locator metadata and 64-bit offsets/counts/sizes beyond the reader's safe
+integer range remain unsupported. The locator offset is disk-relative, so
+multi-disk metadata is checked before applying local file bounds. This follows
+the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.15](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT);
+it does not establish full validation of Zip64 versions or extensible data.
+Signer framing includes signature records, the public-key field and matching
+signed/outer v3 SDK ranges. Suffix bytes beyond known
fields are counted as uninterpreted, as Android readers may ignore extensions;
their presence does not establish malformed signing.
When the central directory is not immediately followed by
diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index ddb1d785b4..6ab14e680c 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -38,7 +38,11 @@ graph TD
IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"]
KUNA["src/kuna/
BYO Kuna deep analysis candidate
discovery reads metadata; version probe on target open"]
IDA_MCP["External ida-pro-mcp
GUI proxy or database supervisor
upstream installation and engine lifecycle"]
+
+ KUNA["src/kuna/
BYO Kuna decompiler, a deep analysis candidate
discovery reads metadata only; version probe when a target opens"]
AUX["src/artifacts/ + src/native/
artifact inventory, bounded directory triage and extraction
format readers and native capabilities"]
+
+
BROWSER["src/browser/
CDP / Playwright capture
captured scripts and native module resolution
execution/: coverage, listener and source attribution"]
JS_CODEC["src/javascript/sourceMaps/
pinned upstream map codec
owned heap-limited Node process, complete point evidence"]
INSPECTOR["src/inspector/
passive V8 Inspector discovery and capture
script and target locations"]
@@ -50,12 +54,19 @@ graph TD
FIRMWARE["src/firmware/
Binwalk / Unblob adapters
owned processes, report normalization, private extraction"]
ANDROID["src/android/
Static APK adapter
owned SDK stdio, pinned headless JADX, normalization"]
REFERENCE["src/reference/
Filesystem reference-source reader
bounded traversal, validation, errors"]
+
+ ANDROID_TOOLS["src/adb/ + src/apktool/ + src/jeb/
BYO device, resource-decoding and JEB adapters
owned adb and Apktool processes; JEB engine client over MCP"]
+ CAPTURE_TOOLS["src/captures/ + src/pcap/ + src/signals/ + src/sqlite/
BYO tshark, pcap, sigrok and SQLite child inspection
selected captures, recordings and databases; owned processes"]
+ NATIVE_READERS["src/go/ + src/rust/ + src/il2cpp/ + src/flutter/ + src/gpu/
Bundled in-process binary metadata readers
Go build info, Rust dependencies, IL2CPP, Dart build ID, GPU code objects
src/compression/: shared codec-result helper for rust and gpu"]
+ FORMAT_READERS["src/model/ + src/python/ + src/erlang/ + src/electron/
Bundled in-process format readers
GGUF and safetensors headers, Python .pyc, Erlang BEAM, Electron fuses"]
+ RUNTIME["src/frida/ + src/cutter/
Frida instrumentation sessions and live Cutter bridge client"]
end
subgraph "Provider Process Foundation"
PROCESS["src/process/
owned process groups, private roots
deadlines, bounded file reads and diagnostics, cleanup"]
WINDOWS["src/windows/
Windows host boundary adapters
package loader, opaque handles, owned jobs and private runtimes"]
WINDOWS_NATIVE["native/windows/src/
Node-API implementation
NTFS admission, protected DACLs and atomic Job Object assignment"]
+ FILESYSTEM["src/filesystem/
owned file and directory handles
stable reads, staged output, caller paths"]
end
subgraph "Contracts Layer"
@@ -181,3 +192,23 @@ graph TD
APP -->|EVM interface port| EVM
EVM --> PROCESS
EVM --> DOMAIN
+ COMPOSITION --> KUNA
+ KUNA --> PROCESS
+ KUNA --> FILESYSTEM
+ KUNA --> DOMAIN
+ COMPOSITION --> ANDROID_TOOLS
+ ANDROID_TOOLS --> PROCESS
+ ANDROID_TOOLS --> DOMAIN
+ COMPOSITION --> CAPTURE_TOOLS
+ CAPTURE_TOOLS --> PROCESS
+ CAPTURE_TOOLS --> DOMAIN
+ COMPOSITION --> NATIVE_READERS
+ NATIVE_READERS --> DOMAIN
+ COMPOSITION --> FORMAT_READERS
+ FORMAT_READERS --> DOMAIN
+ SERVER --> RUNTIME
+ CLI --> RUNTIME
+ RUNTIME --> DOMAIN
+ PROCESS --> FILESYSTEM
+ AUX --> FILESYSTEM
+ APP --> FILESYSTEM
diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md
index 857cd53830..242949f5b0 100644
--- a/docs/binary-diagnostics.md
+++ b/docs/binary-diagnostics.md
@@ -340,7 +340,48 @@ rea inspect-native-exports ./runtimes/linux-x64/native/libexample.so --json
```
The complete Evidence identifies the stable artifact snapshot by path, byte
-count and SHA-256.
+count and SHA-256. Complete delivery remains the default. For a small initial
+MCP reply, select `detail: "summary"`; this retains the complete Evidence and
+returns artifact identity, table coverage and row counts. Use its
+`normalized_result.parent_evidence_id` with `inspect_analysis_view`:
+
+```json
+{
+ "source": {
+ "kind": "retained-evidence",
+ "evidence_id": "ev_"
+ },
+ "view": {
+ "kind": "native-exports",
+ "collection": "exports",
+ "names": ["selected_name"],
+ "fields": ["name"],
+ "offset": 0,
+ "limit": 32
+ }
+}
+```
+
+Names match exactly and preserve every same-name version or alias. Optional
+`indices` selects original row positions, intersecting a name filter when both
+are supplied; empty filters select nothing. `source_index` always remains and,
+with the parent ID, identifies the original export or issue row. Fields default
+to the complete row; an empty field list returns only its identity. ELF `name`
+selection also returns raw-byte and encoding qualifications. Fields from the
+other format are rejected. `collection: "issues"` pages the complete diagnostic
+records, with optional original indices. Offset and limit default to 0 and 64
+and apply to matching rows in original order.
+
+View `coverage` describes the selected page; `summary.coverage` or
+`exports.coverage` preserves source-table coverage. An exhausted or empty page
+does not make a partial source complete. Retained views never reopen the file.
+The CLI uses the same projector and requires a durable bundle for a summary:
+
+```sh
+rea inspect-native-exports ./libexample.so --detail summary --evidence_output ./exports.bundle.json --json
+# Pass the summary's parent ID and the same view as JSON to inspect-analysis-view,
+# together with --evidence-bundle ./exports.bundle.json.
+```
- PE rows reproduce the export directory: each used export-address-table slot
with its biased `ordinal` (`OrdinalBase` plus the slot index), one row per
diff --git a/docs/browser-observation.md b/docs/browser-observation.md
index dfcdd987c2..a910fbc66a 100644
--- a/docs/browser-observation.md
+++ b/docs/browser-observation.md
@@ -71,7 +71,7 @@ debugging client, not an REA-owned observing session. The selected page-session
collector does not collect worker-session requests, console events, or scripts,
and workers that disappeared before the snapshot may be missing; worker
coverage is therefore attach-limited. Missing ownership metadata is reported as
-`unknown_target_relationship` with unavailable worker attribution, separately
+`unattributed_target` with unavailable worker attribution, separately
from a proven foreign owner excluded as `out_of_target_scope`. A shared origin
alone establishes neither relationship.
@@ -462,6 +462,11 @@ Frames, resources, scripts, events, and workers outside the selected origin scop
Retained workers include validated opener-target and parent-frame IDs when CDP
provides them; these relationships do not expand the exact-origin boundary or
prove static module ownership.
+Workers without either relationship are withheld with an `unattributed_target`
+exclusion and `workers` in `unavailable_sections`: a same-origin URL does not
+establish which page owns a worker. A supplied relationship to another target or
+frame remains `out_of_target_scope`. Missing ownership also remains unknown when
+the worker URL is empty; withheld target details are not returned.
## Completeness and limits
diff --git a/docs/electron-observation.md b/docs/electron-observation.md
index c6469b15d0..c462b21f78 100644
--- a/docs/electron-observation.md
+++ b/docs/electron-observation.md
@@ -119,6 +119,11 @@ target, frame, script, and resource, a worker URL must resolve to a local
file before it is retained. Relationship IDs improve
attribution but do not prove which static module started a worker or that its
work completed.
+Missing opener and parent relationships leave worker ownership unknown. Such
+targets are withheld with an `unattributed_target` exclusion and unavailable
+`workers` coverage, even when their URL names a local file. Explicit foreign
+relationships remain `out_of_target_scope`; malformed supplied relationship IDs
+are `invalid_protocol_value`.
Inspection does not retain DOM values, execute renderer code, navigate, click,
invoke Electron IPC, close a target, or terminate the application.
diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md
index 5b9f667282..7793442282 100644
--- a/docs/il2cpp-metadata.md
+++ b/docs/il2cpp-metadata.md
@@ -214,9 +214,13 @@ assembly image. Each module record starts with its module name, its method
pointer count and its method pointer array. The reader finds, for every metadata
image, the single record whose name pointer reaches that image's name string and
whose count equals the image's method count. It then requires exactly one
-`codeGenModulesCount`/`codeGenModules` pair that lists exactly those records. No
-symbols are needed, so stripped builds work. A method's token row id selects its
-slot in the module's method pointer array. Within one image, each method must
+`codeGenModulesCount`/`codeGenModules` pair that lists exactly those records.
+`codeGenModulesCount` and module method counts own four bytes, followed by
+alignment padding before their eight-byte pointers. Unresolved loader writes
+overlapping those four bytes prevent the registration from being established;
+padding outside the field is neither required nor interpreted as part of its
+count. No symbols are needed, so stripped builds work. A method's token row id
+selects its slot in the module's method pointer array. Within one image, each method must
claim a distinct MethodDef token row; duplicate rows are refused before any
linked method table or seed output is published. Row ids may repeat across
different images, and distinct rows may still resolve to the same address when
@@ -275,17 +279,27 @@ The result adds:
metadata name (`TKey`). Nested generic arguments stay with the component
that declares them, for example `Game.Outer+Inner` or
`Game.Outer+Leaf` when the nested type adds no parameters. A trailing
- `&` marks a by-reference type. `name` is `null` only for type index `-1`.
+ `&` marks a by-reference type. `name` is `null` for type index `-1`, metadata
+ version 27 as described below, or an unresolved type-table slot. A dyld bind
+ or unresolved ELF relocation retains the type index without interpreting its
+ stored word as a resolved pointer. If an unresolved loader value occurs
+ inside a type graph, linking is refused with `unsupported-pointer-encoding`;
+ a concrete name is not synthesized from that placeholder.
`binary` also records `types_field` and `types_count`: the address of
`Il2CppMetadataRegistration.typesCount` and its value. That record is found as
the single place whose `fieldOffsetsCount` and `typeDefinitionsSizesCount` both
equal the metadata type-definition count and whose type table is file mapped.
Counts use their declared 32-bit width independently of alignment padding.
-The complete registration must have mapped coverage; trailing declared zero-fill
-fields do not need physical file bytes. All dereferenced pointers use the loader
-fixup interpretation: unresolved dyld bindings cannot supply concrete type names
-from their stored placeholders and native linking refuses an unavailable type record.
+The complete 128-byte registration must have unambiguous loader coverage. Its
+used fields through byte 112 must be file-backed; a trailing declared zero-fill
+range can cover the final unused fields. An unmapped or conflicting tail is
+refused. These layouts follow the versioned [Unity 2022.3.8f1 producer header](https://github.com/MlgmXyysd/libil2cpp/blob/master/libil2cpp/Unity_2022.3/2022.3.8f1/il2cpp-class-internals.h#L596-L637)
+(redistributed source) and the [AArch64 aggregate alignment rules](https://github.com/ARM-software/abi-aa/blob/2025Q1/aapcs64/aapcs64.rst#aggregates).
+Dyld pointer ownership comes from the parsed bind stream, as in Apple's
+[dyld analyzer](https://github.com/apple-oss-distributions/dyld/blob/dyld-1165.3/common/MachOAnalyzer.cpp#L1508-L1511), rather than the slot's placeholder word.
+The padding, zero-fill-tail and unresolved-type cases have authored regression
+fixtures; they do not add real Unity-build verification to the checks above.
Without `binary_path`, `binary` is `null` and the four binary collections are
empty in the complete producer report to indicate unrequested observations. Their summary counts, including state counts, are `null`, and native page requests report unavailable observations. This does not establish an empty native inventory. Missing native fields in historical Evidence remain unknown too.
diff --git a/docs/process-capture.md b/docs/process-capture.md
index 3f6353af2b..52f0ab4d29 100644
--- a/docs/process-capture.md
+++ b/docs/process-capture.md
@@ -246,7 +246,12 @@ A missing entry proves creation or deletion only when the corresponding
observation root's path enumeration was exhausted. Otherwise its effect has
`status: "unknown"`, the available before/after state, and a reason; a null
state in this variant means unobserved. Paths below symlinks that REA did not
-follow also remain unknown. Partial enumeration of one root does not erase
+follow also remain unknown. Each observed symlink records a
+`symlink_not_followed` enumeration omission with a null system code, including
+a stable selected root. The link's own metadata can remain unchanged while
+descendant coverage and filesystem comparison remain unknown. Physical
+parent-directory enumeration still proves unrelated sibling absence.
+Partial enumeration of one root does not erase
known effects in another, and unavailable content hashes do not by themselves
make path absence unknown.
@@ -275,6 +280,12 @@ denied or otherwise unavailable lookup records an `entry_unavailable` omission
for that path instead, and its root stays incomplete. Cancellation and
unexpected I/O failures still stop capture.
+An initial selected-root permission denial returns `access_denied` with the
+selected path, native `EACCES` or `EPERM`, and filesystem-read recovery. Capture
+does not launch the target. Earlier snapshot facts and the denied root's
+omission remain in the partial observation; unresolved read cleanup retains its
+own classification until its exact resources are released.
+
Terminal frame `data` is the comparison text after the selected normalization.
New captures also preserve `raw_data` when normalization changes a PTY chunk;
otherwise `data` is already the original text. Older captures may lack the
diff --git a/docs/reconstruction-coverage.md b/docs/reconstruction-coverage.md
index 2d4d132a82..f2f184ad14 100644
--- a/docs/reconstruction-coverage.md
+++ b/docs/reconstruction-coverage.md
@@ -64,3 +64,17 @@ fails closure even when another proof passes; unknown, unsupported or skipped
applicable outcomes keep it unknown. The result cites all applicable outcomes.
Proof array order does not establish freshness or supersession. A proof for
unrelated artifact digests does not contradict the selected boundary.
+
+## Function reconstruction claims
+
+`verify_reconstruction` evaluates declared claims over retained comparison
+Evidence. An `overall` function claim includes active residual unknowns scoped
+to any of that comparison's dimensions. For example, an open pseudocode
+question keeps the overall claim `unknown` and retains its unknown ID and
+recommended probe; an independent assembly claim can still pass. An unknown
+scoped only to `overall` does not block every individual dimension.
+
+Selection uses each unknown's latest revision, its comparison identity and
+Evidence references. Explicit unknown dependencies remain relevant across
+dimensions. Unknowns for another comparison and resolved heads do not become
+blockers merely because their source Evidence overlaps.
diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md
index 0eadabad81..5d8a98c55e 100644
--- a/docs/recorded-crashes.md
+++ b/docs/recorded-crashes.md
@@ -29,6 +29,12 @@ note interpretation; an unspecified OSABI alone does not prove OS origin.
from the decoder's `type` label. Source binding and semantic decisions use the
numeric identity; an architecture-specific display name does not establish
support for that segment's payload semantics.
+ Header `type` names follow the pinned parser's machine-specific projection:
+ `PT_AARCH64_ARCHEXT` and `PT_AARCH64_UNWIND` denote `0x70000000` and
+ `0x70000001` only for AArch64. The same codes on x86-64 remain numeric, as
+ does AArch64's `0x70000002` in pyelftools 0.33. These display projections
+ are checked against the architecture and source-bound `type_id`; they do not
+ decode architecture compatibility, unwind or MTE payloads.
- Every note's original identity, owner/descriptor bytes and source ranges,
including unfamiliar owners and note kinds.
CORE symbolic note labels apply only to the exact `CORE\0` owner. Other
@@ -113,8 +119,14 @@ GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test
program; subsequent inspection only reads the resulting core. Real acceptance
checks recorded threads, high registers, missing/malformed notes, opaque owners,
CLI/MCP parity, optional debugger context and historical PID collision with an
-owned live sentinel. An observed Node thread group must launch the exact ownership
-`ps` inventory before it can read that live process's `stat` or `status` for native
+owned live sentinel. Authored AArch64 auxiliary-header and numeric-unknown
+carriers also pass through the configured decoder and both public adapters;
+they are metadata fixtures, rather than captured AArch64 crashes. Their expected
+names follow [pyelftools 0.33's enums](https://github.com/eliben/pyelftools/blob/v0.33/elftools/elf/enums.py#L431-L435)
+and [machine-specific selection](https://github.com/eliben/pyelftools/blob/v0.33/elftools/elf/structs.py#L179-L184).
+The [AArch64 ELF ABI](https://github.com/ARM-software/abi-aa/blob/2025Q1/aaelf64/aaelf64.rst#program-header)
+defines their raw codes. An observed Node thread group must launch the exact
+ownership `ps` inventory before it can read that live process's `stat` or `status` for native
identity/UID facts. Only traced `CLONE_THREAD` members share this allowance;
exec-replaced threads, ordinary children, debugger and decoder processes cannot
use it. The purpose of stat/status reads within the same Node thread group
diff --git a/docs/signal-recordings.md b/docs/signal-recordings.md
index ab06cc2279..80a7a2c2ba 100644
--- a/docs/signal-recordings.md
+++ b/docs/signal-recordings.md
@@ -40,20 +40,24 @@ unrelated producer warnings do not change request status.
Omitting `input_format` requests sigrok's autodetection. REA uses an immutable
private copy whose filename retains the selected recording's final suffix,
including a dotfile basename such as `.csv`. Filename-based format detection
-receives that hint without exposing the
-caller's directory to sigrok. The operand is an explicit relative path, so a
-recording named `-` still selects the private file rather than sigrok stdin.
-Omitting `annotations`
-retains every annotation class. Both omitted selections are recorded as null.
+receives that hint without exposing the caller's directory to sigrok. The
+input operand is an explicit relative path within the private workspace,
+so a recording named `-` is opened as a file rather than
+[sigrok's stdin operand](https://github.com/sigrokproject/sigrok-cli/blob/sigrok-cli-0.7.2/input.c#L49-L75).
+Omitting `annotations` retains every annotation class. Both omitted selections
+are recorded as null.
If sigrok reports an explicitly selected input format as unknown, REA returns
`invalid_request` for `input_format`, retains the producer diagnostic, and
-advises correcting the format or omitting it for autodetection. An explicit
-`Unknown keyword` refusal naming a caller-selected format option also returns
-`invalid_request` for `input_format`, even when sigrok exits zero and produces
-fallback output. Output and diagnostics remain in `captured_output`; unrelated
-warnings remain diagnostics. A completed nonzero `--version` probe is a
-configuration refusal with its captured output and the selected sigrok/prlimit
-configuration guidance. See the
+advises correcting the format or omitting it for autodetection. The
+[tagged option parser](https://github.com/sigrokproject/sigrok-cli/blob/sigrok-cli-0.7.2/parsers.c#L387-L404)
+warns when an option key is unknown. When that diagnostic's key exactly matches
+a selected `input_format` option, REA returns `invalid_request`, even on exit zero.
+Fallback output and the diagnostic remain in `captured_output`; unrelated
+option warnings stay visible without rejecting the request. Matching keeps
+the complete diagnostic caption, including an LF inside a selected key;
+embedded warning substrings do not establish a refusal. A completed nonzero
+`--version` probe is a configuration refusal with its captured output and the
+selected sigrok/prlimit configuration guidance. See the
[upstream manual](https://sigrok.org/wiki/Sigrok-cli) for formats, decoder
stacking and annotation selectors. No arbitrary command flags are accepted.
diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md
index c9b50c00b7..c00818fd2d 100644
--- a/docs/web-network-captures.md
+++ b/docs/web-network-captures.md
@@ -171,7 +171,24 @@ fully qualified message type. All imports must be included. REA never fetches
schemas or executes the selected application. Descriptor identity and parser
version remain in the body facts. Scalars, repeated numeric packing and nested
messages are interpreted alongside the unchanged wire facts; disagreements
-remain explicit. Map semantics, extensions and required-field validation are
+remain explicit. Descriptor admission rejects duplicate field numbers within
+each message, including nested messages and oneof fields, before schema
+annotation. This does not reject repeated occurrences in captured wire bytes
+or reuse of a number in a different message. Fully qualified message, enum,
+service and extension names must also identify one declaration across the
+descriptor set, including nested and synthetic map-entry messages. Different
+packages or enclosing messages may reuse a local type name. Schema-admission
+failures retain the partial capture Evidence and its original artifact identity.
+REA also requires an unambiguous descriptor file identity. Repeated exact file
+names are coalesced only when protobuf-es equality confirms matching field
+presence, declared values and retained opaque unknown/custom-option fields.
+Opaque fields must match in their retained order and bytes; REA does not
+interpret them. Conflicting definitions are refused rather than selecting a
+file by its position. This is REA's supplied-schema admission policy, separate
+from protobuf wire validity and the [first-occurrence precedence in protoc v32.1](https://github.com/protocolbuffers/protobuf/blob/v32.1/src/google/protobuf/compiler/command_line_interface.cc#L1402).
+The descriptor-set byte count and SHA-256 continue to identify the complete
+original input, including repeated file facts.
+Map semantics, extensions and required-field validation are
unsupported. A supplied schema does not establish the sender's actual schema.
An invalid nested interpretation records `schema_status: "invalid-message"`
and `schema_error` locally; later outer fields remain available. Valid string
diff --git a/experiments/analysis-efficiency/native-export-costs.py b/experiments/analysis-efficiency/native-export-costs.py
new file mode 100644
index 0000000000..8b6111e0cd
--- /dev/null
+++ b/experiments/analysis-efficiency/native-export-costs.py
@@ -0,0 +1,115 @@
+"""Check equivalent export questions and count explicitly projected tokenizer costs."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import statistics
+
+import tiktoken
+
+
+def compact(value):
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
+
+
+def inspect(path, encoding):
+ report = json.loads((path / "report.json").read_text())
+ catalog = json.loads((path / "catalog.json").read_text())
+ transcript = json.loads((path / "transcript.json").read_text())
+ scope = next(json.loads(line) for line in reversed(path.with_suffix(".log").read_text().splitlines())
+ if line.startswith('{"resource_scope":'))
+ observed = scope.get("payload_resource_observation") or {}
+ events = observed.get("resource_events", {})
+ assert report["status"] == "passed" and report.get("runtime_identity_sha256")
+ assert not report["cleanup_failures"] and not report["receipt_failures"]
+ assert scope["abort"] is None and scope.get("payload_resource_observation_verified") is True
+ assert observed.get("payload_exit_code") == 0 and observed.get("payload_cleanup_verified") is True
+ assert all(events.get("memory.events", {}).get(key) == 0 for key in ["high", "max", "oom", "oom_kill"])
+ assert events.get("pids.events", {}).get("max") == 0
+ assert len(catalog["tools"]) == 220
+ count = lambda text: len(encoding.encode(text, disallowed_special=()))
+ directory = [{key: tool[key] for key in ["name", "description", "inputSchema"]} for tool in catalog["tools"]]
+ question = (f"For {report['artifact']['path']}, report the export count and every version/alias of "
+ f"{', '.join(dict.fromkeys(row['name'] for row in report['answer']['rows']))}, including addresses, "
+ "sizes, types, binding and symbol-version facts. Check that __rea_export_oracle_missing__ "
+ "has no recorded match, preserve source coverage and export the complete Evidence. "
+ "Report static declarations without claiming runtime execution.")
+ arguments = sum(count(compact({"name": event["name"], "arguments": event["input"]})) for event in transcript)
+ replies = sum(count(block["text"]) for event in transcript for block in event["reply"]["content"] if block["type"] == "text")
+ inputs = count(compact(directory))
+ raw_catalog = count(compact(catalog))
+ task_directory = [tool for tool in directory if tool["name"] in ["inspect_native_exports", "inspect_analysis_view", "export_evidence_bundle"]]
+ return {"case": path.name, "lane": report["lane"], "artifact": report["artifact"],
+ "answer": report["answer"], "parent_evidence_id": report["parent_evidence_id"],
+ "source_report_sha256": report["source_report_sha256"],
+ "runtime_identity_sha256": report["runtime_identity_sha256"],
+ "catalog_sha256": report["catalog_sha256"], "catalog_tools": len(catalog["tools"]),
+ "buffer_bytes": report["buffer_bytes"], "calls": report["calls"], "phases": report["phases"],
+ "wall_seconds": scope["elapsed_seconds"], "cpu_seconds": observed["cpu_stat"]["usage_usec"] / 1e6,
+ "kernel_family_peak_bytes": observed["memory_peak_bytes"],
+ "events_at_payload_exit": events, "payload_cleanup_verified": True,
+ "final_whole_scope_events": scope.get("resource_events_final"),
+ "token_proxy": {"question": count(question), "input_catalog_once": inputs, "arguments": arguments,
+ "text_replies": replies, "combined": count(question) + inputs + arguments + replies,
+ "only_three_task_tools": count(question) + count(compact(task_directory)) + arguments + replies,
+ "complete_transport_catalog": raw_catalog},
+ "receipt_sha256": {file.name: hashlib.sha256(file.read_bytes()).hexdigest() for file in
+ [path / "report.json", path / "catalog.json", path / "transcript.json", path / "evidence.json", path.with_suffix(".log")]}}
+
+
+def summarize(rows):
+ result = {}
+ for artifact in sorted({row["artifact"]["sha256"] for row in rows}):
+ selected = [row for row in rows if row["artifact"]["sha256"] == artifact]
+ assert len({row["parent_evidence_id"] for row in selected}) == 1
+ assert len({row["source_report_sha256"] for row in selected}) == 1
+ assert all(row["answer"] == selected[0]["answer"] for row in selected)
+ result[artifact] = {}
+ for lane in ["complete", "selected"]:
+ samples = [row for row in selected if row["lane"] == lane]
+ assert len(samples) == 3
+ metrics = {key: [row[key] for row in samples] for key in ["wall_seconds", "cpu_seconds", "kernel_family_peak_bytes"]}
+ metrics["projected_tokens"] = [row["token_proxy"]["combined"] for row in samples]
+ metrics["mcp_call_count"] = [len(row["calls"]) for row in samples]
+ metrics["mcp_reply_bytes"] = [sum(call["reply_bytes"] for call in row["calls"]) for row in samples]
+ result[artifact][lane] = {metric: {"median": statistics.median(values), "minimum": min(values), "maximum": max(values)} for metric, values in metrics.items()}
+ return result
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--cohort", type=Path, required=True)
+ parser.add_argument("cases", type=Path, nargs="+")
+ args = parser.parse_args()
+ manifest = json.loads((args.cohort / "manifest.json").read_text())
+ index = json.loads((args.cohort / "index.json").read_text())
+ assert len(index) == 12 and all(case["exit"] == 0 and case["status"] == "passed" for case in index)
+ assert {path.resolve() for path in args.cases} == {(args.cohort / case["name"]).resolve() for case in index}
+ assert len(args.cases) == len(index)
+ for file, digest in manifest["driver_files"].items():
+ assert hashlib.sha256(Path(file).read_bytes()).hexdigest() == digest, file
+ runtimes = {}
+ for case in index:
+ path = Path(case["runtime"]) / "runtime-identity.json"
+ data = path.read_bytes()
+ identity = json.loads(data)
+ assert identity["source_clean"] is True
+ runtimes[hashlib.sha256(data).hexdigest()] = {
+ "source_revision": identity["source_revision"],
+ "compiled_files": len(identity["compiled_files"]),
+ "entrypoints": len(identity["entrypoints"]),
+ }
+ encoding = tiktoken.get_encoding("cl100k_base")
+ rows = [inspect(path, encoding) for path in args.cases]
+ assert all(row["runtime_identity_sha256"] in runtimes for row in rows)
+ receipt = {"tiktoken": tiktoken.__version__, "encoding": encoding.name, "actual_model_usage": None,
+ "cohort": manifest, "commands": index, "runtime_identities": runtimes,
+ "projection": "Scripted SDK tasks, not an actual model run. The estimate includes a fixed task description, every discovered tool name/description/input schema once, every tool argument and text reply, including Evidence export. Complete raw SDK directory token costs and a three-tool-only alternative are reported separately; output schemas are not included in the primary model-context projection. Host injection, history replay, inference quality and billable usage are unmeasured.",
+ "resources": "CPU quota 100%; launcher binds clients/servers to CPUs 0-1 (the outer observer starts on CPU15). The guard receipt affinity is initial placement, not child affinity; a live cohort observation records the override. 3 GiB early stop/4 GiB hard/no swap. Fresh client/server/HOME per task, Node compile cache disabled, OS caches unflushed. Later retained pages are warm queries; no separate warm-server cohort. Whole-family costs include client/oracle comparisons, source hashing, runtime binding, serialization and receipts. Tokenizer processing itself is outside these measured tasks. Verified counters are at payload exit while the observer remains alive; unavailable final whole-scope counters remain null.",
+ "summary": summarize(rows), "recordings": rows}
+ args.output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/experiments/analysis-efficiency/native-export-oracle.py b/experiments/analysis-efficiency/native-export-oracle.py
new file mode 100644
index 0000000000..b0213e15d6
--- /dev/null
+++ b/experiments/analysis-efficiency/native-export-oracle.py
@@ -0,0 +1,48 @@
+"""Independent readelf symbol facts and directly mapped ELF64 version indices."""
+import hashlib
+import json
+from pathlib import Path
+import re
+import struct
+import subprocess
+import sys
+
+path = Path(sys.argv[1]).resolve()
+selected_names = sys.argv[2:]
+source = path.read_bytes()
+assert source[:6] == b'\x7fELF\x02\x01', 'This experiment oracle only covers ELF64 little-endian'
+section_offset = struct.unpack_from(' 0
+sections = [struct.unpack_from(' /absolute/path/oracle.json
+python3 experiments/analysis-efficiency/resource-run.py --observe-payload-exit -- \
+ node experiments/analysis-efficiency/native-export-views.mjs \
+ --runtime /absolute/path/runtime --identity /absolute/path/runtime/runtime-identity.json \
+ --oracle /absolute/path/oracle.json --output /absolute/path/unused-receipt-directory \
+ --lane selected --buffer 67108864
+```
+
+Use `--lane complete` against the baseline. Repeat three pairs in alternating
+order. The JSON receipt records every executed command, runtime binding,
+per-call timings/bytes, results and SHA-256 of raw reports/catalogs/transcripts/
+Evidence/resource logs. Its `cohort` manifest records driver and guard hashes.
+Generate token estimates with `native-export-costs.py --cohort COHORT --output
+OUTPUT CASE...`, using tiktoken 0.12.0. Raw local receipts are under
+`/tmp/rea-perf-native-exports-20261023/`; they are not committed binaries or
+portable artifact downloads. `selector-cohort/index.json` and `manifest.json` bind
+the twelve cases. Preserve these files together when rerunning the cost script.
+
+Executed functional validation of the measured implementation:
+
+- 48 selected tests passed: parser, export views, MCP schemas/retention preflight,
+ and actual public CLI/stdio workflows for PE and ELF fixtures. Source deletion
+ after retention still permits equivalent offline CLI and retained MCP views.
+ Sparse field checks also recompute the returned view digest in the native
+ regression and actual MCP acceptance workflow.
+- `npm run check`, documentation generation/check and test-discovery verification
+ passed; 2,180 tests were discovered exactly once.
+- Real LLVM public CLI summary → full Evidence bundle → offline selected view
+ was also checked; the complete bundle retains 44,459 exports.
+- Earlier assertion spelling mistakes, an architecture-boundary test placement
+ violation and a concurrent-worker timeout are retained in the local logs.
+ The test was moved to the native layer. A later field-omission regression
+ reproduced source defaults leaking into projected columns; the projection
+ schema now removes those defaults, while complete source normalization retains
+ them. Final bounded tests and static gates passed. No real Hopper/Ghidra/IDA behavior changed or was qualified here.
+
+Partial/unsupported source coverage, duplicate names/versions, PE ordinal-only
+exports, forwarded exports, field projection, malformed retained input and older
+encoding metadata are covered by deterministic boundary regressions. Those tests
+supplement the real ELF tasks; they are not real PE provider measurements.
+
+## Initial CI attribution
+
+[CI run 40015745440](https://github.com/morluto/rea/actions/runs/40015745440)
+tested head `5e9a17bea7a6bc5931df60256eef61010a9c67e1`, merge
+`e6f10a331aed5228504ed45ca6244b9498548cb9`, against main
+`f713087132021d235682f908a1c0f3bc6ab8762d`. Static checks, build, docs and selected
+real-format qualification jobs passed. Source tests and built tests failed.
+Every cancelled shard's log was inspected: 11 source and 46 built failures were
+actually observed, including the ADB floating-point regex rejected in `v` mode.
+
+Bounded serial controls compared that exact main base with implementation
+`c7a90b742a29328e5b0069c6f8a34381e0c11e57`. The source selection reproduced all
+11 failures on both. A broader built selection executed 52 cases on both, with
+48 failures and four passes; it includes every observed CI built failure. The
+two extra failures and four passes in this selection are not presented as CI
+observations. All selected case identities, statuses and full failure messages
+were identical after replacing only the two checkout roots, including the
+physical path of their shared dependency symlink. No assertion text was removed.
+All 57 actual CI failures were therefore reproduced on the base and candidate.
+
+Local `ci-attribution.json`, `ci-observed-case-identities.json`,
+`ci-control-index.json`, the four control reports and their resource logs under
+the receipt directory retain commands, pinned revisions, complete differences
+and SHA-256 bindings. Controls had no resource stop or OOM and verified payload
+cleanup. This attribution applies to that initial CI run and those revisions;
+later CI results must be inspected separately.
diff --git a/experiments/analysis-efficiency/native-export-views.mjs b/experiments/analysis-efficiency/native-export-views.mjs
new file mode 100644
index 0000000000..f2dec30f29
--- /dev/null
+++ b/experiments/analysis-efficiency/native-export-views.mjs
@@ -0,0 +1,224 @@
+/** Equivalent export-table questions over public production stdio MCP workflows. */
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { createWriteStream } from "node:fs";
+import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { finished } from "node:stream/promises";
+import { parseArgs } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
+import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
+
+const { values } = parseArgs({
+ options: {
+ runtime: { type: "string" },
+ oracle: { type: "string" },
+ output: { type: "string" },
+ lane: { type: "string" },
+ buffer: { type: "string", default: "67108864" },
+ identity: { type: "string" },
+ },
+});
+for (const key of ["runtime", "oracle", "output"])
+ assert.ok(values[key] && values[key] === resolve(values[key]), key);
+assert.ok(["complete", "selected"].includes(values.lane));
+const buffer = Number(values.buffer);
+assert.ok(Number.isSafeInteger(buffer) && buffer >= 10485760);
+const expected = JSON.parse(await readFile(values.oracle, "utf8"));
+const hash = (value) => createHash("sha256").update(value).digest("hex");
+const artifact = expected.artifact.path;
+assert.equal(hash(await readFile(artifact)), expected.artifact.sha256);
+await mkdir(values.output, { mode: 0o700 });
+const account = await mkdtemp(join(tmpdir(), "rea-export-views-"));
+const client = new Client({ name: "native-export-views", version: "1" });
+const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
+ maxBufferSize: buffer,
+ env: {
+ HOME: account,
+ TMPDIR: account,
+ PATH: process.env.PATH,
+ REA_LOG_LEVEL: "silent",
+ REA_MCP_MAX_RESPONSE_BYTES: String(buffer),
+ NODE_OPTIONS: "--max-old-space-size=2048",
+ NODE_DISABLE_COMPILE_CACHE: "1",
+ UV_THREADPOOL_SIZE: "1",
+ },
+ stderr: "pipe",
+});
+const serverLog = createWriteStream(join(values.output, "server.log"));
+const serverLogFinished = finished(serverLog);
+serverLogFinished.catch(() => {});
+transport.stderr?.pipe(serverLog);
+const report = {
+ lane: values.lane,
+ buffer_bytes: buffer,
+ artifact: expected.artifact,
+ calls: [],
+ phases: {},
+ status: "running",
+};
+const verifyRuntime = async (phase) => {
+ if (values.identity === undefined) return;
+ const started = performance.now();
+ const identityBytes = await readFile(values.identity);
+ const identity = JSON.parse(identityBytes);
+ for (const entry of [...identity.compiled_files, ...identity.entrypoints])
+ assert.equal(
+ hash(await readFile(join(values.runtime, entry.path))),
+ entry.sha256,
+ entry.path,
+ );
+ report.runtime_identity_sha256 ??= hash(identityBytes);
+ assert.equal(hash(identityBytes), report.runtime_identity_sha256);
+ report.phases[`runtime_validation_${phase}_ms`] = performance.now() - started;
+};
+const transcript = [];
+const call = createRetainedQueryCaller(client, (receipt, event) => {
+ report.calls.push(receipt);
+ transcript.push(event);
+});
+const observe = async (name, input, phase) => {
+ const reply = await call(name, input, phase);
+ if (reply.isError === true) assert.fail(JSON.stringify(reply));
+ assert.ok(reply.structuredContent);
+ return reply.structuredContent;
+};
+const choose = (source) =>
+ source.exports.flatMap((row, source_index) =>
+ expected.names.includes(row.name) ? [{ ...row, source_index }] : [],
+ );
+try {
+ await verifyRuntime("before");
+ let start = performance.now();
+ await client.connect(transport);
+ const catalog = await client.listTools();
+ report.phases.discovery_ms = performance.now() - start;
+ report.catalog_bytes = Buffer.byteLength(JSON.stringify(catalog));
+ report.catalog_sha256 = hash(JSON.stringify(catalog));
+ await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
+ const selected = values.lane === "selected";
+ const reply = await observe(
+ "inspect_native_exports",
+ { path: artifact, ...(selected ? { detail: "summary" } : {}) },
+ "inspect",
+ );
+ const parentId = selected
+ ? reply.normalized_result.parent_evidence_id
+ : reply.evidence_id;
+ report.parent_evidence_id = parentId;
+ let rows;
+ if (selected) {
+ rows = [];
+ let offset = 0;
+ do {
+ const page = (
+ await observe(
+ "inspect_analysis_view",
+ {
+ source: { kind: "retained-evidence", evidence_id: parentId },
+ view: {
+ kind: "native-exports",
+ collection: "exports",
+ names: expected.names,
+ offset,
+ limit: 2,
+ },
+ },
+ "page",
+ )
+ ).normalized_result;
+ assert.equal(page.exports.coverage.status, "complete");
+ assert.equal(page.coverage.total, expected.rows.length);
+ rows.push(...page.items);
+ offset = page.coverage.next_offset;
+ } while (offset !== null);
+ const empty = (
+ await observe(
+ "inspect_analysis_view",
+ {
+ source: { kind: "retained-evidence", evidence_id: parentId },
+ view: {
+ kind: "native-exports",
+ collection: "exports",
+ names: ["__rea_export_oracle_missing__"],
+ limit: 1,
+ },
+ },
+ "negative",
+ )
+ ).normalized_result;
+ assert.deepEqual(empty.items, []);
+ assert.equal(empty.exports.coverage.status, "complete");
+ assert.equal(empty.coverage.total, 0);
+ } else {
+ rows = choose(reply.normalized_result);
+ assert.equal(
+ reply.normalized_result.exports.some(
+ (row) => row.name === "__rea_export_oracle_missing__",
+ ),
+ false,
+ );
+ }
+ assert.deepEqual(rows, expected.rows);
+ await observe(
+ "export_evidence_bundle",
+ { path: join(values.output, "evidence.json") },
+ "export",
+ );
+ start = performance.now();
+ const bundle = JSON.parse(
+ await readFile(join(values.output, "evidence.json"), "utf8"),
+ );
+ const parent = bundle.records.find(
+ (record) => record.evidence_id === parentId,
+ );
+ assert.ok(parent);
+ assert.deepEqual(choose(parent.normalized_result), expected.rows);
+ assert.equal(
+ parent.normalized_result.coverage.exports,
+ expected.export_count,
+ );
+ assert.equal(parent.normalized_result.exports.length, expected.export_count);
+ assert.equal(parent.normalized_result.coverage.status, "complete");
+ assert.equal(parent.subject.digest.sha256, expected.artifact.sha256);
+ report.source_report_sha256 = hash(JSON.stringify(parent.normalized_result));
+ report.phases.bundle_verification_ms = performance.now() - start;
+ assert.equal(hash(await readFile(artifact)), expected.artifact.sha256);
+ report.answer = {
+ rows,
+ export_count: expected.export_count,
+ negative: [],
+ coverage: parent.normalized_result.coverage,
+ };
+ report.status = "passed";
+} catch (cause) {
+ report.status = "failed";
+ report.failure = String(cause?.stack ?? cause);
+ process.exitCode = 1;
+} finally {
+ try {
+ await verifyRuntime("after");
+ } catch (cause) {
+ report.status = "failed";
+ report.runtime_identity_failure = String(cause?.stack ?? cause);
+ process.exitCode = 1;
+ }
+ if (
+ !(await finalizeRetainedQueryReceipt({
+ report,
+ client,
+ transport,
+ account,
+ serverLog,
+ serverLogFinished,
+ output: values.output,
+ transcript,
+ }))
+ )
+ process.exitCode = 1;
+}
diff --git a/scripts/lib/recorded-core-fixtures.py b/scripts/lib/recorded-core-fixtures.py
index 323096ce66..f0bfa52b0b 100644
--- a/scripts/lib/recorded-core-fixtures.py
+++ b/scripts/lib/recorded-core-fixtures.py
@@ -5,6 +5,7 @@ import json
from pathlib import Path
import sys
from elftools.elf.elffile import ELFFile
+from elftools.elf.structs import ELFStructs
from elftools.construct import Container
from pwnlib.elf.datatypes import elf_prstatus_amd64
@@ -31,6 +32,26 @@ def note_header(content, note, **changes):
content[note.n_offset:note.n_offset + len(encoded)] = encoded
+# Authored metadata carriers exercise actual parser projection through public
+# inspection, independently of the host that produced the captured amd64 core.
+for name, machine, kind in [
+ ("aarch64-archext", "EM_AARCH64", "PT_AARCH64_ARCHEXT"),
+ ("aarch64-unwind", "EM_AARCH64", "PT_AARCH64_UNWIND"),
+ ("aarch64-mte-opaque", "EM_AARCH64", 0x70000002),
+ ("amd64-processor-opaque", "EM_X86_64", 0x70000001),
+]:
+ structs = ELFStructs(little_endian=True, elfclass=64)
+ structs.create_basic_structs()
+ structs.create_advanced_structs("ET_CORE", machine, image.header.e_ident.EI_OSABI)
+ header_bytes, entry_bytes = structs.Elf_Ehdr.sizeof(), structs.Elf_Phdr.sizeof()
+ header = Container(**dict(image.header, e_machine=machine, e_entry=0, e_ehsize=header_bytes,
+ e_phoff=header_bytes, e_phentsize=entry_bytes, e_phnum=1,
+ e_shoff=0, e_shentsize=64, e_shnum=0, e_shstrndx=0, e_flags=0))
+ segment = Container(p_type=kind, p_flags=4, p_offset=header_bytes + entry_bytes,
+ p_vaddr=0, p_paddr=0, p_filesz=4, p_memsz=4, p_align=4)
+ save(name, structs.Elf_Ehdr.build(header) + structs.Elf_Phdr.build(segment) + bytes(4))
+
+
save("truncated-header", original[:32])
for name, osabi, version in [("linux-osabi", 3, 0), ("solaris-osabi", 6, 0), ("freebsd-osabi", 9, 0), ("unsupported-abi-version", 0, 1)]:
content = bytearray(original)
diff --git a/scripts/verify-recorded-crash.mjs b/scripts/verify-recorded-crash.mjs
index 36bc8028d1..bfe24fbab5 100644
--- a/scripts/verify-recorded-crash.mjs
+++ b/scripts/verify-recorded-crash.mjs
@@ -216,6 +216,22 @@ try {
assert.equal(noNotes.threads.length, 0);
assert.equal(noNotes.note_interpretation_completeness, "unknown");
cases++;
+ for (const [name, architecture, type] of [
+ ["aarch64-archext", "aarch64-little-endian", "PT_AARCH64_ARCHEXT"],
+ ["aarch64-unwind", "aarch64-little-endian", "PT_AARCH64_UNWIND"],
+ ["aarch64-mte-opaque", "aarch64-little-endian", 0x70000002],
+ ["amd64-processor-opaque", "x86_64-little-endian", 0x70000001],
+ ]) {
+ const auxiliary = await basic.inspect(mode, fixtures[name]);
+ assert.equal(auxiliary.architecture, architecture);
+ assert.equal(auxiliary.segments.length, 1);
+ assert.equal(auxiliary.segments[0].type, type);
+ assert.equal(auxiliary.notes.length, 0);
+ assert.equal(auxiliary.threads.length, 0);
+ assert.equal(auxiliary.note_interpretation_completeness, "unknown");
+ assert.equal(auxiliary.live_process_identity, "unknown");
+ cases++;
+ }
const negative = await basic.inspect(mode, fixtures["negative-pid"]);
assert.ok(negative.threads.some((thread) => thread.historical_pid === -2));
cases++;
diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs
index 0277e495fc..c45adf6107 100644
--- a/scripts/verify-wasm-artifact.mjs
+++ b/scripts/verify-wasm-artifact.mjs
@@ -227,8 +227,23 @@ try {
const candidate = join(candidateRoot, "module.wasm");
await writeFile(join(candidateRoot, "source.wat"), "(module (memory 2))");
await tool("wat2wasm", ["source.wat", "-o", "module.wasm"], candidateRoot);
- const glueSource =
- 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);';
+ const glueValues = [
+ "./module.wasm",
+ "https://example.invalid/module.wasm?version=1",
+ ...["\n", "\r", "\r\n", "\u2028", "\u2029"].flatMap((separator) => [
+ `https://example.invalid/module.wasm?query=a${separator}b`,
+ `https://example.invalid/module.wasm#fragment=a${separator}b`,
+ ]),
+ ];
+ const glueLiterals = glueValues.map((value) =>
+ JSON.stringify(value)
+ .replace(/\u2028/gu, "\\u2028")
+ .replace(/\u2029/gu, "\\u2029"),
+ );
+ const glueSource = [
+ `fetch(${glueLiterals[0]}); new URL(${glueLiterals[1]}, import.meta.url);`,
+ ...glueLiterals.slice(2).map((literal) => `fetch(${literal});`),
+ ].join("\n");
const glueSources = [
glueSource,
'import source module from "./module.wasm"; new URL("https://example.invalid/module.wasm?version=1", import.meta.url);',
@@ -285,21 +300,32 @@ try {
assert.equal(observed.artifact.bytes, Buffer.byteLength(source));
assert.equal(await readFile(gluePaths[index], "utf8"), source);
assert.equal(observed.parse_status, "complete");
+ assert.deepEqual(
+ observed.references.map(({ value }) => value),
+ glueValues,
+ );
assert.deepEqual(
observed.references.map(({ candidate_paths }) => candidate_paths),
- [[path], [path, candidate]],
+ glueValues.map((_, index) =>
+ index === 0 ? [path] : [path, candidate],
+ ),
+ );
+ assert.deepEqual(
+ observed.references.map(({ association }) => association),
+ glueValues.map((_, index) =>
+ index === 0 ? "local-path-candidate" : "basename-candidates",
+ ),
);
assert.deepEqual(
observed.references.map(({ line, column }) => ({ line, column })),
- ["./module.wasm", "https://example.invalid/module.wasm?version=1"].map(
- (value) => {
- const offset = source.indexOf(`"${value}"`);
- return {
- line: source.slice(0, offset).split("\n").length,
- column: offset - source.lastIndexOf("\n", offset) - 1,
- };
- },
- ),
+ glueLiterals.map((literal) => {
+ const offset = source.indexOf(literal);
+ assert.notEqual(offset, -1);
+ return {
+ line: source.slice(0, offset).split("\n").length,
+ column: offset - source.lastIndexOf("\n", offset) - 1,
+ };
+ }),
);
}
assert.notEqual(report.candidates[0].sha256, report.candidates[1].sha256);
diff --git a/src/apkSignature/ApkSignatureProvider.test.ts b/src/apkSignature/ApkSignatureProvider.test.ts
index 2e77458c9a..c9619fc471 100644
--- a/src/apkSignature/ApkSignatureProvider.test.ts
+++ b/src/apkSignature/ApkSignatureProvider.test.ts
@@ -541,7 +541,7 @@ const registerSignatureInputAdmissionCases = () => {
if (!damaged.ok) {
expect(damaged.error._tag).toBe("AnalysisInputError");
expect(JSON.stringify(damaged.error)).toContain(
- "malformed Zip64 EOCD record",
+ "invalid Zip64 EOCD signature",
);
}
});
diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts
index a97c7d43a9..0466b5b9c7 100644
--- a/src/apkSignature/apkSignatureFailures.ts
+++ b/src/apkSignature/apkSignatureFailures.ts
@@ -44,6 +44,12 @@ export const mapApkBlockError = (
`The selected file is not a ZIP/APK (${error.detail}).`,
{ remediationAction: "Select a local .apk/.zip/.aab archive." },
);
+ case "zip64-unsupported":
+ return new AnalysisUnsupportedTargetError(
+ OPERATION,
+ path,
+ `This archive uses a Zip64 structure this reader does not decode (${error.detail}).`,
+ );
case "malformed-zip":
return inputError(
`Damaged ZIP/APK at byte ${String(error.offset)}: ${error.detail}.`,
diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts
index 5a8a787594..d71bf1a9d3 100644
--- a/src/application/ClientRegistrationIdentity.ts
+++ b/src/application/ClientRegistrationIdentity.ts
@@ -80,7 +80,7 @@ const unwrapCmdCommand = (command: readonly string[]): readonly string[] => {
};
/** Preserve the package argument from a supported package-runner command. */
-export const clientRegistrationPackageReference = (
+const clientRegistrationPackageReference = (
command: readonly string[],
): string | undefined => {
const arguments_ = unwrapCmdCommand(command);
@@ -100,20 +100,6 @@ export const clientRegistrationPackageReference = (
return undefined;
};
-const isOwnedPackageReference = (packageReference: string): boolean => {
- if (
- packageReference === PRODUCT_IDENTITY.packageName ||
- packageReference === PRODUCT_IDENTITY.packageSpecifier ||
- packageReference === PRODUCT_IDENTITY.registrationPackageSpecifier
- )
- return true;
- const versionPrefix = `${PRODUCT_IDENTITY.packageName}@`;
- return (
- packageReference.startsWith(versionPrefix) &&
- valid(packageReference.slice(versionPrefix.length)) !== null
- );
-};
-
/** Check that one parsed command points to REA's MCP entry point. */
export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown";
@@ -182,69 +168,110 @@ export const equivalentClientRegistrationCommands = (
previous.every((value, index) => value === requested[index])
)
return true;
- const entrypoint = (command: readonly string[]): string | undefined => {
- if (
- command.length === 2 &&
- command[1] === "mcp" &&
- isAbsolute(command[0] ?? "")
- )
- return command[0];
- if (
- command.length === 3 &&
- command[2] === "mcp" &&
- isAbsolute(command[1] ?? "") &&
- entrypointIdentity(command[0] ?? "", process.execPath) === "owned"
- )
- return normalizeEntrypointPath(command[1]);
- return undefined;
- };
- const current = entrypoint(requested);
- return (
- current !== undefined &&
- entrypoint(previous) !== undefined &&
- classifyClientRegistrationCommand(previous, current) === "owned"
- );
+ const current = inspectClientRegistrationCommand(
+ requested,
+ undefined,
+ ).entrypoint;
+ if (current === undefined) return false;
+ const prior = inspectClientRegistrationCommand(previous, current);
+ return prior.entrypoint !== undefined && prior.identity === "owned";
};
-/** Classify the command once; uncertainty never grants positive ownership. */
+export interface ClientRegistrationCommandFacts {
+ readonly identity: ClientRegistrationCommandIdentity;
+ /** Selected absolute launcher/script path; excludes package and unresolved PATH commands. */
+ readonly entrypoint: string | undefined;
+ readonly package:
+ | {
+ readonly reference: string;
+ readonly exactVersion: string | undefined;
+ }
+ | undefined;
+}
+
+/** Parse package identity once, retaining exact source spelling and version facts. */
+export const inspectClientRegistrationCommand = (
+ command: readonly string[],
+ currentCommandPath: string | undefined,
+): ClientRegistrationCommandFacts => {
+ const reference = clientRegistrationPackageReference(command);
+ if (reference !== undefined) {
+ const prefix = `${PRODUCT_IDENTITY.packageName}@`;
+ const version = reference.startsWith(prefix)
+ ? reference.slice(prefix.length)
+ : undefined;
+ const normalized = version === undefined ? null : valid(version);
+ const owned =
+ reference === PRODUCT_IDENTITY.packageName ||
+ reference === PRODUCT_IDENTITY.packageSpecifier ||
+ reference === PRODUCT_IDENTITY.registrationPackageSpecifier ||
+ normalized !== null;
+ return {
+ identity: owned ? "owned" : "foreign",
+ entrypoint: undefined,
+ package: {
+ reference,
+ exactVersion: normalized === version ? version : undefined,
+ },
+ };
+ }
+ return inspectDirectRegistrationCommand(command, currentCommandPath);
+};
+
+/** Ownership-only consumers share the same parsed command facts. */
export const classifyClientRegistrationCommand = (
command: readonly string[],
currentCommandPath: string | undefined,
-): ClientRegistrationCommandIdentity => {
- const packageReference = clientRegistrationPackageReference(command);
- if (packageReference !== undefined)
- return isOwnedPackageReference(packageReference) ? "owned" : "foreign";
+): ClientRegistrationCommandIdentity =>
+ inspectClientRegistrationCommand(command, currentCommandPath).identity;
+
+const inspectDirectRegistrationCommand = (
+ command: readonly string[],
+ currentCommandPath: string | undefined,
+): ClientRegistrationCommandFacts => {
const currentPathAvailable =
currentCommandPath !== undefined && currentCommandPath.length > 0;
if (command.length === 3 && command[2] === "mcp") {
const interpreter = command[0] ?? "";
const entrypoint = command[1] ?? "";
- if (
- !isAbsolute(interpreter) ||
- !isAbsolute(entrypoint) ||
- !currentPathAvailable
- )
- return "unknown";
+ if (!isAbsolute(interpreter) || !isAbsolute(entrypoint))
+ return { identity: "unknown", entrypoint: undefined, package: undefined };
const interpreterIdentity = entrypointIdentity(
interpreter,
process.execPath,
);
- if (interpreterIdentity !== "owned") return interpreterIdentity;
+ if (interpreterIdentity !== "owned")
+ return {
+ identity: currentPathAvailable ? interpreterIdentity : "unknown",
+ entrypoint: undefined,
+ package: undefined,
+ };
// Node selects its script from normalized argv; executable paths use native
// traversal semantics, where symlink/.. can denote a different file.
const selectedEntrypoint = normalizeEntrypointPath(entrypoint);
- return selectedEntrypoint === undefined
- ? "unknown"
- : entrypointIdentity(selectedEntrypoint, currentCommandPath);
+ return {
+ identity:
+ selectedEntrypoint === undefined || !currentPathAvailable
+ ? "unknown"
+ : entrypointIdentity(selectedEntrypoint, currentCommandPath),
+ entrypoint: selectedEntrypoint,
+ package: undefined,
+ };
}
if (command.length === 2 && command[1] === "mcp") {
const executable = command[0] ?? "";
- if (executable === PRODUCT_IDENTITY.cliBinary) return "owned";
+ if (executable === PRODUCT_IDENTITY.cliBinary)
+ return { identity: "owned", entrypoint: undefined, package: undefined };
if (isAbsolute(executable))
- return currentPathAvailable
- ? directEntrypointIdentity(executable, currentCommandPath)
- : "unknown";
- if (/[./\\]/u.test(executable)) return "unknown";
+ return {
+ identity: currentPathAvailable
+ ? directEntrypointIdentity(executable, currentCommandPath)
+ : "unknown",
+ entrypoint: executable,
+ package: undefined,
+ };
+ if (/[./\\]/u.test(executable))
+ return { identity: "unknown", entrypoint: undefined, package: undefined };
}
- return "foreign";
+ return { identity: "foreign", entrypoint: undefined, package: undefined };
};
diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts
index 649bb27992..5b1d7c6058 100644
--- a/src/application/ClientRegistrationStatus.ts
+++ b/src/application/ClientRegistrationStatus.ts
@@ -11,8 +11,6 @@ import { lstat } from "node:fs/promises";
import { err, ok, type Result } from "../domain/result.js";
import { isAbsolute } from "node:path";
-import { valid } from "semver";
-
import { PRODUCT_IDENTITY } from "../identity.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
@@ -24,8 +22,8 @@ import {
type McpToolTimeoutInputs,
} from "../mcpStartupPolicy.js";
import {
- clientRegistrationPackageReference,
- classifyClientRegistrationCommand,
+ inspectClientRegistrationCommand,
+ type ClientRegistrationCommandFacts,
packageRunnerRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
import {
@@ -279,16 +277,21 @@ export const readClientRegistrationInspections = async (
registration.command,
...registration.args,
];
- const packageReference = clientRegistrationPackageReference(command);
+ const commandFacts = inspectClientRegistrationCommand(
+ command,
+ currentCommandPath,
+ );
+ const packageReference = commandFacts.package?.reference;
const activation = registrationActivation(registration, client, parsed);
- const packageVersion = exactRegisteredPackageVersion(packageReference);
+ const packageVersion = commandFacts.package?.exactVersion;
const packageVersionDrift =
packageVersion !== undefined &&
packageVersion !== PRODUCT_IDENTITY.packageVersion;
const alignment = registrationAligned({
registration,
client: effectiveClient,
- currentCommandPath,
+ command,
+ commandFacts,
platform: options.platform ?? process.platform,
kimiInputSchemaProfileSelected:
registrationEnvironment?.ok === true &&
@@ -373,21 +376,19 @@ const registrationActivation = (
const registrationAligned = ({
registration,
client,
- currentCommandPath,
+ command,
+ commandFacts,
platform,
kimiInputSchemaProfileSelected,
}: {
readonly registration: ClientRegistration;
readonly client: SetupClient;
- readonly currentCommandPath: string | undefined;
+ readonly command: readonly string[];
+ readonly commandFacts: ClientRegistrationCommandFacts;
readonly platform: NodeJS.Platform;
readonly kimiInputSchemaProfileSelected: boolean;
}): "aligned" | "stale" | "unknown" => {
- const command = [registration.command, ...registration.args];
- const identity = classifyClientRegistrationCommand(
- command,
- currentCommandPath,
- );
+ const { identity } = commandFacts;
if (identity === "unknown") return "unknown";
if (identity === "foreign") return "stale";
if (
@@ -427,7 +428,7 @@ const registrationAligned = ({
)
return "aligned";
// Other owned package references remain stale until pinned to this version.
- if (clientRegistrationPackageReference(command) !== undefined) return "stale";
+ if (commandFacts.package !== undefined) return "stale";
// The identity owner already established an exact direct launcher match.
// A bare command still depends on the client's PATH, so cannot prove alignment.
return isAbsolute(command[0] ?? "") ? "aligned" : "stale";
@@ -505,16 +506,6 @@ const timeoutAdvisory = (
return `The configured ${setting} (${currentValue}) is shorter than REA's known provider allowance (${String(providerAllowance)} ms), so provider startup or an upstream request may exceed this client deadline. REA recommends ${setting} = ${recommendedValue} to cover that allowance plus ${headroom} of operation headroom; this additional headroom is a recommendation, not a guarantee for every analysis.`;
};
-const exactRegisteredPackageVersion = (
- packageReference: string | undefined,
-): string | undefined => {
- if (packageReference === undefined) return undefined;
- const prefix = `${PRODUCT_IDENTITY.packageName}@`;
- if (!packageReference.startsWith(prefix)) return undefined;
- const version = packageReference.slice(prefix.length);
- return valid(version) === version ? version : undefined;
-};
-
const packageVersionRemediation = (
packageReference: string,
client: SetupClient,
diff --git a/src/application/binaryDiagnostics/NativeExportsService.ts b/src/application/binaryDiagnostics/NativeExportsService.ts
index d8d5bceb37..19edc83001 100644
--- a/src/application/binaryDiagnostics/NativeExportsService.ts
+++ b/src/application/binaryDiagnostics/NativeExportsService.ts
@@ -46,8 +46,7 @@ export class NativeExportsService {
if (
!report.success ||
report.data.artifact.path !== input.data.path ||
- report.data.artifact.bytes > input.data.max_file_bytes ||
- report.data.coverage.exports !== report.data.exports.length
+ report.data.artifact.bytes > input.data.max_file_bytes
)
return err(
new AnalysisOutputError(
diff --git a/src/application/environment/environmentCompare.ts b/src/application/environment/environmentCompare.ts
index cd6cf9a02f..4fee620c5c 100644
--- a/src/application/environment/environmentCompare.ts
+++ b/src/application/environment/environmentCompare.ts
@@ -1,4 +1,5 @@
import { decodedSegment } from "../../domain/analysisView/environmentStateRules.js";
+import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
import { fromJsonSchema } from "@modelcontextprotocol/server";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { JsonValue } from "../../domain/jsonValue.js";
@@ -139,22 +140,37 @@ const entityOf = (
run: ProbeRun,
tool: EnvironmentManifestTool,
segments: readonly string[],
+ answer: ReplayResult,
): {
readonly scope: string;
readonly at: string;
readonly identity: boolean;
+ readonly rules: readonly FieldRule[];
} => {
const view = run.store?.state.views.get(tool.name);
const plan = run.store?.state.writes.get(tool.name);
const response = plan?.response;
const scope = collectionScope(tool, segments);
if (view?.shape === "item" || view?.shape === "list")
- return { scope, at: view.pointer, identity: false };
+ return { scope, at: view.pointer, identity: false, rules: [] };
const identity = response?.shape === "identity";
const at = identity
? (response.pointer ?? "")
: (response?.pointer ?? plan?.observation?.pointer ?? "");
- return { scope, at, identity };
+ const provenance = (answer as Partial).state;
+ const renderedWrite =
+ provenance?.source === "state" &&
+ (provenance.view === "create" ||
+ provenance.view === "update" ||
+ provenance.view === "document-write" ||
+ provenance.view === "delete");
+ // A missing target replays a recorded absence with state provenance too;
+ // only an applied write can render the plan's entity response.
+ const rules =
+ plan?.served === true && response?.shape === "entity" && renderedWrite
+ ? plan.rules
+ : [];
+ return { scope, at, identity, rules };
};
const fromState = (answer: ReplayResult): boolean => {
@@ -353,7 +369,7 @@ const compareStep = (
status: answered.value.status,
body: envBody(run, answered.value),
};
- const entity = entityOf(run, call.tool, liveSegments);
+ const entity = entityOf(run, call.tool, liveSegments, answered.value);
// Another call's recording holds other entities, whose ids would map wrongly and, the first pairing winning, for good.
if (answersCall(run, call, args, answered.value))
learnIds(identityValue(live.body), identityValue(env.body), run.map, {
@@ -362,21 +378,7 @@ const compareStep = (
entityAt: entity.at,
identity: entity.identity,
});
- const plan = run.store?.state.writes.get(call.tool.name);
- const generatedFields = new Set(
- fromState(answered.value) && plan?.served === true
- ? plan.rules.flatMap((rule) =>
- rule.rule === "unique" ||
- (rule.rule === "identity" && rule.generator !== "sequence")
- ? [rule.field]
- : [],
- )
- : [],
- );
- const compared = compareAnswers(live, env, run.map, {
- ...entity,
- generatedFields,
- });
+ const compared = compareAnswers(live, env, run.map, entity);
for (const key of NORMALISED_KEYS)
run.normalised[key] += compared.normalised[key];
const differences = [...compared.differences];
diff --git a/src/application/environment/environmentCompareDiff.test.ts b/src/application/environment/environmentCompareDiff.test.ts
index 88950b5331..53c52e2208 100644
--- a/src/application/environment/environmentCompareDiff.test.ts
+++ b/src/application/environment/environmentCompareDiff.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { JsonValue } from "../../domain/jsonValue.js";
+import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
import { compareAnswers } from "./environmentCompareDiff.js";
import { IdMap, learnIds } from "./environmentCompareIds.js";
@@ -9,11 +10,13 @@ const compare = (
env: unknown,
map = new IdMap(),
status: [number, number] = [200, 200],
+ rules: readonly FieldRule[] = [],
) =>
compareAnswers(
{ status: status[0], body: { kind: "json", value: live as JsonValue } },
{ status: status[1], body: { kind: "json", value: env as JsonValue } },
map,
+ { rules },
);
describe("early-year clock formats", () => {
@@ -31,8 +34,27 @@ describe("early-year clock formats", () => {
);
});
-const registerValueComparisonCases = () => {
- it("normalizes mapped ids and clocks while comparing unproven generated-looking values literally", () => {
+const registerGeneratedValueComparisonCases = () => {
+ it.each([
+ {
+ name: "normalizes mapped ids and clocks while comparing unproven generated-looking values literally",
+ rules: [],
+ generated: 0,
+ differences: [
+ ["value", "/token"],
+ ["value", "/uuid"],
+ ],
+ },
+ {
+ name: "agrees on mapped ids, clocks and confirmed generated values, and counts each",
+ rules: [
+ { field: "token", rule: "identity", generator: "token" },
+ { field: "uuid", rule: "unique" },
+ ],
+ generated: 2,
+ differences: [],
+ },
+ ] as const)("$name", ({ rules, generated, differences }) => {
const map = new IdMap();
map.learn(101, 1);
const result = compare(
@@ -49,17 +71,16 @@ const registerValueComparisonCases = () => {
uuid: "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
},
map,
+ [200, 200],
+ rules,
);
expect(
result.differences.map(({ category, pointer }) => [category, pointer]),
- ).toEqual([
- ["value", "/token"],
- ["value", "/uuid"],
- ]);
+ ).toEqual(differences);
expect(result.normalised).toEqual({
ids: 1,
clocks: 1,
- generated: 0,
+ generated,
large_bodies: 0,
unobserved_bodies: 0,
});
@@ -69,20 +90,24 @@ const registerValueComparisonCases = () => {
const result = compareAnswers(
{
status: 201,
- body: { kind: "json", value: { token: "live-token", sku: "product2" } },
+ body: { kind: "json", value: { token: "token00001", sku: "product2" } },
},
{
status: 201,
- body: { kind: "json", value: { token: "env-token", sku: "product1" } },
+ body: { kind: "json", value: { token: "token00002", sku: "product1" } },
},
new IdMap(),
- { generatedFields: new Set(["token"]) },
+ { rules: [{ field: "token", rule: "identity", generator: "token" }] },
);
expect(result.differences).toMatchObject([
{ category: "value", pointer: "/sku" },
]);
expect(result.normalised.generated).toBe(1);
});
+};
+
+describe("compareAnswers", () => {
+ registerGeneratedValueComparisonCases();
it("categorises status, missing and extra fields, values and clock formats", () => {
const result = compare(
@@ -129,9 +154,9 @@ const registerValueComparisonCases = () => {
),
).toEqual(["value"]);
});
-};
+});
-const registerMappedIdentityComparisonCases = () => {
+describe("mapped fields and literal collections", () => {
it("compares an object keyed by mapped ids under the environment's keys", () => {
const map = new IdMap();
map.learn("liveTable", "envTable");
@@ -193,7 +218,7 @@ const registerMappedIdentityComparisonCases = () => {
).toEqual([]);
});
- it("reports scalar reordering and keeps unnamed epoch-shaped array measurements literal", () => {
+ it("reports a reordered set of scalars as order alone and keeps nested numeric measurements literal", () => {
expect(
compare(
{ access: ["a", "b", "c"] },
@@ -203,13 +228,224 @@ const registerMappedIdentityComparisonCases = () => {
expect(
compare({ stats: [[1792450800, 3]] }, { stats: [[1792285200, 3]] })
.differences,
- ).toMatchObject([{ category: "value", pointer: "/stats/0/0" }]);
+ ).toEqual([
+ {
+ category: "value",
+ pointer: "/stats/0/0",
+ live: "1792450800",
+ env: "1792285200",
+ },
+ ]);
});
-};
+});
-describe("compareAnswers", () => {
- registerValueComparisonCases();
- registerMappedIdentityComparisonCases();
+describe("normalization context", () => {
+ it.each([
+ { live: 1800000000, env: 1700000000, pointer: "" },
+ { live: [1800000000], env: [1700000000], pointer: "/0" },
+ {
+ live: { samples: [1800000000] },
+ env: { samples: [1700000000] },
+ pointer: "/samples/0",
+ },
+ {
+ live: { updated_at: [{ samples: 1800000000 }] },
+ env: { updated_at: [{ samples: 1700000000 }] },
+ pointer: "/updated_at/0/samples",
+ },
+ ])(
+ "keeps epoch-shaped numbers literal at $pointer without a time field",
+ ({ live, env, pointer }) => {
+ const result = compare(live, env);
+ expect(result.differences).toEqual([
+ { category: "value", pointer, live: "1800000000", env: "1700000000" },
+ ]);
+ expect(result.normalised.clocks).toBe(0);
+ },
+ );
+
+ it("preserves the time field through nested numeric clock arrays", () => {
+ const result = compare(
+ { updated_at: [[1700000000]] },
+ { updated_at: [[1800000000]] },
+ );
+ expect(result.differences).toEqual([]);
+ expect(result.normalised.clocks).toBe(1);
+ });
+
+ it.each([
+ ["product1", "product2"],
+ ["product1", "productX"],
+ ["3f2a9c8d7e", "9b8e7d6c5a"],
+ [
+ "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b",
+ "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
+ ],
+ ])(
+ "keeps literal business strings %s versus %s without generation facts",
+ (live, env) => {
+ const result = compare(
+ { sku: live, items: [live] },
+ { sku: env, items: [env] },
+ );
+ expect(
+ result.differences.map(({ category, pointer }) => [category, pointer]),
+ ).toEqual([
+ ["value", "/sku"],
+ ["value", "/items/0"],
+ ]);
+ expect(result.normalised.generated).toBe(0);
+ },
+ );
+
+ it("scopes confirmed renewal rules to their response entity and exact array pointers", () => {
+ const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b";
+ const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d";
+ const answer = (token: string) => ({
+ status: 200,
+ body: {
+ kind: "json" as const,
+ value: {
+ data: { values: [{ token, sku: token }] },
+ values: [{ token }],
+ },
+ },
+ });
+ const result = compareAnswers(answer(first), answer(second), new IdMap(), {
+ at: "/data",
+ rules: [{ field: "values", rule: "renewed", pointers: ["/*/token"] }],
+ });
+ expect(result.normalised.generated).toBe(1);
+ expect(
+ result.differences.map(({ category, pointer }) => [category, pointer]),
+ ).toEqual([
+ ["value", "/data/values/0/sku"],
+ ["value", "/values/0/token"],
+ ]);
+ });
+
+ it("does not extend generated-string normalization to numeric sequences", () => {
+ const result = compare(
+ { code: 17 },
+ { code: 18 },
+ new IdMap(),
+ [200, 200],
+ [{ field: "code", rule: "sequence", scope: "type" }],
+ );
+ expect(result.differences).toEqual([
+ { category: "value", pointer: "/code", live: "17", env: "18" },
+ ]);
+ expect(result.normalised.generated).toBe(0);
+ });
+});
+
+describe("generated response identities", () => {
+ const first = "6f1c2b9e-2a3d-4f5e-8a9b-0c1d2e3f4a5b";
+ const second = "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d";
+ const fresh = "123e4567-e89b-42d3-a456-426614174000";
+ const other = "223e4567-e89b-42d3-a456-426614174000";
+ const rules: readonly FieldRule[] = [
+ { field: "left", rule: "renewed", pointers: ["/*"] },
+ { field: "right", rule: "renewed", pointers: ["/*"] },
+ ];
+ const answer = (left: string, right: string) => ({
+ status: 200,
+ body: { kind: "json" as const, value: { left: [left], right: [right] } },
+ });
+
+ it.each([
+ {
+ name: "consistent fresh renaming",
+ liveLeft: fresh,
+ liveRight: fresh,
+ envLeft: first,
+ envRight: first,
+ differs: false,
+ generated: 2,
+ },
+ {
+ name: "case-equivalent UUID aliases",
+ liveLeft: fresh.toUpperCase(),
+ liveRight: fresh,
+ envLeft: first,
+ envRight: first.toUpperCase(),
+ differs: false,
+ generated: 2,
+ },
+ {
+ name: "split alias",
+ liveLeft: fresh,
+ liveRight: other,
+ envLeft: first,
+ envRight: first,
+ differs: true,
+ generated: 1,
+ },
+ {
+ name: "merged identities",
+ liveLeft: fresh,
+ liveRight: fresh,
+ envLeft: first,
+ envRight: second,
+ differs: true,
+ generated: 1,
+ },
+ {
+ name: "split after an unchanged array",
+ liveLeft: first,
+ liveRight: fresh,
+ envLeft: first,
+ envRight: first,
+ differs: true,
+ generated: 0,
+ },
+ {
+ name: "merge after an unchanged array",
+ liveLeft: first,
+ liveRight: first,
+ envLeft: first,
+ envRight: second,
+ differs: true,
+ generated: 0,
+ },
+ ])(
+ "preserves $name within one confirmed renewal response",
+ ({ liveLeft, liveRight, envLeft, envRight, differs, generated }) => {
+ const result = compareAnswers(
+ answer(liveLeft, liveRight),
+ answer(envLeft, envRight),
+ new IdMap(),
+ { rules },
+ );
+ expect(result.normalised.generated).toBe(generated);
+ expect(result.differences).toEqual(
+ differs
+ ? [
+ {
+ category: "value",
+ pointer: "/right/0",
+ live: JSON.stringify(liveRight),
+ env: JSON.stringify(envRight),
+ },
+ ]
+ : [],
+ );
+ },
+ );
+
+ it("retains order-only comparison for reordered generated scalar sets", () => {
+ const result = compare(
+ { left: [first, second] },
+ { left: [second, first] },
+ new IdMap(),
+ [200, 200],
+ rules,
+ );
+ expect(result.differences).toEqual([
+ { category: "order", pointer: "/left" },
+ ]);
+ expect(result.normalised.generated).toBe(0);
+ });
});
describe("clock field names", () => {
@@ -347,6 +583,32 @@ describe("identity responses two collections reuse", () => {
});
describe("wrapped answers", () => {
+ it("keeps collection identity context separate from a wrapped array's clock field context", () => {
+ const map = new IdMap();
+ map.learn(101, 1, "/api/books");
+ map.learn(101, 2, "/api/authors");
+ const result = compareAnswers(
+ {
+ status: 200,
+ body: {
+ kind: "json",
+ value: { data: [{ id: 101, updated_at: [1700000000] }] },
+ },
+ },
+ {
+ status: 200,
+ body: {
+ kind: "json",
+ value: { data: [{ id: 2, updated_at: [1800000000] }] },
+ },
+ },
+ map,
+ { scope: "/api/authors", at: "/data" },
+ );
+ expect(result.differences).toEqual([]);
+ expect(result.normalised).toMatchObject({ ids: 1, clocks: 1 });
+ });
+
it("compares the identity of an entity inside an envelope within the call's collection", () => {
const map = new IdMap();
map.learn(101, 1, "/api/books");
diff --git a/src/application/environment/environmentCompareDiff.ts b/src/application/environment/environmentCompareDiff.ts
index 1552799673..b028e99a12 100644
--- a/src/application/environment/environmentCompareDiff.ts
+++ b/src/application/environment/environmentCompareDiff.ts
@@ -1,5 +1,7 @@
import type { JsonObject } from "../../domain/analysisView/environmentStateRules.js";
+import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js";
import {
+ isUuid,
readClock,
type ClockFormat,
} from "../../domain/analysisView/environmentStateWriteValues.js";
@@ -88,7 +90,54 @@ const clockFormat = (
return clock === undefined ? undefined : formatKey(clock.format);
};
+const characterClasses = (text: string): string =>
+ [
+ ...new Set(
+ text
+ .replaceAll(/\d/gu, "0")
+ .replaceAll(/[a-z]/gu, "a")
+ .replaceAll(/[A-Z]/gu, "A"),
+ ),
+ ]
+ .sort()
+ .join("");
+
+/** A confirmed response field's rule, and the pointer within that field (`*` marks actual array traversal). */
+interface Generation {
+ readonly rule: FieldRule;
+ readonly pointer: string;
+}
+
+/** Whether a confirmed generation rule applies here and both strings retain its generated format. */
+const generatedPair = (
+ live: string,
+ env: string,
+ generation: Generation | undefined,
+): boolean => {
+ if (generation === undefined) return false;
+ const { rule, pointer } = generation;
+ if (rule.rule === "renewed")
+ return rule.pointers.includes(pointer) && isUuid(live) && isUuid(env);
+ if (pointer !== "") return false;
+ if (
+ rule.rule === "unique" ||
+ (rule.rule === "identity" && rule.generator === "uuid")
+ )
+ return isUuid(live) && isUuid(env);
+ if (rule.rule !== "sequence" && rule.rule !== "identity") return false;
+ return (
+ live.length === env.length &&
+ live.length >= 8 &&
+ /\d/u.test(live) &&
+ /\d/u.test(env) &&
+ characterClasses(live) === characterClasses(env)
+ );
+};
+
class Differ {
+ readonly #rules: ReadonlyMap;
+ readonly #generatedToEnv = new Map();
+ readonly #generatedToLive = new Map();
readonly differences: Difference[] = [];
readonly normalised: Normalised = {
ids: 0,
@@ -105,14 +154,39 @@ class Differ {
readonly scope: string | undefined;
readonly at: string;
readonly identity: boolean;
- readonly generatedFields: ReadonlySet;
+ /** Parsed rules of the confirmed write whose entity response is being compared. */
+ readonly rules: readonly FieldRule[];
},
- ) {}
+ ) {
+ this.#rules = new Map(entity.rules.map((rule) => [rule.field, rule]));
+ }
add(difference: Difference): void {
this.differences.push(difference);
}
+ /** One answer's generated identities must rename consistently in both directions. */
+ #pairGenerated(live: string, env: string, rule: FieldRule): boolean {
+ const uuid =
+ rule.rule === "unique" ||
+ rule.rule === "renewed" ||
+ (rule.rule === "identity" && rule.generator === "uuid");
+ const identity = (value: string) =>
+ uuid ? `uuid:${value.toLowerCase()}` : `string:${value}`;
+ const liveKey = identity(live);
+ const envKey = identity(env);
+ const knownEnv = this.#generatedToEnv.get(liveKey);
+ const knownLive = this.#generatedToLive.get(envKey);
+ if (
+ (knownEnv !== undefined && knownEnv !== envKey) ||
+ (knownLive !== undefined && knownLive !== liveKey)
+ )
+ return false;
+ this.#generatedToEnv.set(liveKey, envKey);
+ this.#generatedToLive.set(envKey, liveKey);
+ return true;
+ }
+
/** Whether a value compares within the call's collection, as an entity's own identity or a create's bare id does; other ids compare by the probe's mapping. */
#inCollection(
pointer: string,
@@ -140,10 +214,29 @@ class Differ {
live: JsonValue,
env: JsonValue,
pointer: string,
- at: { readonly key?: string; readonly own?: boolean } = {},
+ at: {
+ readonly key?: string;
+ readonly own?: boolean;
+ readonly clockKey?: string | undefined;
+ } = {},
+ generation?: Generation,
): void {
+ const key = at.clockKey ?? at.key;
+ const liveClock = clockFormat(live, key);
+ const envClock = clockFormat(env, key);
+ const generatedMatch =
+ generation !== undefined &&
+ typeof live === "string" &&
+ typeof env === "string" &&
+ liveClock === undefined &&
+ envClock === undefined &&
+ !this.map.hasLive(live) &&
+ !this.map.hasEnv(env) &&
+ generatedPair(live, env, generation)
+ ? this.#pairGenerated(live, env, generation.rule)
+ : undefined;
if (live === env) {
- if (this.#reassigned(live, pointer, at))
+ if (generatedMatch === false || this.#reassigned(live, pointer, at))
this.add({
category: "value",
pointer,
@@ -152,17 +245,17 @@ class Differ {
});
return;
}
- const { key } = at;
+ const field = at.key;
if (
(typeof live === "string" || typeof live === "number") &&
(typeof env === "string" || typeof env === "number")
) {
const mapsAsId =
- (key !== undefined && ID_FIELD.test(key)) ||
+ (field !== undefined && ID_FIELD.test(field)) ||
this.#inCollection(pointer, at) ||
(typeof live === "string" &&
- key !== undefined &&
- this.map.isStateReference(live, key));
+ field !== undefined &&
+ this.map.isStateReference(live, field));
const mapped = mapsAsId
? this.map.env(
live,
@@ -173,8 +266,6 @@ class Differ {
this.normalised.ids += 1;
return;
}
- const liveClock = clockFormat(live, key);
- const envClock = clockFormat(env, key);
if (liveClock !== undefined && envClock !== undefined) {
if (liveClock === envClock) this.normalised.clocks += 1;
else
@@ -186,15 +277,7 @@ class Differ {
});
return;
}
- if (
- typeof live === "string" &&
- typeof env === "string" &&
- at.own === true &&
- key !== undefined &&
- this.entity.generatedFields.has(key) &&
- !this.map.hasLive(live) &&
- !this.map.hasEnv(env)
- ) {
+ if (generatedMatch === true) {
this.normalised.generated += 1;
return;
}
@@ -207,9 +290,28 @@ class Differ {
});
}
- array(live: JsonValue[], env: JsonValue[], pointer: string): void {
+ array(
+ live: JsonValue[],
+ env: JsonValue[],
+ pointer: string,
+ clockKey: string | undefined,
+ generation: Generation | undefined,
+ ): void {
const own = pointer === this.entity.at;
- if (this.sameItems(live, env, pointer)) return;
+ if (this.sameItems(live, env, pointer)) {
+ // Equal/reordered scalar sets still establish unchanged generated pairs;
+ // otherwise another field could split an alias hidden by this fast path.
+ if (generation !== undefined)
+ for (const [index, item] of live.entries())
+ this.scalar(
+ item,
+ item,
+ `${pointer}/${String(index)}`,
+ { own, clockKey },
+ { ...generation, pointer: `${generation.pointer}/*` },
+ );
+ return;
+ }
const pairs = pairElements(live, env);
if (live.length !== env.length || pairs.length !== live.length)
this.add({
@@ -224,9 +326,18 @@ class Differ {
)
this.add({ category: "order", pointer });
for (const [left, right] of pairs)
- this.value(live[left]!, env[right]!, `${pointer}/${String(left)}`, {
- own,
- });
+ this.value(
+ live[left]!,
+ env[right]!,
+ `${pointer}/${String(left)}`,
+ {
+ own,
+ clockKey,
+ },
+ generation === undefined
+ ? undefined
+ : { ...generation, pointer: `${generation.pointer}/*` },
+ );
}
/** Arrays of scalars holding the same items in another order differ only in order, as sets an application returns unordered do. */
@@ -262,6 +373,7 @@ class Differ {
env: JsonObject,
pointer: string,
own = false,
+ generation?: Generation,
): void {
const self = own || pointer === this.entity.at;
// An object keyed by ids compares each member under the environment's key; any other compares field by field.
@@ -286,11 +398,25 @@ class Differ {
});
else {
matched.add(other);
- this.value(value, env[other]!, child, {
- key,
- own: self,
- keyed: self && keyed,
- });
+ const rule = self ? this.#rules.get(key) : undefined;
+ this.value(
+ value,
+ env[other]!,
+ child,
+ {
+ key,
+ own: self,
+ keyed: self && keyed,
+ },
+ rule !== undefined
+ ? { rule, pointer: "" }
+ : generation === undefined
+ ? undefined
+ : {
+ ...generation,
+ pointer: `${generation.pointer}/${escapePointer(key)}`,
+ },
+ );
}
}
for (const [key, value] of Object.entries(env))
@@ -311,16 +437,19 @@ class Differ {
readonly key?: string;
readonly own?: boolean;
readonly keyed?: boolean;
+ readonly clockKey?: string | undefined;
} = {},
+ generation?: Generation,
): void {
if (Array.isArray(live) && Array.isArray(env))
- this.array(live, env, pointer);
+ this.array(live, env, pointer, at.clockKey ?? at.key, generation);
else if (isObject(live) && isObject(env))
this.object(
live,
env,
pointer,
at.own === true && (at.key === undefined || at.keyed === true),
+ generation,
);
else if (
Array.isArray(live) ||
@@ -334,7 +463,7 @@ class Differ {
live: shown(live),
env: shown(env),
});
- else this.scalar(live, env, pointer, at);
+ else this.scalar(live, env, pointer, at, generation);
}
}
@@ -355,15 +484,14 @@ export const compareAnswers = (
readonly scope?: string;
readonly at?: string;
readonly identity?: boolean;
- /** Direct entity fields whose executed write plan proves independent generation. */
- readonly generatedFields?: ReadonlySet;
+ readonly rules?: readonly FieldRule[];
} = {},
): { readonly differences: Difference[]; readonly normalised: Normalised } => {
const differ = new Differ(map, {
scope: entity.scope,
at: entity.at ?? "",
identity: entity.identity === true,
- generatedFields: entity.generatedFields ?? new Set(),
+ rules: entity.rules ?? [],
});
if (live.status !== env.status)
differ.add({
diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts
index d132a1e287..32ce10eb96 100644
--- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts
+++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts
@@ -11,6 +11,7 @@ import {
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import { createHash } from "node:crypto";
import { lstat } from "node:fs/promises";
+import { streamChunkToBuffer } from "../StreamBytes.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
@@ -489,7 +490,7 @@ const digestBounded = async (
"Asset catalog inspection cancelled",
{ cause: signal.reason },
);
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ const bytes = streamChunkToBuffer(chunk);
length += bytes.length;
if (length > MAX_CATALOG_BYTES)
throw new ArtifactReaderFailure(
diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts
index a9be0dafd9..94d83ea11a 100644
--- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts
+++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts
@@ -10,6 +10,7 @@ import {
isSourceLessAppKitAnnotationConnector,
isUIKitRuntimeConnectionClass,
} from "../../domain/apple/interfaceBuilderKeyedArchive.js";
+import { streamChunkToBuffer } from "../StreamBytes.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
@@ -1015,9 +1016,7 @@ const readEntry = async (
const chunks: Buffer[] = [];
let length = 0;
for await (const chunk of stream) {
- const bytes = Buffer.isBuffer(chunk)
- ? chunk
- : Buffer.from(chunk as Uint8Array);
+ const bytes = streamChunkToBuffer(chunk);
length += bytes.length;
if (length > maxInputBytes) {
stream.destroy();
diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts
index 87942cf51e..04d5b27c1b 100644
--- a/src/artifacts/apple/MachoLoadCommandReader.test.ts
+++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts
@@ -108,6 +108,7 @@ it.each([
{ command: LC.VERSION_MIN_IPHONEOS, cpu: CPU.i386, platform: 7 },
{ command: LC.VERSION_MIN_IPHONEOS, cpu: CPU.x86_64, platform: 7 },
{ command: LC.VERSION_MIN_IPHONEOS, cpu: CPU.arm64, platform: 2 },
+ { command: LC.VERSION_MIN_TVOS, cpu: CPU.i386, platform: 3 },
{ command: LC.VERSION_MIN_TVOS, cpu: CPU.x86_64, platform: 8 },
{ command: LC.VERSION_MIN_TVOS, cpu: CPU.arm64, platform: 3 },
{ command: LC.VERSION_MIN_WATCHOS, cpu: CPU.i386, platform: 9 },
diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts
index 2b90792fe6..47c268131a 100644
--- a/src/artifacts/apple/MachoLoadCommandReader.ts
+++ b/src/artifacts/apple/MachoLoadCommandReader.ts
@@ -5,6 +5,10 @@ import type {
MachoSlice,
} from "../../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
+import {
+ legacyMachoPlatform,
+ type MachoVersionMinPlatform,
+} from "../../domain/apple/machoPlatform.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
@@ -53,53 +57,18 @@ const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
-const LC_VERSION_MIN_MACOSX = 0x24;
-const LC_VERSION_MIN_IPHONEOS = 0x25;
-const LC_VERSION_MIN_TVOS = 0x2f;
-const LC_VERSION_MIN_WATCHOS = 0x30;
const LC_BUILD_VERSION = 0x32;
-// `mach-o/loader.h` `PLATFORM_*` numbers observed via Apple linker output.
-const PLATFORM_MACOS = 1;
-const PLATFORM_IOS = 2;
-const PLATFORM_TVOS = 3;
-const PLATFORM_WATCHOS = 4;
-const PLATFORM_IOSSIMULATOR = 7;
-const PLATFORM_TVOSSIMULATOR = 8;
-const PLATFORM_WATCHOSSIMULATOR = 9;
-
-const CPU_TYPE_I386 = 7;
-const CPU_TYPE_X86_64 = 0x01000007;
const CPU_TYPE_ARM64 = 0x0100000c;
-/**
- * Legacy `LC_VERSION_MIN_*` commands predate explicit simulator platform
- * IDs. Per Apple's dyld rules the image CPU type distinguishes simulator
- * from device: Intel images are simulator, ARM images are device
- * (tvOS simulator applies to x86_64 only). `LC_BUILD_VERSION` platform
- * numbers are preserved verbatim; unknown future numbers stay unknown
- * downstream, never mapped to nearest.
- */
-const legacyVersionMinPlatform = (
- command: number,
- cpuType: number,
-): number | undefined => {
- const isIntel = cpuType === CPU_TYPE_I386 || cpuType === CPU_TYPE_X86_64;
- switch (command) {
- case LC_VERSION_MIN_MACOSX:
- return PLATFORM_MACOS;
- case LC_VERSION_MIN_IPHONEOS:
- return isIntel ? PLATFORM_IOSSIMULATOR : PLATFORM_IOS;
- case LC_VERSION_MIN_TVOS:
- return cpuType === CPU_TYPE_X86_64
- ? PLATFORM_TVOSSIMULATOR
- : PLATFORM_TVOS;
- case LC_VERSION_MIN_WATCHOS:
- return isIntel ? PLATFORM_WATCHOSSIMULATOR : PLATFORM_WATCHOS;
- default:
- return undefined;
- }
-};
+/** loader.h command codes and their device platform identities. */
+const VERSION_MIN_PLATFORMS: ReadonlyMap =
+ new Map([
+ [0x24, 1], // LC_VERSION_MIN_MACOSX
+ [0x25, 2], // LC_VERSION_MIN_IPHONEOS
+ [0x2f, 3], // LC_VERSION_MIN_TVOS
+ [0x30, 4], // LC_VERSION_MIN_WATCHOS
+ ]);
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
@@ -679,14 +648,14 @@ const decodeCommand = (
slice.platforms.push(platform);
return;
}
- const legacyPlatform = legacyVersionMinPlatform(command, slice.cpu_type);
+ const legacyPlatform = VERSION_MIN_PLATFORMS.get(command);
if (legacyPlatform !== undefined) {
if (body.byteLength < 16)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a version-min command`,
);
- slice.platforms.push(legacyPlatform);
+ slice.platforms.push(legacyMachoPlatform(legacyPlatform, slice.cpu_type));
return;
}
if (command === LC_LAZY_LOAD_DYLIB_INFO)
diff --git a/src/browser/CdpCaptureCompleteness.ts b/src/browser/CdpCaptureCompleteness.ts
index 8c81b25059..7ca8671b5d 100644
--- a/src/browser/CdpCaptureCompleteness.ts
+++ b/src/browser/CdpCaptureCompleteness.ts
@@ -62,6 +62,7 @@ export class CdpCaptureCompleteness {
if (POLICY_REASONS.has(reason)) this.#policyFiltered.add(section);
if (
reason === "provider_unavailable" ||
+ reason === "unattributed_target" ||
reason === "invalid_protocol_value" ||
reason === "unknown_target_relationship"
)
diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts
index f7acb95825..51a6b1e9e2 100644
--- a/src/browser/CdpElectronWorkers.ts
+++ b/src/browser/CdpElectronWorkers.ts
@@ -11,6 +11,7 @@ import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
+import { workerTargetOwnership } from "./CdpWorkerTargetOwnership.js";
/** Inventory local worker targets without attaching to them. */
export const captureElectronWorkers = async (input: {
@@ -53,13 +54,22 @@ export const captureElectronWorkers = async (input: {
}
const type = cdpStringValue(target.type) ?? "";
if (!type.includes("worker")) continue;
- const openerTargetId = boundedTargetField(target.openerId);
- const parentFrameId = boundedTargetField(target.parentFrameId);
- if (
- openerTargetId !== input.target.id &&
- (parentFrameId === null || !input.frameIds.has(parentFrameId))
- ) {
- input.completeness.exclude("workers", "out_of_target_scope");
+ const ownership = workerTargetOwnership(
+ target,
+ input.target.id,
+ input.frameIds,
+ );
+ if (!ownership.ok) {
+ input.completeness.exclude("workers", "invalid_protocol_value");
+ continue;
+ }
+ if (ownership.value.scope !== "selected") {
+ input.completeness.exclude(
+ "workers",
+ ownership.value.scope === "unknown"
+ ? "unattributed_target"
+ : "out_of_target_scope",
+ );
continue;
}
const path = await authorizedElectronFile(
@@ -86,8 +96,8 @@ export const captureElectronWorkers = async (input: {
type,
file_path: path,
attached: target.attached,
- opener_target_id: openerTargetId,
- parent_frame_id: parentFrameId,
+ opener_target_id: ownership.value.openerTargetId,
+ parent_frame_id: ownership.value.parentFrameId,
});
}
return workers.sort((left, right) =>
diff --git a/src/browser/CdpPageCaptureWorkers.test.ts b/src/browser/CdpPageCaptureWorkers.test.ts
index 77768ad997..bf67749b96 100644
--- a/src/browser/CdpPageCaptureWorkers.test.ts
+++ b/src/browser/CdpPageCaptureWorkers.test.ts
@@ -125,3 +125,49 @@ describe("web worker target inventory", () => {
);
});
});
+
+it.each([`${origin}/worker.js`, ""])(
+ "keeps an unattributed worker unavailable rather than policy filtered: %s",
+ async (url) => {
+ const result = await capture({
+ targetInfos: [
+ { targetId: "worker", type: "worker", url, attached: false },
+ ],
+ });
+ expect(result.workers).toEqual([]);
+ expect(result.completeness).toMatchObject({
+ status: "attach_limited",
+ conditions: ["attach_limited"],
+ unavailable_sections: ["workers"],
+ policy_filtered_sections: [],
+ excluded: [
+ { section: "workers", reason: "unattributed_target", count: 1 },
+ ],
+ });
+ },
+);
+
+it.each([{ openerId: "" }, { parentFrameId: null }])(
+ "retains invalid supplied relationship coverage: %j",
+ async (relationship) => {
+ const result = await capture({
+ targetInfos: [
+ {
+ targetId: "worker",
+ type: "worker",
+ url: `${origin}/worker.js`,
+ attached: false,
+ ...relationship,
+ },
+ ],
+ });
+ expect(result.workers).toEqual([]);
+ expect(result.completeness).toMatchObject({
+ unavailable_sections: ["workers"],
+ policy_filtered_sections: [],
+ excluded: [
+ { section: "workers", reason: "invalid_protocol_value", count: 1 },
+ ],
+ });
+ },
+);
diff --git a/src/browser/CdpPageCaptureWorkers.ts b/src/browser/CdpPageCaptureWorkers.ts
index 82ee53a6e1..b50854102c 100644
--- a/src/browser/CdpPageCaptureWorkers.ts
+++ b/src/browser/CdpPageCaptureWorkers.ts
@@ -9,6 +9,7 @@ import {
} from "./CdpCaptureValues.js";
import type { CaptureContext } from "./CdpPageCapture.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
+import { workerTargetOwnership } from "./CdpWorkerTargetOwnership.js";
interface CaptureWorkersInput {
readonly context: CaptureContext;
@@ -62,39 +63,25 @@ export const captureWorkers = async (
malformedEntries += 1;
continue;
}
- const openerId =
- target.openerId === undefined
- ? undefined
- : cdpStringValue(target.openerId);
- const parentFrameId =
- target.parentFrameId === undefined
- ? undefined
- : cdpStringValue(target.parentFrameId);
- if (
- (target.openerId !== undefined && openerId === undefined) ||
- (target.parentFrameId !== undefined && parentFrameId === undefined)
- ) {
+ const ownership = workerTargetOwnership(
+ target,
+ context.target.id,
+ frameIds,
+ );
+ if (!ownership.ok) {
malformedEntries += 1;
continue;
}
- const url = allowedSanitizedUrl(target.url, allowedOrigins);
- const relatedToPage =
- openerId === context.target.id ||
- (parentFrameId !== undefined && frameIds.has(parentFrameId));
- const relationship = relatedToPage
- ? "related"
- : openerId !== undefined || parentFrameId !== undefined
- ? "foreign"
- : "unknown";
- if (relationship !== "related") {
+ if (ownership.value.scope !== "selected") {
completeness.exclude(
"workers",
- relationship === "foreign"
- ? "out_of_target_scope"
- : "unknown_target_relationship",
+ ownership.value.scope === "unknown"
+ ? "unattributed_target"
+ : "out_of_target_scope",
);
continue;
}
+ const url = allowedSanitizedUrl(target.url, allowedOrigins);
if (url === undefined) {
const rawUrl = cdpStringValue(target.url);
completeness.exclude(
@@ -113,8 +100,8 @@ export const captureWorkers = async (
url: url.url,
origin: url.origin,
attached: target.attached,
- opener_target_id: openerId ?? null,
- parent_frame_id: parentFrameId ?? null,
+ opener_target_id: ownership.value.openerTargetId,
+ parent_frame_id: ownership.value.parentFrameId,
});
}
if (malformedEntries > 0)
diff --git a/src/browser/CdpWorkerTargetOwnership.ts b/src/browser/CdpWorkerTargetOwnership.ts
new file mode 100644
index 0000000000..27611119c0
--- /dev/null
+++ b/src/browser/CdpWorkerTargetOwnership.ts
@@ -0,0 +1,38 @@
+import { err, ok, type Result } from "../domain/result.js";
+import { cdpStringValue } from "./CdpCaptureValues.js";
+
+/** Optional CDP relationships establish ownership only when supplied. */
+export const workerTargetOwnership = (
+ target: Readonly>,
+ selectedTargetId: string,
+ frameIds: ReadonlySet,
+): Result<
+ {
+ readonly scope: "selected" | "foreign" | "unknown";
+ readonly openerTargetId: string | null;
+ readonly parentFrameId: string | null;
+ },
+ { readonly _tag: "InvalidWorkerRelationship" }
+> => {
+ const openerTargetId = cdpStringValue(target.openerId);
+ const parentFrameId = cdpStringValue(target.parentFrameId);
+ if (
+ (target.openerId !== undefined &&
+ (openerTargetId === undefined || openerTargetId === "")) ||
+ (target.parentFrameId !== undefined &&
+ (parentFrameId === undefined || parentFrameId === ""))
+ )
+ return err({ _tag: "InvalidWorkerRelationship" });
+ const selected =
+ openerTargetId === selectedTargetId ||
+ (parentFrameId !== undefined && frameIds.has(parentFrameId));
+ return ok({
+ scope: selected
+ ? "selected"
+ : openerTargetId === undefined && parentFrameId === undefined
+ ? "unknown"
+ : "foreign",
+ openerTargetId: openerTargetId ?? null,
+ parentFrameId: parentFrameId ?? null,
+ });
+};
diff --git a/src/browser/history/protobuf/ProtobufSchema.ts b/src/browser/history/protobuf/ProtobufSchema.ts
index f75379f1ae..a7308d9893 100644
--- a/src/browser/history/protobuf/ProtobufSchema.ts
+++ b/src/browser/history/protobuf/ProtobufSchema.ts
@@ -1,6 +1,8 @@
import { err, ok, type Result } from "../../../domain/result.js";
import {
createFileRegistry,
+ createRegistry,
+ equals,
fromBinary,
ScalarType,
type DescField,
@@ -9,6 +11,8 @@ import {
} from "@bufbuild/protobuf";
import {
FileDescriptorSetSchema,
+ FileDescriptorProtoSchema,
+ type FileDescriptorProto,
type FileDescriptorSet,
} from "@bufbuild/protobuf/wkt";
import { BinaryReader } from "@bufbuild/protobuf/wire";
@@ -52,55 +56,91 @@ const scalarWire = (scalar: ScalarType): ProtobufField["wire_type"] => {
const floatingValue = (value: number): string =>
Object.is(value, -0) ? "-0" : String(value);
-/** Qualified names can expand a small descriptor into a much larger registry. */
-const admitDescriptorRegistry = async (
+// Descriptor custom options and future fields remain opaque identity facts.
+const descriptorEquality = { registry: createRegistry(), unknown: true };
+
+/** Admit descriptor identities and bound qualified names before registry construction. */
+const admitRegistryDescriptor = async (
descriptor: FileDescriptorSet,
budget: ProtobufBudget,
-): Promise => {
+): Promise> => {
+ const files = new Map();
+ const registryNames = new Set();
+ const duplicateType: ProtobufSchemaAdmissionFailure = {
+ kind: "malformed-descriptor",
+ field: "descriptor_set_path",
+ message: "Duplicate fully qualified type name in FileDescriptorSet",
+ };
+ const chargeQualifiedName = (name: string, prefix: string) =>
+ budget.charge((prefix.length + 1 + name.length) * 8 + 128);
+ const admitTypeName = (name: string, prefix: string): string | null => {
+ chargeQualifiedName(name, prefix);
+ budget.charge(32);
+ const qualified = prefix === "" ? name : `${prefix}.${name}`;
+ if (registryNames.has(qualified)) return null;
+ registryNames.add(qualified);
+ return qualified;
+ };
for (const file of descriptor.file) {
+ await budget.checkpoint();
budget.charge(file.name.length * 8 + 128);
+ const previous = files.get(file.name);
+ if (previous !== undefined) {
+ if (
+ !equals(FileDescriptorProtoSchema, previous, file, descriptorEquality)
+ )
+ return err({
+ kind: "malformed-descriptor",
+ field: "descriptor_set_path",
+ message: "Conflicting file identity in FileDescriptorSet",
+ });
+ continue;
+ }
+ files.set(file.name, file);
const stack = file.messageType.map((message) => ({
message,
- prefix: file.package.length,
+ prefix: file.package,
}));
- const chargeQualifiedName = (name: string, prefix: number) =>
- budget.charge((prefix + 1 + name.length) * 8 + 128);
for (const enumeration of file.enumType)
- chargeQualifiedName(enumeration.name, file.package.length);
+ if (admitTypeName(enumeration.name, file.package) === null)
+ return err(duplicateType);
for (const extension of file.extension)
- chargeQualifiedName(extension.name, file.package.length);
+ if (admitTypeName(extension.name, file.package) === null)
+ return err(duplicateType);
for (const service of file.service) {
- const length = file.package.length + 1 + service.name.length;
- budget.charge(length * 8 + 128);
+ const qualified = admitTypeName(service.name, file.package);
+ if (qualified === null) return err(duplicateType);
for (const method of service.method)
- chargeQualifiedName(method.name, length);
+ chargeQualifiedName(method.name, qualified);
}
while (stack.length > 0) {
await budget.checkpoint();
const { message, prefix } = stack.pop()!;
- const numbers = new Set();
+ const qualified = admitTypeName(message.name, prefix);
+ if (qualified === null) return err(duplicateType);
+ budget.charge(message.field.length * 32);
+ const fieldNumbers = new Set();
for (const field of message.field) {
- if (numbers.has(field.number))
- return {
+ chargeQualifiedName(field.name, qualified);
+ if (fieldNumbers.has(field.number))
+ return err({
kind: "malformed-descriptor",
field: "descriptor_set_path",
message: `Duplicate field number ${field.number} in descriptor message ${message.name}`,
- };
- numbers.add(field.number);
+ });
+ fieldNumbers.add(field.number);
}
- const length = prefix + 1 + message.name.length;
- budget.charge(length * 8 + 128);
- for (const field of message.field)
- chargeQualifiedName(field.name, length);
for (const extension of message.extension)
- chargeQualifiedName(extension.name, length);
+ if (admitTypeName(extension.name, qualified) === null)
+ return err(duplicateType);
for (const enumeration of message.enumType)
- chargeQualifiedName(enumeration.name, length);
+ if (admitTypeName(enumeration.name, qualified) === null)
+ return err(duplicateType);
for (const nested of message.nestedType)
- stack.push({ message: nested, prefix: length });
+ stack.push({ message: nested, prefix: qualified });
}
}
- return undefined;
+ return ok({ ...descriptor, file: [...files.values()] });
};
const scalarValue = (reader: BinaryReader, scalar: ScalarType): string => {
@@ -166,10 +206,17 @@ export class ProtobufSchema implements WireSchema {
redact,
new ProtobufSchema(FileDescriptorSetSchema, budget, redact),
);
+ const invalidField = checked.fields.find(
+ (field) => field.schema_status === "invalid-message",
+ );
const wireError =
checked.error ??
- checked.fields.find((field) => field.schema_status === "invalid-message")
- ?.schema_error;
+ (invalidField === undefined
+ ? undefined
+ : (invalidField.schema_error ?? {
+ offset: invalidField.offset,
+ reason: "Invalid descriptor message",
+ }));
if (wireError !== undefined)
return err({
kind: "malformed-descriptor",
@@ -179,7 +226,7 @@ export class ProtobufSchema implements WireSchema {
let descriptor: FileDescriptorSet;
try {
descriptor = fromBinary(FileDescriptorSetSchema, bytes, {
- readUnknownFields: false,
+ readUnknownFields: true,
recursionLimit: WEB_NETWORK_CAPTURE_LIMITS.depth,
});
} catch (cause) {
@@ -189,11 +236,11 @@ export class ProtobufSchema implements WireSchema {
message: `Malformed FileDescriptorSet: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
- const failure = await admitDescriptorRegistry(descriptor, budget);
- if (failure !== undefined) return err(failure);
+ const admitted = await admitRegistryDescriptor(descriptor, budget);
+ if (!admitted.ok) return admitted;
let message: DescMessage | undefined;
try {
- message = createFileRegistry(descriptor).getMessage(
+ message = createFileRegistry(admitted.value).getMessage(
typeName.replace(/^\./, ""),
);
} catch (cause) {
diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts
index 2af1e4e090..d5cf0e831d 100644
--- a/src/cli/binaryDiagnosticsCommands.ts
+++ b/src/cli/binaryDiagnosticsCommands.ts
@@ -23,6 +23,10 @@ import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
+import {
+ analysisEvidenceDeliveryOptions,
+ deliverCliAnalysisEvidence,
+} from "./analysisEvidenceDelivery.js";
/** Inspect operator-selected files through the same shared workflow as MCP. */
export const registerBinaryDiagnosticsCommands = (
@@ -173,6 +177,7 @@ export const registerBinaryDiagnosticsCommands = (
args: z.object({ path: z.string().describe("Local PE or ELF image") }),
options: z.object({
maxFileBytes: inspectNativeExportsInputSchema.shape.max_file_bytes,
+ ...analysisEvidenceDeliveryOptions,
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
@@ -181,8 +186,8 @@ export const registerBinaryDiagnosticsCommands = (
logger,
CLI_COMMANDS.inspectNativeExports,
async () =>
- finishAnalysisCommand(
- () =>
+ deliverCliAnalysisEvidence({
+ run: () =>
exports.inspect(
{
path: absoluteCallerPath(args.path),
@@ -190,11 +195,13 @@ export const registerBinaryDiagnosticsCommands = (
},
{ signal },
),
- exports,
- "rea-native-exports",
- "inspect_native_exports",
+ service: exports,
+ providerId: "rea-native-exports",
+ operation: "inspect_native_exports",
signal,
- ),
+ detail: options.detail,
+ evidenceOutput: options.evidence_output,
+ }),
);
}),
});
diff --git a/src/cli/gpuCommands.ts b/src/cli/gpuCommands.ts
index e42d2066fd..039bdfe99c 100644
--- a/src/cli/gpuCommands.ts
+++ b/src/cli/gpuCommands.ts
@@ -101,7 +101,7 @@ export const registerGpuCommands = (cli: CliInstance, logger: Logger): void => {
run: () =>
service.inspect(
{
- path: absoluteCallerPath(args.path),
+ path: selected.data.path,
analysis: selected.data.analysis,
...(selected.data.expected_sha256 === undefined
? {}
diff --git a/src/cli/modelCommands.ts b/src/cli/modelCommands.ts
index e6b3803bf6..0df6e94408 100644
--- a/src/cli/modelCommands.ts
+++ b/src/cli/modelCommands.ts
@@ -84,8 +84,8 @@ export const registerModelCommands = (
run: () =>
service.inspect(
{
- path: absoluteCallerPath(args.path),
- hash: options.hash,
+ path: selected.data.path,
+ hash: selected.data.hash,
detail: "complete",
},
{ signal },
diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts
index bba5f61877..cb19115c38 100644
--- a/src/contracts/analysisViewToolContracts.ts
+++ b/src/contracts/analysisViewToolContracts.ts
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
- 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. 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. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. 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: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. 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. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. 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. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. 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. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. 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.',
+ 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. 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. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. 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: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. 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. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. 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. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. 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. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters accept names, types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses accept addresses/states, preserving every folded alternative. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. 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/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts
index 732af307d6..65d938465b 100644
--- a/src/contracts/native/binaryDiagnosticsToolContracts.ts
+++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts
@@ -9,7 +9,10 @@ import {
inspectBinaryLayoutInputSchema,
binaryLayoutSchema,
} from "../../domain/native/binaryLayout.js";
-import { binaryLayoutSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
+import {
+ binaryLayoutSummaryViewResultSchema,
+ nativeExportsSummaryViewResultSchema,
+} from "../../domain/analysisView/analysisView.js";
import {
analysisDetailOutputSchemaOf,
analysisDetailSchema,
@@ -38,6 +41,9 @@ import {
export const inspectBinaryLayoutRequestSchema =
inspectBinaryLayoutInputSchema.extend({ detail: analysisDetailSchema });
+export const inspectNativeExportsRequestSchema =
+ inspectNativeExportsInputSchema.extend({ detail: analysisDetailSchema });
+
/**
* Public `inspect_byte_entropy` input. The shared constructor runs before
* `detail` is added so the advertised input stays closed.
@@ -123,9 +129,12 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_native_exports"),
kind: "native-provider",
description:
- "Inspect one explicit local PE or ELF image's export table without executing it or opening an engine: PE export-directory names, ordinals, addresses and forwarders, or ELF defined default/protected global, weak and unique .dynsym symbols with type, binding and symbol version. Ordinal-only, forwarded and hidden-version exports stay distinct. Portable on any supported host. Coverage is partial when table entries are unreadable and unsupported for an ELF without section headers; an empty complete inventory means the image declares no exports. Mach-O exports use inspect_macho.",
- inputSchema: inspectNativeExportsInputSchema,
- outputSchema: evidenceResultOf(nativeExportsSchema),
+ "Inspect one explicit local PE or ELF image's export table without executing it or opening an engine: PE export-directory names, ordinals, addresses and forwarders, or ELF defined default/protected global, weak and unique .dynsym symbols with type, binding and symbol version. Ordinal-only, forwarded and hidden-version exports stay distinct. Portable on any supported host. Coverage is partial when table entries are unreadable and unsupported when ELF dynamic symbols cannot be enumerated from the available section metadata; an empty complete inventory means the image declares no exports. Mach-O exports use inspect_macho. Omitted/complete detail preserves inline delivery. Select detail summary to retain complete Evidence and return identity, counts and source coverage. Query inspect_analysis_view kind native-exports, collection exports or issues, with exact names/original indices, fields and matching-row pagination. Names return every version or alias; source_index is scoped to the parent Evidence. ELF name projections keep raw-byte and encoding qualifications.",
+ inputSchema: inspectNativeExportsRequestSchema,
+ outputSchema: analysisDetailOutputSchemaOf(
+ nativeExportsSchema,
+ nativeExportsSummaryViewResultSchema,
+ ),
examples: [
{
title: "List a native library's exports",
diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts
index 825a0f261d..c58952cc1e 100644
--- a/src/domain/analysisView/analysisView.ts
+++ b/src/domain/analysisView/analysisView.ts
@@ -8,6 +8,12 @@ import {
apkSignatureSourceCoverageSchema,
} from "./apkSignatureView.js";
import { byteEntropySchema } from "../native/byteEntropy.js";
+import {
+ nativeExportsViewRequestSchema,
+ nativeExportsViewItemsSchema,
+ nativeExportsSummarySchema,
+ projectNativeExportsView,
+} from "./nativeExportsView.js";
import {
byteEntropySummarySchema,
projectByteEntropyView,
@@ -325,6 +331,7 @@ const nativeViewSchema = z.strictObject({
/** Caller-selected projection of already completed analysis Evidence. */
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
+ nativeExportsViewRequestSchema,
javaScriptRoutesViewRequestSchema,
nativeFunctionMatchingViewRequestSchema,
sourceToBundleViewRequestSchema,
@@ -443,6 +450,7 @@ const viewResultBase = {
parent_evidence_id: prefixedDigestSchema("ev"),
parent_operation: z.enum([
"inspect_byte_entropy",
+ "inspect_native_exports",
"inspect_binary_layout",
"inspect_go_binary",
"gdb_console",
@@ -490,6 +498,12 @@ export const byteEntropySummaryViewResultSchema = z.strictObject({
parent_operation: z.literal("inspect_byte_entropy"),
});
+export const nativeExportsSummaryViewResultSchema = z.strictObject({
+ ...summaryViewResultBase,
+ parent_operation: z.literal("inspect_native_exports"),
+ summary: nativeExportsSummarySchema,
+});
+
/** Opt-in command delivery keeps output once and retains the complete parent. */
export const gdbCommandSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -807,6 +821,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
view: environmentStateViewRequestSchema,
}),
z.discriminatedUnion("parent_operation", [
+ nativeExportsSummaryViewResultSchema,
byteEntropySummaryViewResultSchema,
gdbCommandSummaryViewResultSchema,
objdumpSummaryViewResultSchema,
@@ -835,6 +850,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
nativeFunctionMatchingSummaryViewResultSchema,
sourceToBundleSummaryViewResultSchema,
]),
+ z.strictObject({
+ ...viewResultBase,
+ kind: z.literal("native-exports"),
+ parent_operation: z.literal("inspect_native_exports"),
+ view: nativeExportsViewRequestSchema,
+ exports: nativeExportsSummarySchema,
+ items: nativeExportsViewItemsSchema,
+ }),
z.strictObject({
...viewResultBase,
...nativeInventoryViewContext,
@@ -1190,6 +1213,10 @@ export const projectAnalysisView = (
const projected = projectPcapCaptureView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
+ if (parent.operation === "inspect_native_exports") {
+ const projected = projectNativeExportsView(parent, view);
+ return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
+ }
if (parent.operation === "inspect_byte_entropy") {
const report = parseAnalysisViewResult(byteEntropySchema, parent);
if (!report.ok) return report;
diff --git a/src/domain/analysisView/nativeExportsView.ts b/src/domain/analysisView/nativeExportsView.ts
new file mode 100644
index 0000000000..97dd500528
--- /dev/null
+++ b/src/domain/analysisView/nativeExportsView.ts
@@ -0,0 +1,251 @@
+import { z } from "zod";
+import {
+ nativeExportsSchema,
+ peNativeExportsSchema as pe,
+ elfNativeExportsSchema as elf,
+ type NativeExports,
+} from "../native/nativeExports.js";
+import {
+ canBorrowParsedJsonSnapshot,
+ freezeOwnedJsonTreeSnapshot,
+ isImmutableJsonSnapshot,
+} from "../immutableJson.js";
+import type { AnalysisError } from "../analysisErrorBase.js";
+import { err, ok, type Result } from "../result.js";
+import type {
+ AnalysisViewParent,
+ AnalysisViewRequest,
+ UnsignedAnalysisView,
+} from "./analysisView.js";
+import { analysisViewInputError } from "./analysisViewErrors.js";
+import { parseAnalysisViewResult } from "./analysisViewParent.js";
+import {
+ boundedViewMessages,
+ completeWithinViewCoverage,
+ pageViewCoverage,
+} from "./analysisViewCoverage.js";
+import { selectViewRows } from "./selectViewRows.js";
+
+const peRow = pe.shape.exports.element;
+const elfRow = elf.shape.exports.element;
+const issue = pe.shape.issues.element;
+const sourceIndex = { source_index: z.int().nonnegative() };
+const page = {
+ kind: z.literal("native-exports"),
+ offset: z.int().nonnegative().default(0),
+ limit: z.int().positive().default(64),
+};
+
+/** Exact names preserve all versions/aliases; indices are scoped to the complete parent. */
+export const nativeExportsViewRequestSchema = z.discriminatedUnion(
+ "collection",
+ [
+ z.strictObject({
+ ...page,
+ collection: z.literal("exports"),
+ names: z.array(z.string()).optional(),
+ indices: z.array(z.int().nonnegative()).optional(),
+ fields: z.array(z.union([peRow.keyof(), elfRow.keyof()])).optional(),
+ }),
+ z.strictObject({
+ ...page,
+ collection: z.literal("issues"),
+ indices: z.array(z.int().nonnegative()).optional(),
+ }),
+ ],
+);
+
+export const nativeExportsViewItemsSchema = z.union([
+ z.array(peRow.partial().extend(sourceIndex)),
+ z.array(
+ elfRow.partial().extend({
+ ...sourceIndex,
+ // Historical defaults normalize complete source rows, never omitted columns.
+ name_bytes_base64: elfRow.shape.name_bytes_base64
+ .removeDefault()
+ .optional(),
+ name_utf8_decodable: elfRow.shape.name_utf8_decodable
+ .removeDefault()
+ .optional(),
+ }),
+ ),
+ z.array(issue.extend(sourceIndex)),
+]);
+
+const counts = {
+ export_count: z.int().nonnegative(),
+ issue_count: z.int().nonnegative(),
+ limitation_count: z.int().nonnegative(),
+};
+/** Source coverage remains distinct from page coverage, including an empty partial page. */
+export const nativeExportsSummarySchema = z.discriminatedUnion("format", [
+ pe.omit({ exports: true, issues: true, limitations: true }).extend(counts),
+ elf.omit({ exports: true, issues: true, limitations: true }).extend(counts),
+]);
+
+// This validates exact immutable Evidence, never a path or current filesystem state.
+// Weak keys release their parsed representation with the owning retained record.
+const checked = new WeakMap