diff --git a/.agents/skills/reverse-engineer-anything/references/environments.md b/.agents/skills/reverse-engineer-anything/references/environments.md index f2497af61e..4185c46163 100644 --- a/.agents/skills/reverse-engineer-anything/references/environments.md +++ b/.agents/skills/reverse-engineer-anything/references/environments.md @@ -88,20 +88,37 @@ shape, with the application's own locators, paths and headers: "path": "/api/folders", "expected_status": [200] }, + { + "step_id": "new-folder-1", + "action": "click", + "timeout_ms": 15000, + "locator": { "kind": "role", "role": "button", "name": "New folder" } + }, + { + "step_id": "folder-title-1", + "action": "fill", + "timeout_ms": 15000, + "locator": { "kind": "css", "selector": "input[name=title]" }, + "value": { "source": "literal", "value": "Scratch folder 1" } + }, { "step_id": "create-folder-1", + "action": "click", + "timeout_ms": 15000, + "locator": { "kind": "role", "role": "button", "name": "Create" }, + "save_response": { + "method": "POST", + "path": "/api/folders", + "save": [{ "name": "folder_1", "json_pointer": "/id" }] + } + }, + { + "step_id": "read-folder-1", "action": "request", "timeout_ms": 15000, - "method": "POST", - "path": "/api/folders", - "json": { "title": "Scratch folder 1" }, - "headers": [ - { - "name": "X-CSRFToken", - "value": { "source": "cookie", "name": "csrftoken" } - } - ], - "expected_status": [200, 201] + "method": "GET", + "path": "/api/folders/{saved:folder_1}", + "expected_status": [200] } ] } @@ -117,13 +134,19 @@ client with it. Match `wait_for` locators to exactly one element. 2. Read with `request` steps: each collection the environment needs, twice before any write, so fields that change on their own show up; one item of each. -3. Use `request` steps only when the API lets the caller choose each scratch - object's ID or a prior read already established it. Request paths are fixed - strings; no action substitutes a value from an earlier response. For - server-assigned IDs, create through the UI and open the new object's detail - view to record its read, edit and delete through UI actions. If that journey - cannot be scripted, leave the write unserved and report the limitation; - never guess an ID or create objects with no supported cleanup path. +3. Write the way the application's UI does: fill its forms and click its + buttons, so each request carries the fields the UI sends, including ones + the server would otherwise derive. Give the click or key press that submits + a create `save_response` to keep the new object's ID, then `goto` its page + (`/items/{saved:item_1}`) or read it with a `request` step. Never guess an + ID, and never create a probe object to learn the next one. Write with + `request` steps instead only when the UI cannot reach the operation (an + API-only resource, or a UI that does not call the JSON API), or when coverage + withholds the UI-shaped write as `unexplained-change` or `contradicted`; + that counts as one of the two re-recordings. A `request` step write that + coverage withholds that way usually lacks fields the UI sends: record it + through the UI instead. If a journey cannot be scripted, leave the write + unserved and report the limitation. Record two numbered scratch objects per write type, with varied free text. After each write, and before the next one, read the object back by id and then its list: create 1, read 1, list, create 2, @@ -143,13 +166,34 @@ About `request` steps: - `path` is on the start URL's origin; the page must be on that origin. - Header values are literals, declared secrets, a cookie the browser holds - (`{source: "cookie", name}`), or a local storage entry - (`{source: "local_storage", key, json_pointer}`), with an optional - `prefix` such as `"Bearer "`. Copy the headers the application's own - requests send (a CSRF header, a bearer token). Values read from the page - are redacted from records made after they are read, not from the login - response that returned them, and values shorter than 8 characters are not - redacted at all. + (`{source: "cookie", name}`), a local storage entry + (`{source: "local_storage", key, json_pointer}`), a saved value + (`{source: "saved", name}`), or Basic credentials + (`{source: "basic_credentials", parts: [username, password]}`, sent as the + base64 of `username:password` and redacted), with an optional `prefix` such as + `"Bearer "` or `"Basic "`. Each credential part is a literal, secret, saved + value, cookie or local storage entry, so a header the frontend builds from a + login token (the username and the token) can be copied too; the password is + a secret or saved value, never a literal. Copy the + headers the application's own requests send (a CSRF header such as + `X-CSRFToken` from the `csrftoken` cookie, a bearer token). Values read from the page are redacted from records made after they + are read, not from the login response that returned them, and values + shorter than 8 characters are not redacted at all. +- `save: [{name, json_pointer, secret}]` keeps a string or number from the + step's JSON response, such as the created object's `/id`, for later steps; + `secret: true` redacts it, as for a token the API returns, so mark every + saved token, key or session value secret. This also protects the selected + source response before its body or headers are retained. Compressed or + unknown-size UI responses cannot supply saved values. On a `click` or `press`, + `save_response: {method, path, save}` keeps values from the response to the + new request observed while the step waits (`{*}` in `path` matches one segment). + Matching does not prove UI causality: choose a path distinct from polling; + multiple observed matches fail as ambiguous. The step + fails unless a 2xx JSON response arrives before its `timeout_ms`. Later + steps send a saved value as `{saved:name}` in a request `path` (and its + query) or a `goto` URL; in the `json` body, where a string that is only + `{saved:name}` keeps the saved JSON type; or as `{source: "saved", name}` in + a header or credential part. - Reading a page value makes later bodies costlier to keep: after a CSRF token, bodies over about 47 KiB are kept as `unavailable`. When the API returns larger responses, raise `capture.limits` @@ -198,8 +242,10 @@ withheld with its hint: | `contradicted` | Stop: a later read disagrees with the modelled effect. | | `body-unusable` | Re-record a UI write once with `request` steps (its body was not exposed); else stop. | -Use new scratch names on each attempt; the first attempt's objects may still -exist. +Use new scratch names on each attempt, and delete what an earlier attempt +created before recording again (through the UI, or a `request` step with the +id that capture shows): the environment starts from what the recording first +reads, so a leftover object becomes seeded data that write tasks then find. New browser scenario captures include request times, so a fully timed capture can learn a write clock and serve updates that stamp modification times. Older captures or ones missing a request start keep record order and learn no clock. diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 1b107c3516..d1f8bd141f 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -2099,10 +2099,7 @@ public final class ReaGhidraBridge extends HeadlessScript { requireKeys(params, Set.of("document", "address")); requireDocument(params); Address query = requireAddress(params, "address"); - Function function = currentProgram.getFunctionManager().getFunctionAt(query); - if (function == null && query.isMemoryAddress()) { - function = currentProgram.getFunctionManager().getFunctionContaining(query); - } + Function function = functionAtOrContaining(query); JsonObject result = new JsonObject(); result.addProperty("query_address", canonicalAddress(query)); if (function != null) { @@ -2118,10 +2115,9 @@ public final class ReaGhidraBridge extends HeadlessScript { } private JsonArray search(JsonObject params, boolean procedureSearch) throws Exception { - requireKeys( - params, - Set.of("pattern", "mode", "case_sensitive", "document") - ); + Set keys = new HashSet<>(Set.of("pattern", "mode", "case_sensitive", "document")); + if (params.has("literal_ascii_candidate")) keys.add("literal_ascii_candidate"); + requireKeys(params, keys); requireDocument(params); String expression = requireString(params, "pattern"); String mode = requireString(params, "mode"); @@ -2132,9 +2128,18 @@ public final class ReaGhidraBridge extends HeadlessScript { List inventory = procedureSearch ? procedures().stream().map(FunctionEntry::item).toList() : strings(); - ValueMatcher matcher = mode.equals("literal") - ? literalMatcher(expression, caseSensitive) - : regexMatcher(expression, caseSensitive); + ValueMatcher matcher; + if (mode.equals("literal") && !caseSensitive) { + if (!params.has("literal_ascii_candidate")) + throw new RequestFailure("invalid_request", "Insensitive literal search requires an ASCII candidate"); + matcher = literalCandidateMatcher(optionalString(params, "literal_ascii_candidate")); + } else { + if (params.has("literal_ascii_candidate")) + throw new RequestFailure("invalid_request", "ASCII candidate applies only to insensitive literals"); + matcher = mode.equals("literal") + ? literalMatcher(expression) + : regexMatcher(expression, caseSensitive); + } JsonArray items = new JsonArray(); for (InventoryItem item : inventory) { monitor.checkCancelled(); @@ -2291,6 +2296,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return function; } + /** Exact entries include externals; containment applies only to memory addresses. */ private Function functionAtOrContaining(Address address) { Function function = currentProgram.getFunctionManager().getFunctionAt(address); return function == null && address.isMemoryAddress() @@ -2995,7 +3001,7 @@ public final class ReaGhidraBridge extends HeadlessScript { if (!currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) { return null; } - Function function = containingFunction(jumpTable.getSwitchAddress()); + Function function = functionAtOrContaining(jumpTable.getSwitchAddress()); if (function == null) return null; List instructions = new ArrayList<>(); @@ -3495,7 +3501,7 @@ public final class ReaGhidraBridge extends HeadlessScript { private boolean unrepresentedCaller(Reference reference, Set callers) { if (!reference.getReferenceType().isCall()) return false; - Function caller = containingFunction(reference.getFromAddress()); + Function caller = functionAtOrContaining(reference.getFromAddress()); return caller == null || !callers.contains(caller); } @@ -3642,7 +3648,7 @@ public final class ReaGhidraBridge extends HeadlessScript { void observe(Reference reference) { if (!reference.getReferenceType().isCall()) return; // Reference identity follows the same containing-function owner as referenceEdge. - Function target = containingFunction(reference.getToAddress()); + Function target = functionAtOrContaining(reference.getToAddress()); if (returnedCallees != null && (target == null || !returnedCallees.contains(target))) unrepresented++; if (target == null) unresolved(reference.getFromAddress(), @@ -3812,8 +3818,8 @@ public final class ReaGhidraBridge extends HeadlessScript { JsonObject result = new JsonObject(); result.addProperty("source_address", canonicalAddress(reference.getFromAddress())); result.addProperty("target_address", canonicalAddress(reference.getToAddress())); - Function source = containingFunction(reference.getFromAddress()); - Function target = containingFunction(reference.getToAddress()); + Function source = functionAtOrContaining(reference.getFromAddress()); + Function target = functionAtOrContaining(reference.getToAddress()); result.add( "source_procedure", source == null ? JsonNull.INSTANCE : procedureIdentity(source) @@ -3848,14 +3854,6 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } - private Function containingFunction(Address address) { - Function result = currentProgram.getFunctionManager().getFunctionAt(address); - if (result == null && address.isMemoryAddress()) { - result = currentProgram.getFunctionManager().getFunctionContaining(address); - } - return result; - } - private static String procedureName(Function function) { Symbol symbol = function.getSymbol(); return symbol == null ? function.getName() : symbol.getName(true); @@ -4707,11 +4705,18 @@ public final class ReaGhidraBridge extends HeadlessScript { return source.name().toLowerCase(Locale.ROOT); } - private static ValueMatcher literalMatcher(String pattern, boolean caseSensitive) { - String needle = caseSensitive ? pattern : pattern.toLowerCase(Locale.ROOT); + private static ValueMatcher literalMatcher(String pattern) { + return value -> value.contains(pattern); + } + + // REA's host owns Unicode simple folding. Keep all non-ASCII source values + // for its final predicate, and filter ASCII-only values without Java regexes. + private static ValueMatcher literalCandidateMatcher(String asciiPattern) { + if (asciiPattern != null && asciiPattern.chars().anyMatch(character -> character > 127)) + throw new RequestFailure("invalid_request", "ASCII candidate contains a non-ASCII character"); return value -> { - String candidate = caseSensitive ? value : value.toLowerCase(Locale.ROOT); - return candidate.contains(needle); + if (value.chars().anyMatch(character -> character > 127)) return true; + return asciiPattern != null && value.toLowerCase(Locale.ROOT).contains(asciiPattern); }; } diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 364cf8f308..07a5af573b 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -205,7 +205,10 @@ attached and a transaction no record represents, and each transaction URL's query once. Bodies larger than 4 MiB of UTF-8 text are not scanned; `credential-scan-incomplete` counts them and lists up to 20 record or event pointers, including base64 bodies whose decoded scan was cut. An empty name -warning does not establish that these bodies were checked. Names do not establish sensitivity, +warning does not establish that these bodies were checked. A browser scenario's +saved values (`save` and `save_response`) named like credentials but not marked +`secret: true` are recorded unredacted; `credential-named-saved-values` lists up +to 20 of them by parameter pointer. Names do not establish sensitivity, so the build never refuses on them. Capture Evidence also records the capture file's absolute path (`subject.local_path`, diff --git a/docs/android-analysis.md b/docs/android-analysis.md index d9dbef8027..05c7e67fb1 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -30,8 +30,9 @@ code units; follow `coverage.next_offset`. The page preserves `source_status` an `reported_total_bytes`: exhausting retained text does not make a partial decoded manifest complete. Complete source Evidence remains exportable under the existing session lifetime. Servers without retention reject summary before starting JADX. -One-shot CLI output remains complete. Older servers require the existing complete -request after schema discovery. APK authenticity and runtime behavior remain +One-shot CLI output remains complete by default. Select `--detail summary` with +`--evidence_output /local/capture.json` to retain complete Evidence before returning +a linked view. Older servers require the existing complete request after schema discovery. APK authenticity and runtime behavior remain unverified. ## Manifest component projection @@ -76,6 +77,61 @@ capacity limitation appears only when an actual collection limit was reached. The projection does not merge manifests, expand resource references, or observe runtime enabled/export state. +`enabled` and `grant_uri_permissions` report only literal component-level +`android:enabled` and provider-level `android:grantUriPermissions` declarations. +They are `null` when absent or not a literal boolean; missing attributes are not +filled from application defaults. A nonliteral declaration gets a limitation. +`process` preserves the exact component-level `android:process` spelling (including +empty or symbolic values), or `null` when absent or omitted under the inventory-wide +1 MiB copy budget. Neither field resolves resources, merges manifest values or +establishes the effective runtime process or enabled state. The exact boolean +attributes are retained in `enabled_declaration` and +`grant_uri_permissions_declaration`, including resource references. + +`intent_filters` preserves each filter's source ordinal, exact priority spelling, +actions, categories and individual data declarations (scheme, host, port, path, +path prefix/pattern/advanced pattern/suffix and MIME type). These are declarations, +not computed Android matching rules. `meta_data` lists names; values remain in the +complete decoded manifest. Component, filter, data and metadata `location` values +use one-based lines/columns and UTF-16 offsets into that manifest. A component's +`declaration_index` preserves its original declaration ordinal, including gaps. + +`declaration_coverage` distinguishes complete, partial and unknown observations. +Historical Evidence omits newly introduced fields and defaults coverage to +`unknown`; omission never establishes absence. Current records contain the fields, +with null for absent or unresolved scalar declarations. The inventory retains at +most 32 filters and 64 nested action/category/data/metadata records per component, +and shares a 1 MiB structured-value copy budget across components. Omitted values +or unsupported filter constructs mark coverage partial and include a limitation; +complete retained manifest text remains accessible. + +For a small answer, select `detail: "summary"` with an explicit view: + +```json +{ + "kind": "android", + "facet": "components", + "actions": ["android.provider.Telephony.SMS_RECEIVED"], + "fields": ["name", "permission", "enabled", "process", "intent_filters"], + "offset": 0, + "limit": 16 +} +``` + +The same JSON works with CLI `--view` or retained `inspect_analysis_view`. +`kinds`, `names` and `actions` use exact matching; names accept either the recorded +declared or resolved name, actions match any requested action, and different +filters combine with AND. `exported` matches only explicit declarations, keeping +`exported_default` separate. Empty selectors match nothing. Every selected row +retains its parent-array `source_index` and `declaration_coverage`; source coverage, +parent identity and limitations remain alongside the page. Follow +`coverage.next_offset`. The 1 MiB projection budget can shorten a page; a single +oversized row requires fewer selected fields. Exhausting a partial source does +not establish complete manifest coverage. `facet: "component-summary"` applies +the same filters and counts kinds, declaration coverage and explicit/default +exported values separately. Both adapters use the same retained projection; +repeated pages do not reload JADX. + The service `foreground_service_type` field preserves the declared attribute, including an empty value or surrounding spaces; `null` means the attribute is absent or the component is not a service. This field does not validate service diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 015b835ecb..91603c944e 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -21,14 +21,14 @@ graph TD subgraph "Application Layer" APP["src/application/
Shared CLI/MCP workflows
enhanced analysis and investigations"] SESSION["SessionProviderRouter + BinarySession
one immutable deep binding per target
disjoint auxiliary routing"] - REGISTRY["AnalysisProviderRegistry
sorted deep candidates
deterministic selection, no fallback"] + REGISTRY["AnalysisProviderRegistry
sorted deep candidates
deterministic selection, no fallback
ProviderInspectionScope: prerequisite observation lifetime"] RECORDS["src/application/investigation/
composed InvestigationRecords + narrow ports
EvidenceLedger and Unknown ownership"] SNAPSHOT["BinarySessionRecords + AnalysisSnapshotCache
binary target/profile snapshots and session file ownership
record coordination and post-commit notifications"] JS_RECON["src/application/javascript/ + src/artifacts/javascript/
JavaScript artifact reconstruction
directory/ASAR reads
AST-only bundle projection"] end subgraph "Provider Layers (selected adapters)" - PROVIDER_OWNERS["src/providers/
shared deferred client and inspection owners
exact cleanup retry and attributed failures"] + PROVIDER_OWNERS["src/providers/
shared deferred client owners
exact cleanup retry and attributed failures"] PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/
BYO offline ELF/core adapters
owned snapshots/Python and optional core-only GDB
unchanged upstream parsers, ABI structures and mapping API"] WASM["src/wasm/
caller-supplied WABT; immutable artifact inspection
owned process supervision and retryable workspace cleanup"] APKSIG["src/apkSignature/
in-process APK Signing Block + v1 reader
bounded single read, no device or execution"] diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 6b655b8a64..5c557d34f3 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -51,6 +51,30 @@ covers endpoint discovery only. The following WebSocket handshake and `Target.attachToTarget` each use `command_timeout_ms`. `capture.limits` controls retained event and network-content budgets; it does not change those liveness deadlines. +## Validate a scenario before running it + +`validate-browser-scenario INPUT_JSON` checks one scenario against the same +`browserScenarioSchema` refinement that `capture_browser_scenario` admits, and +stops there. `INPUT_JSON` may be inline JSON, a JSON file path, or `-` for +standard input. + +```bash +rea validate-browser-scenario ./browser-scenario.json --json +``` + +A valid scenario reports `{"valid":true,"actions":,"secrets":}` +and exits 0. An invalid one exits 1 and reports the same field paths capture +would report, so a correction made here is the correction capture needs: +duplicate or reserved step IDs, duplicate secret identities and +environment-variable names, undeclared secret references, request-step +violations, and unknown fields. + +Validation performs no browser discovery, launch, attachment, or navigation and +constructs no provider. It does not read secret environment variables, so a +scenario whose declared variables are unset still validates; the missing-value +failure belongs to capture. A schema-valid scenario does not prove that the +executable or target is available on the host. + ## Minimal launch example Save this as `browser-scenario.json`, replacing the executable path with an @@ -213,7 +237,8 @@ HTTP(S) URLs may include ordinary query values and fragments directly. Use ordered structured query entries when a value needs to reference a declared environment-backed secret. Form, storage, and cookie values are either literal strings or declared secret references; request step headers can also read a -cookie or local storage value from the page. Raw URL userinfo credentials are rejected. +cookie or local storage value from the page, and request steps and `goto` +destinations can send [saved values](#saved-values). Raw URL userinfo credentials are rejected. Captured credential-header values are not retained, and declared secret values are redacted automatically. A secret may be declared solely to @@ -406,6 +431,17 @@ data you may change. This scenario creates one item: by default) get smaller: after a 32-character `X-CSRFToken` value, a body over about 47 KiB is kept as `unavailable`. Raise `capture.limits` when the application's responses are larger. +- A header can send Basic credentials, `{"source": "basic_credentials", "parts": + [username, password]}`: the base64 of `username:password`, after a `prefix` + such as `"Basic "` or with none, as some APIs take encoded credentials in + their own header. Each part is a literal, a declared secret, a saved value, a + cookie or a local storage entry, so a header a frontend builds from a login + token can be sent too; the password is never a literal other than empty, since + Evidence records literals as written, and a username with a colon fails the + step, since Basic authentication ends the username at its first colon. The + encoded value is redacted however short, as + `[REDACTED:page:.
]`, and parts read from the page are + redacted as page values are. - Validation refuses a header name given twice in a step, header names a page's `fetch` cannot set, such as `Cookie`, `Origin`, `Host` or `Sec-*`, and `User-Agent`, which Chrome drops; it refuses line breaks and other ASCII @@ -431,6 +467,142 @@ Captures with request steps state in `limitations` that the steps sent their declared requests through the page's fetch. A scenario without request steps is unchanged. +## Saved values + +A step can keep a string or number from a JSON response, by name, and a later +step can send it, so a scenario can read back, edit and delete an object whose +id the application assigns: + +```json +{ + "browser": { + "mode": "launch", + "executable_path": "/absolute/path/to/chromium" + }, + "start_url": { "url": "http://127.0.0.1:3000/items" }, + "actions": [ + { + "step_id": "save-item-1", + "action": "click", + "timeout_ms": 10000, + "locator": { "kind": "role", "role": "button", "name": "Save" }, + "save_response": { + "method": "POST", + "path": "/api/items", + "save": [{ "name": "item_1", "json_pointer": "/id" }] + } + }, + { + "step_id": "open-item-1", + "action": "goto", + "timeout_ms": 10000, + "destination": { "url": "http://127.0.0.1:3000/items/{saved:item_1}" }, + "wait_until": "load" + }, + { + "step_id": "copy-item-1", + "action": "request", + "timeout_ms": 10000, + "method": "POST", + "path": "/api/items/{saved:item_1}/copies", + "json": { + "source_id": "{saved:item_1}", + "title": "Copy of {saved:item_1}" + }, + "expected_status": [201] + } + ] +} +``` + +- A `request` step saves with `save: [{name, json_pointer, secret}]`, from its + own response. A `click` or `press` step saves with `save_response: {method, + path, save}`, from a newly observed matching response while the step waits. + The recorder watches before the action and excludes requests already running, + such as an earlier autosave. Method/path matching does not prove that the UI + caused the request: use a path that distinguishes the intended operation from + background polling. Multiple observed matches before binding fail as ambiguous; + requests starting after this observation window cannot be ruled out. It matches the method and a path on the + start origin as the browser spells it, so a path may name `My List` or + `My%20List` (`{*}` matches one segment, and the query is ignored), and fails + the step unless a 2xx JSON response arrives before its `timeout_ms`; one still + being read when the step times out or is cancelled saves nothing. A pointer + that names no string or number fails the step, so later steps never send a + missing value, as does a response over 4,000,000 characters: a request step + stops reading and aborts its fetch there. A UI step reads a body only when + its positive received size is known, at most 12,000,000 bytes, and its + Content-Encoding is absent or identity. Compressed responses and unknown or + zero cache sizes are refused before materializing the body; decoded text + over the character limit also fails. The UI action may already have applied. +- `{saved:name}` sends a saved value in a request step's `path`, including its + query, and in a `goto` destination's `url`, percent-encoded; a destination's + structured `query` values are sent as declared. A saved value that makes a + path segment `.` or `..`, alone or with the text beside it (including `%2e`), + fails the step, since the URL would drop it. In a request step's `json` body, + a string that is only `{saved:name}` becomes the saved value with the JSON + type it was saved with, so a saved number id is sent as a number, and + `{saved:name}` inside a longer string is filled as text. A header value or + Basic credentials part sends one as `{"source": "saved", "name"}`. Other + braces, such as `{id}`, are sent as written, and so is a percent-encoded + `%7Bsaved:name%7D` anywhere but a `goto` URL, whose parsed form spells the + braces that way. +- Validation refuses a saved value that no earlier step saves. A later save of + the same name replaces the value. An integer past 2^53, which JSON parsing + would round to another number, and a number that is not finite, such as + `1e400`, negative zero, or a value that underflows to zero, such as `1e-400`, fail the step + that saves it. +- A scenario keeps at most 4,000,000 characters of saved values at once, + counting distinct retained redaction spellings, including trimmed variants, + of values saved as secret. Replacing a binding releases its live value + charge, while secret spellings remain protected for the scenario lifetime. + A save past the budget fails before changing the binding. +- A value saved with `secret: true` is redacted however short, as a declared + secret is, as `[REDACTED:page:.]`. The selected source + response is protected before retaining its body or header values, including + when only headers were requested. If that body cannot be read safely, its + body and header values remain unavailable. Before publishing the capture, + the selected source transaction's earlier URL, request headers and request + body are also protected using the admitted values. Unresolved source values + withhold that transaction's affected metadata/content with an explicit + limitation. Before publication, retained events are rechecked + with the final marked-response values, including immediate follow-up requests + and their response bodies, console/page errors, WebSocket text and event URL/name fields. Learned redaction facts are independent of source-identity readiness. Unknown source values withhold only the selected + source transaction. Other saved values, such as ids, stay visible, since + environments model them. The environment build warns about saved values + named like credentials that are not marked secret + (`credential-named-saved-values`). +- Page-derived cookie/local-storage values and constructed Basic credentials + remain redacted only from observations made after they were read/built. + They do not retroactively rewrite earlier facts. The final event projection + borrows only declared and response-marked patterns; promotion of an already + held page value to an explicit response secret preserves its first marker. + Projection state is session-local, references admitted pattern/byte owners, + and is invalidated on admission or response classification changes. +- Screenshots are withheld as `missing` from the start when the scenario plans + any response save marked `secret: true`, page-derived or Basic authentication + header values, or whenever nonempty sensitive values + are already known. Raw pixels cannot be safely text-redacted, so the provider + does not request pixels in those cases. This also protects earlier steps and + partial failures without retaining an image to scrub later. Other requested + artifacts remain available; affected screenshot/aggregate completeness is + partial and is not eligible for equality. +- Saved URLs and expanded request JSON are admitted before building their expanded strings or + serializing. Its allowance is the smaller of Node's string limit and + one sixteenth of current V8 available heap bytes, allowing for the expanded + tree, serialization, Playwright transport and browser-side copies. Size + accounting includes JSON escaping and repeated references; each distinct + saved value's size is measured once per request. An oversized request fails + before browser dispatch; the complete request envelope, including repeated + header values, is checked as well. URL encoding uses a conservative + nine-characters-per-UTF-16-unit allowance before encoding a saved value. + Reduce the payload/repetitions, or raise the parent + Node heap and restart; this is a conservative allocation estimate, not a + measurement or guarantee of browser/process-family memory. +- Evidence records a request step's empty `save` list as absent, so a scenario + that saves nothing records the same parameters and `scenario_sha256` as + before saved values existed, and REA versions without saved values read its + captures. + ## Network evidence `capture.events: ["network"]` retains request, response, completion, failure, diff --git a/docs/installation.md b/docs/installation.md index daec23ad7f..af9a96bff3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -84,6 +84,13 @@ For an existing configuration, setup and uninstall recovery refuse a detected replacement or edit and name the recovery backup. Quit clients before editing their configuration; these checks do not lock out another writer modifying the same file in place, or exclude pathname changes between the final check and publication. +Setup refreshes an existing `rea` registration only when its launcher proves REA +ownership. Foreign or unresolved launcher identity blocks setup before any +backup or write, including an OpenCode legacy entry that migration would remove. +Review the existing launcher and manually rename or remove that registration to +approve replacing it. A different existing installation path is foreign to the +current entrypoint; a path that cannot be resolved remains unknown. Cordis uses +its stable REA entry ID instead of launcher identity. Setup also retains valid environment overrides in REA's existing server entry, such as `REA_MCP_INPUT_SCHEMA_PROFILE`, `REA_MCP_OUTPUT_SCHEMA_PROFILE`, and `REA_MCP_MAX_RESPONSE_BYTES`, and `REA_MCP_TOOL_FAMILIES`. diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index f166814287..6bda719d9c 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -72,14 +72,17 @@ code-point order. Dependency manifests remain package observations but do not declare Electron main or renderer entries. If only dependency manifests are present, the inventory artifact remains the graph root. -Package observations keep the legacy `renderer` property as a derived entrypoint -candidate: the first nonempty string from `renderer`, `browser`, then `module`. -It does not assert that the source declares a literal `renderer` field. -`renderer_declarations` records the actual present fields: strings, including -empty strings, remain exact; non-string values use `{ "status": "non-string" }` +Package observations use `renderer_declarations` to record the actual present +fields: strings, including empty strings, remain exact; non-string values use `{ "status": "non-string" }` without copying nested browser mappings. An empty record means no such fields were present; `null` means package metadata was invalid or unavailable. Renderer -role mechanisms and package load edges name the field that supplied the candidate. +role mechanisms and package load edges name the field that supplied the candidate, +selected as the first nonempty string from `renderer`, `browser`, then `module`. +The package load edge retains `declared_path`, `source_field`, and resolution +facts for that selection. New analyses no longer emit the derived package +`renderer` alias; consumers must read declarations for source metadata and load +edges for the selected candidate. Previously retained Evidence keeps its original +properties and remains readable without migration. ## Select sources before analysis diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index c21c25f529..6d418aed92 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -660,13 +660,15 @@ name and raw signature digest available while marking that signature malformed and coverage partial. A fat method header's nonzero LocalVarSigTok must name an existing StandAloneSig row whose blob is a structurally valid LocalVarSig. REA validates its local -types and exact blob coverage but does not project their identities. An invalid +types and exact blob coverage, retaining `local_var_signature` with the raw blob +digest and whether named types, modifiers, or generic variables require metadata +or declaring context. Historical Evidence defaults this field to null. An invalid token or malformed signature leaves decoded CIL anchors intact but marks the body malformed and coverage partial. Unsupported local type encodings retain the readable body with partial coverage. Zero continues to mean no locals -signature. Comparison detects whether a locals declaration is present, but raw -table tokens are not cross-build identities and local type contents are not -compared. +signature. Raw table tokens are not cross-build identities. Comparison uses +validated context-free signature digests; signatures requiring metadata or +declaring context remain unknown across artifacts. ## Decoded-CIL fingerprint @@ -868,9 +870,14 @@ MethodDef flags or implementation flags value differs, including accessibility or synchronization changes. Observed method-body `init_locals` and `max_stack` differences also contribute to `metadata`, even when CIL decoding is incomplete. For complete bodies, presence or absence of a locals signature also contributes; -the locals table token is build-local and is not compared. Local type contents -are not currently compared, so this facet does not establish equivalent locals -declarations. +the locals table token is build-local and is not compared. Different validated +context-free LocalVarSig digests report a metadata encoding difference without +asserting a semantic type change. Equal digests establish unchanged bytes only +when neither signature requires metadata or declaring context. Historical +unrecorded signatures and context-dependent signatures keep local identity +unknown, preserving any independently observed changes. The same method token +in an artifact with the same authenticated SHA-256 identifies the same local +signature bytes, including for retained historical Evidence. An unavailable header value keeps body coverage unknown rather than establishing equality or a difference. These comparisons are separate from the CIL/signature matching tiers: identical instructions do not establish unchanged method metadata. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index a950045411..4d3a7545b4 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1491,6 +1491,8 @@ validates every result before delivery. On catalogs large enough that full input schemas alone approach the frame limit, keep the input profile at its `compact` default as well; with full input schemas the advertisement is dominated by the input side. +The full input profile has no one-megabyte catalog-frame guarantee, including +when output schemas are omitted. Only client-side schema discovery and validation are reduced or omitted, so hosts using these profiles should rely on REA's validation and retain their diff --git a/docs/native-investigation.md b/docs/native-investigation.md index b9978aee35..9fb4241f51 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -332,7 +332,10 @@ back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument. -Literal mode retains Unicode casefold matching. +Literal mode retains Python Unicode full case folding, which can expand a +character into a sequence: for example, `ss` matches `ß`. This differs from +REA's Unicode simple case folding used by other native search providers and +Hopper regex mode. ## Bounded static traces diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md index dcf0e91ada..567b1e62ca 100644 --- a/docs/pcap-analysis.md +++ b/docs/pcap-analysis.md @@ -81,9 +81,13 @@ Its separate inferred truncation lengths do not replace that limit; an unset file-header limit remains `null`. Addresses count once per packet, including loopback and nested IP occurrences. -DNS addresses are associated only with one question in the same response packet; -additional records may be included, so this does not prove final resolution. -Ambiguous addresses remain in a separate inventory. Protocol distribution counts +DNS address attribution requires one question in one response and complete, +consistent RR owner/type and section counts. Only answer-section A/AAAA records +matching that question (or its unambiguous answer-section CNAME chain) are +associated, with the requested address type respected. Authority, additional, +unrelated, malformed, and ambiguous addresses remain in the unattributed +inventory and make coverage partial. These packet-local relationships do not +prove final resolution or destination authenticity. Protocol distribution counts exact nonempty `frame.protocols` stack strings. Frames for which TShark reports an empty stack, including zero-length packets, still contribute to the packet count without adding a protocol row. An all-empty stack inventory can have diff --git a/docs/process-capture.md b/docs/process-capture.md index 88156c3ab3..89c6ce8dd9 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -500,6 +500,11 @@ Every capture requires `truncation_details`, with separate accounting for: An unreadable directory is recorded in `enumeration_omissions` with its root-aliased path and operating-system error code. Entries observed before that directory remain available, while descendants beneath it stay unknown. + A basename that is not valid UTF-8 is never looked up through replacement + characters. It consumes entry capacity and records an `unrepresentable_name` + omission at its containing directory, with exact basename bytes in + `raw_name_hex` and a null `system_code`. That root's enumeration remains + incomplete, so missing paths beneath it remain unknown. Each retained regular file without a digest has an aliased path, size, remaining budget and reason: `file_bytes_budget`, `file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is @@ -663,3 +668,11 @@ REA then preserves ownership uncertainty and can report `cleanup_incomplete`; it does not disable verification to continue. A sandbox that explicitly allows this read-only process inspector is a separate prerequisite from the compiler and cache settings. + +Symlink observations preserve their stored target bytes in `symlink_target_hex` +(lowercase hexadecimal). `symlink_target` retains valid UTF-8 text and is `null` +when the target bytes cannot be decoded as UTF-8. Filesystem effects compare +these exact bytes without resolving the link. Older captures without the hex +field still parse; a legacy target containing U+FFFD has ambiguous byte identity, +so matching target text alone produces an `unknown` effect. Symlink descendants +remain outside filesystem observation regardless of target encoding. diff --git a/docs/specs/agent-environments/scenario-saved-values/plan.md b/docs/specs/agent-environments/scenario-saved-values/plan.md new file mode 100644 index 0000000000..65939090b2 --- /dev/null +++ b/docs/specs/agent-environments/scenario-saved-values/plan.md @@ -0,0 +1,22 @@ +# Plan + +1. Schema: `save` on request steps, `save_response` on click and press, the + `saved` value source in headers and credential parts, `basic_credentials` + with two parts, and the `{saved:name}` placeholder. +2. Validation: every saved value a step sends was saved by an earlier step. +3. Runtime: read the response body when a step saves, start waiting for a UI + step's response before it acts, keep saved values with their JSON type, + fill placeholders, build Basic credentials, and redact secret saves. +4. Evidence: leave an empty `save` out of the recorded step. +5. Environment build: warn about credential-named saved values not marked + secret. +6. Skill and docs: write through the UI first and save ids instead of guessing + them; bump the skill version. +7. Tests: schema, runtime with a fake page, Evidence parameters, the build + warning, the catalog budget, and a real-Chrome recording. +8. Write model: compose strings from generated ids and other fields, learn the + `&` spelling of slugs, tell apart addresses whose parts are the same text, + and check templates on later creates; census every recorded capture. +9. End to end: agents build environments from prompts with main and with this + branch on the same applications, and the branch's recordings are rebuilt + before and after the write-model changes. diff --git a/docs/specs/agent-environments/scenario-saved-values/spec.md b/docs/specs/agent-environments/scenario-saved-values/spec.md new file mode 100644 index 0000000000..0cb300333f --- /dev/null +++ b/docs/specs/agent-environments/scenario-saved-values/spec.md @@ -0,0 +1,74 @@ +# Saved values in browser scenarios + +## Outcome + +A browser scenario could not carry a value from one step to a later one. An +agent recording an application from a prompt therefore guessed the id of the +object it had just created, created probe objects to learn the next id, or +read the object back by list only. An API that takes Basic auth, or a header a +frontend builds from a login token, could not be sent at all, so the agent +stopped and asked for an API key. + +Steps now save strings and numbers from JSON responses, and later steps send +them. A click or key press saves from a newly observed matching response, +so a scenario can create an object the way the application's UI does and then +open, read, edit or delete it by the id the application assigned. A request +header can send Basic credentials built from literal, secret, saved or page +values. + +## Requirements + +- A `request` step saves `[{name, json_pointer, secret}]` from its own JSON + response. A `click` or `press` step's `save_response: {method, path, save}` + saves from the first response that matches its method and a path on the start + origin (`{*}` matches one segment; the query is ignored), which must be 2xx + JSON. The wait starts before the step acts, and the step fails, saving + nothing, unless such a response arrives and is read before its timeout or + cancellation. +- A pointer that names no string or number, a number that is not finite (such as + `1e400`), a response that is not JSON or is larger than 4,000,000 characters + (a UI step reads none received as more than 12,000,000 bytes, three per + character), and a non-2xx `save_response` fail the step. +- `{saved:name}` sends a saved value, percent-encoded, in a request path and its + query and in a goto URL, and in a request's JSON body: a string that is only + the placeholder becomes the saved value with its JSON type, and a placeholder + inside a longer string is filled as text. Header values and Basic credential + parts send `{source: "saved", name}`. Other braces are sent as written. A + saved value that makes a path segment `.` or `..`, alone or with the text + beside it, fails the step. +- `basic_credentials` sends the base64 of `username:password` from `parts: + [username, password]`, each a literal, declared secret, saved value, cookie or + local storage entry. The encoded value and parts read from the page are + redacted as page values are. A username with a colon fails the step. +- Validation refuses a saved value no earlier step saves. +- A scenario keeps at most 4,000,000 characters of saved values at once, counting + the redaction spellings of values saved as secret; a save past that fails its + step before the value is kept. +- A value saved with `secret: true` is redacted from the selected source response + before retention and from subsequent records. The environment build warns about saved values named like credentials + that are not marked secret (`credential-named-saved-values`). +- A scenario that saves nothing records the same Evidence parameters and + `scenario_sha256` as before: an empty `save` is left out of the recorded + step. +- Earlier REA versions read captures that use saved values. +- The compact MCP catalog stays under its measured one-megabyte frame. +- The skill records writes the way the application's UI does and falls back to + request steps when the UI cannot reach an operation or coverage withholds the + UI-shaped write. +- A UI-made create must be served as faithfully as one made through the API: + - a string composed from a generated id and another field, such as Grafana's + folder address, is learned as composed, not as a template around the id; + - a slug learns how the application spells `&` from the call that shows it, + and held entities that spell it both ways leave no slug rule; + - an id that equals the title's slug is told apart by the entities the type + holds, or by a later create; + - a template a later create answers otherwise is withheld, as a composed + string is. + +## Non-goals + +- No general value transforms, string templates or script steps: the value + kinds are fixed. +- No saved values in `fill` or `select_option` values. +- No retroactive redaction of earlier unrelated records. +- No multi-factor sign-in. diff --git a/docs/specs/agent-environments/scenario-saved-values/tasks.md b/docs/specs/agent-environments/scenario-saved-values/tasks.md new file mode 100644 index 0000000000..c5a4915539 --- /dev/null +++ b/docs/specs/agent-environments/scenario-saved-values/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Add saved values, `save_response` and Basic credentials to the scenario schema. +- [x] Validate that saved values are saved before use. +- [x] Save and send values in the Playwright runtime. +- [x] Keep scenarios that save nothing byte-identical in Evidence. +- [x] Warn about credential-named saved values not marked secret. +- [x] Update the skill and docs. +- [x] Add unit, boundary and real-browser checks. +- [x] Run the gates. +- [x] Build prompt-recorded environments with main and with this branch. +- [x] Make the skill's recording example write through the UI. +- [x] Learn the addresses a UI-made create spells, and check templates on later creates. +- [x] Rebuild the branch's recordings with the write-model changes and record every application again. diff --git a/docs/specs/agent-environments/scenario-saved-values/verification.md b/docs/specs/agent-environments/scenario-saved-values/verification.md new file mode 100644 index 0000000000..bba226d037 --- /dev/null +++ b/docs/specs/agent-environments/scenario-saved-values/verification.md @@ -0,0 +1,138 @@ +# Verification + +This is Edgar Arakelyan's historical verification record from upstream PR #7162 +(head `d39f386d202fe8e98592fb2d9d26e43d8d0fb94f`), retained with attribution. +These multi-application and model evaluations were not rerun during integration. +The independent integration measurements and their limits are recorded in +[saved-values integration verification](../../../verification/browser-saved-values-2026-10-26.md). + +Executed on macOS with Node v24.22.1 at `feat/recorder-saved-values` e42362c9 (on main 2b84efe0), and on Ubuntu 24.04 +VMs with snap Chromium for the agent recordings. + +## Gates + +- `npm run typecheck`, `lint:built`, `format:check`, `metadata:check`, `verify:architecture-guards`, `knip:production`, + `lint:typed`, `docs:check`, `mcp-catalog:check`, `verify:test-discovery`, `scan:todos` and `jscpd`: passed. + `knip` reports one unused export, `isToolFamilyId` in `src/contracts/toolFamilies.ts`, which this branch does not + touch and main 2b84efe0 reports too. +- `npx vitest run --project domain --project services --project adapters`: 18,261 passed and 1 failed, "stops Inspector + target authorization after the current stat settles", which fails the same way on main in this environment. +- Browser scenario CLI and MCP boundaries, scenario input diagnostics, cancellation and cleanup, the analysis-view CLI + and MCP boundaries, the environment build, export and serve CLI boundaries, the environment serve, served-writes and + bundle MCP boundaries, tool schema validity, MCP schema validation, and output schema constraints and profile: 17 + files, 128 passed. +- The compact MCP catalog must stay under the one-megabyte frame `outputSchemaProfile.test.ts` measures. A first + design with `path_params` on request and goto steps, `json_values`, and separate `username` and `password` unions + added 9,391 bytes on main ff6d96bc and failed the test, which then had 5,731 bytes to spare. Saved values now travel + as `{saved:name}` placeholders, Basic credentials repeat their part union once, and repeated inner descriptions were + dropped: on main 2b84efe0 the catalog takes 1,038,975 bytes and this branch 1,042,639. + +## Compatibility + +- Scenarios that save nothing record the same Evidence parameters and `scenario_sha256` on main and on this branch: a + UI-only scenario with a goto to a URL holding literal braces, and a request step with a secret query value, a cookie + header, and literal braces in its path and JSON body. +- Main reads a capture that uses saved values: `environment-build --dry-run` of the real-Chrome capture below gives the + same manifest, state, coverage and file plan on main and on this branch. This branch adds the + `credential-named-saved-values` warning. +- Write plans for 109 captures (the 50 published captures, 29 earlier agent recordings and the 30 recorded in the + comparison below), built by main and by this branch: 524 plans and 247 served on each. Three plans differ, all + Grafana folder creates recorded below, whose `url` the branch composes from the uid and the title's slug where main + learned a template or nothing. + +## Real Chrome + +A local application with a cookie-session UI and an API that takes only Basic auth, recorded with Google Chrome: + +- fill a name and click Save with `save_response` on `POST /ui/items`, saving the new id; +- `goto` `/items/{saved:item1}` and wait for the item's page; +- `GET /api/items/{saved:item1}` with Basic credentials whose username comes from local storage and password from a + declared secret; +- `POST /api/tokens`, saving the token both as a secret and, unmarked, as `session_token`; +- `POST /api/items` with the saved token in a header and `{"name": "Child of {saved:item1}", "ref": "{saved:item1}"}`; +- `PATCH /api/items/{saved:child}` and `GET /api/items?parent={saved:item1}`. + +All 11 steps completed. The requests went to `/items/101`, `/api/items/101`, `/api/items/108` and `?parent=101`, and the +body sent was `{"name":"Child of 101","ref":101}`. The Evidence parameters keep the placeholders, the password appears +nowhere in the capture, and the token header is recorded as `[REDACTED:page:token.api_token]`. The token's own response +was redacted too in this run; the docs say it may not be, since the recorder reads bodies on their own schedule. +`environment-build` warned about `/parameters/actions/7/save/1`. + +## Agent-built environments, main and this branch + +Claude Code (`claude-sonnet-5-5`) built an environment of each application from the same prompt, with REA main +ff6d96bc (skill 49) and with this branch (skill 50), on freshly seeded instances. Each bundle was rebuilt by the REA it +was recorded with, and its write tasks run with `fireworks_ai/gpt-oss-120b` and `fireworks_ai/glm-5p3-flash`. Each task +checks its result from the environment's state, including that nothing else changed. + +The branch was recorded three times as it changed: + +1. With the skill as first written, EspoCRM's agent wrote through request steps with Basic credentials instead of the + UI, and EspoCRM's API answers in a shape the write model withholds (0/3). The skill's recording example created its + object with a request step, which agents copy; it now creates through the UI with `save_response`. +2. With that example, Grafana's agent created folders through the UI, where Grafana generates the uid. REA then learned + the folder's `url` as a template around the uid that kept the first folder's slug, and coverage did not check the + template against the second folder, so served creates answered with wrong addresses (0/2). The write-model fixes + in this PR compose the address from the uid and the title's slug, learn Grafana's `&` spelling, and check templates + on later creates. +3. With both, every application was recorded again. + +**The same recordings, before and after the write-model fixes.** Run 2's captures, rebuilt by this branch before and +after the fixes (gpt-oss, GLM): + +| Application | Before | After | +| ----------- | ------------- | ------------- | +| Miniflux | 3/3 3/3 | 3/3 3/3 | +| Paperless | 1/3 2/3 | 1/3 1/3 | +| Vikunja | 1/3 1/3 + 0/2 | 1/3 1/3 + 0/2 | +| Linkding | 0/2 0/2 | 0/2 0/2 | +| Homebox | 0/2 0/2 | 0/2 0/2 | +| Grafana | 0/2 0/2 | 2/2 2/2 | +| EspoCRM | 1/3 1/3 | 1/3 1/3 | + +Only Grafana's plans change, and its tasks pass. Paperless's plans are identical in both builds; its GLM difference is +the eval model's. + +**Fresh recordings, main and the final branch:** + +| Application | Write tasks | Main: served, tasks | Branch: served, tasks | +| ----------- | ----------- | ---------------------- | --------------------- | +| Miniflux | 3 | no bundle | 4, 3/3 3/3 | +| Paperless | 3 | 3, 1/3 1/3; 6, 2/3 2/3 | 6, 1/3 2/3 | +| Vikunja | 3 + 2 | 1, 0/3 0/3 + 0/2 0/2 | 3, 1/3 1/3 + 2/2 2/2 | +| Linkding | 2 | 0, 0/2 0/2 | 3, 0/2 0/2 | +| Homebox | 2 | 1, 0/2 0/2 | 5, 0/2 0/2 | +| Grafana | 2 | 2, 2/2 2/2; 3, 2/2 2/2 | 1, 1/2 1/2 | +| EspoCRM | 3 | 2, 1/3 1/3 | 2, 0/3 0/3 | +| Total | 20 | 9, 4/20 4/20 | 24, 8/20 9/20 | + +"Served" counts the write tools the final capture serves; tasks are gpt-oss then GLM, and Miniflux's count as failed +without a bundle. Paperless and Grafana were recorded twice on main; totals use the first run. + +- **Miniflux:** main's agent stopped and asked for an API key, since the API takes Basic auth. With Basic credentials + the branch served category create, rename and delete in all three runs, and every category task passed with both + models. +- **Vikunja:** label edits and deletes are served and pass in two of the three branch runs, and task deletes in the + last two. +- **Saved ids:** every branch agent saved ids, with `save_response` on UI writes, and read objects back by them. On main + no step can carry a value to a later one, so agents chose their own ids or read lists back instead. +- **Paperless:** the two main runs differ as much as main and the branch do. +- **Linkding and Homebox** serve more writes, but their create tasks still fail. Linkding's served create requires every + member its recorded calls sent (`description`, `notes`, `unread`, `shared`, `tag_names`), so a task that sends a URL + and a title is refused. +- **Grafana:** the folder create is served with the right address and its task passes. The rename is withheld: + Grafana lists folders sorted by title, the recording renamed a folder to a title that sorts first, and REA keeps an + updated entity where it was in a list. Main's REA reads this recording the same way, and serves its create with the + template that keeps the first folder's slug, so the create task would fail too. Main's two recordings passed because + their titles kept their order. +- **EspoCRM:** the recording started with a contact an earlier attempt in the same session created and left, so the + environment holds it as seeded data and every task's "nothing else changed" check fails. The skill now asks agents to + delete what an earlier attempt created before recording again; that change was not measured. +- Agents spent $9.98 and 3,149 seconds in total on the fresh branch run, against $7.45 and 2,235 on main, a third of it + Homebox's. + +## Not verified + +- One fresh recording per application and version, two for Paperless and Grafana on main; agent runs vary. +- Lists sorted by a field that an update changes, and the skill's cleanup of earlier attempts. +- Multi-factor sign-in, and saved values in `fill` or `select_option` steps, which this change does not add. diff --git a/docs/verification/browser-saved-values-2026-10-26.md b/docs/verification/browser-saved-values-2026-10-26.md new file mode 100644 index 0000000000..925d863907 --- /dev/null +++ b/docs/verification/browser-saved-values-2026-10-26.md @@ -0,0 +1,271 @@ +# Browser saved values: integration verification + +## Pinned scope + +Executed on Linux x64, Node 24.22.1 and Chromium through `/usr/bin/chromium`. +The primary comparison uses **Playwright 1.64.1 and the same integration tree +for both workflows**. Local verification commit `dd343d3b1092d697772f1447d94179d23bcc5cf2` +contains GitHub merge `1a3ef66c8e25f25fdd06053bb33255f10f65c5b6` +(main `030fa886f94e894e34ed2539e9d8dedff19c3327` plus PR head +`ed110935f520d56f6eaf5710ad2f9a58340492b4`), followed by response-scope fix +`2c5f9cc14b3395ef4a99071222e3feaf9501bb62`. The local verification commit is +not a published release. Recreate its tree from the public main/PR inputs, +then apply that fix. Final reconciliation adds `0d89c65409d28fec269cfdbb3ee62e0f153819fa` +(local current-dependency tree `221ba53af`) for query spelling and refused-slug +source constraints; the primary timing pair predates these two corrections. + +Historical comparisons against pre-feature main +`6d5532a204b338a6c770fcbae61e0d55bda70793` used Playwright 1.58.2 and remain +below. They do not isolate the dependency upgrade. The implementation integrates +Edgar Arakelyan's #7162; upstream `d39f386d202fe8e98592fb2d9d26e43d8d0fb94f` +and subsequent fixes through `9f5191ab0` were compared. Its query redaction and refused-slug source +pinning were incorporated. Existing heap-derived URL/JSON/envelope admission +remains the owner of expansion bounds, rather than adding the upstream fixed +fill ceiling. Original multi-app/model +results are [attributed historical evidence](../specs/agent-environments/scenario-saved-values/verification.md), +not executions performed here. + +## Real application and correctness oracles + +PocketBase **0.40.6** runs on an owned loopback instance. Its official Linux amd64 +archive is 12,824,710 bytes, SHA-256 +`0e1f609158ed945f809f8a02ae012aa0427129c66ebb63c1aee76f2c0309373f`. +The [owned frontend](https://github.com/morluto/rea/blob/938052704/experiments/analysis-efficiency/fixtures/browser-saved-values/index.html) +uses actual superuser authentication and record APIs. It is a small test +frontend rather than PocketBase's entire administrative application. +Collection `rea_items` has required text `title` and numeric `quantity`, public +list/view, and superuser-only create/update/delete rules. + +The completed task signs in, creates a record, reuses its generated ID and +numeric quantity, PATCHes it, GETs it with a query, and navigates to its API URL. +Independent PocketBase GETs verify identity, final title and numeric quantity; +retained request bytes verify a JSON number. Every declared action must finish. +The selected login response must actually occur once; no missing response can +pass the redaction oracle. + +- Current integration public CLI: all **eight steps** completed, eight retained + JSON bodies, zero plaintext marked token or declared password. **4.351 s elapsed**, + **4.832 s CPU**, **1,381,789,696 B** sampled process-family peak. +- Current integration MCP: two equivalent tasks completed in both legacy and + saved-value modes. Legacy uses a retained event page and another capture; + saved bindings finish the same analyst task in one capture. +- Both current legacy runs actually observed the login body before the later + page-value read. That earlier unmarked token remained present, while later + observations used redaction. The mixed-scope unit cases additionally establish + that an unrelated page/Basic value remains prospective when response secrets + are also learned. +- A real negative control on the imported implementation retained the marked + authentication token despite completing its actions. The integrated source + response and retained-event checks reject that outcome. + +## Privacy, provenance and resource boundaries + +The public MCP source probe verifies: + +1. Source and later JSON bodies with escaped strings and `1e3` become valid + redacted JSON while preserving unmarked `1e-400` and whitespace exactly. +2. Metadata-only capture redacts the selected source URL without retaining + network-content records. Unknown source values withhold the affected source + fields and expose a limitation. +3. A declared whole JSON string seeded into storage and POSTed to PocketBase is + withheld if a safe whole-body replacement cannot preserve JSON. HTTP **403** + and an unchanged integer record count independently confirm the outcome. +4. The frontend consumes the selected response, logs and visibly renders its + token, and immediately POSTs it in a URL/header/body. Retained console and + network data are protected. All three requested screenshots are `missing`, + with screenshot completeness ineligible for equality. +5. A separate nonsecret scenario captures a real PNG and validates its signature. + +This expanded probe passed in **8.417 s elapsed**, **9.567 s CPU**, +**1,680,818,176 B** sampled family peak. A focused public CLI run of the shared +CI browser fixture also passed: three withheld sensitive screenshots, all other +selected artifacts captured, and explicit screenshot/network-cutoff gaps. +It took **5.956 s elapsed**, **6.868 s CPU**, **1,495,924,736 B** family peak; +that fixture server is inside the measured family. + +The source parser owns strict UTF-8, exact selected numbers, pointer lookup and +source-token redaction. UI whole-body saving requires a positive known +identity-encoded size before reading; compressed/cache-unknown responses are +refused. The original author's fallback is not enabled. Two initial cached UI +probes were correctly refused; the owned fixture uses `cache: "no-store"` for +the known-size case. + +Response-marked values can backfill retained source/follow-up event text. +Page-derived cookie/storage values and constructed Basic credentials retain +their existing prospective-only policy. A session-local response projection +borrows admitted compiled patterns/byte owners and is invalidated on admissions +or response classification. Promoting an already held value to an explicit +response secret preserves its first marker. Replacement work and retained output +are admitted before allocation; omissions and cleanup retry are accounted once. + +UI matching is observational. Multiple observed matches fail without adopting +an ambiguous binding; a single observed match cannot prove UI causality or +exclude a later poll. Returned Playwright body bytes also do not establish +original-wire provenance. These limitations remain in the contract/Evidence. + +Request JSON, saved URLs and the full request envelope are admitted before +expansion/dispatch using Node's string ceiling and a conservative V8-headroom +allowance. Repeated saved/header facts share request-local size/validation work. +Four cases reject 1,000 references to a 3,000,000-character value without dispatch; +those inputs were deliberately not run on unbounded code. The first repeated +header guard took 4.042 s in its source test; the final case no longer appears +in slow-test output (its final individual duration was not separately measured). + +Raw pixels are not text-redactable. The secret owner establishes screenshot +withholding before execution for planned sensitive response/page/Basic values, +and whenever sensitive values are already known. The artifact producer then +returns `missing` without requesting pixels. No separate image-cleanup state or +publication hook remains. + +## Completed-task measurements + +Both columns use the current integration tree, provider and catalog. Each run +creates a fresh MCP server/browser context and completes a first and subsequent +task. The browser has already loaded the fixture; “first” is not a cold browser +or OS cache. One run per condition is an observation, not a general speed claim. + +| Measurement | Legacy workflow | Saved values | +| -------------------------------------- | -----------------------: | -----------------------: | +| Task calls, each task | 3 | 1 | +| First task elapsed | 6.191 s | 1.879 s | +| Warm task elapsed | 1.120 s | 0.872 s | +| Whole verifier elapsed | 13.413 s | 8.092 s | +| Whole verifier CPU | 15.347 s | 9.387 s | +| Sampled family peak RSS | 2,018,435,072 B | 1,689,305,088 B | +| Input catalog + arguments/text replies | 34,885 estimated tokens | 25,881 estimated tokens | +| Full SDK catalog + arguments/results | 370,936 estimated tokens | 353,768 estimated tokens | + +`o200k_base` BPE estimates serialize compact JSON and are not model billing. +The model projection counts all **53** tool names/descriptions/input schemas +(**18,579 tokens** in both columns), followed by the complete array of named +arguments and text-content results. The full SDK estimate counts the complete +catalog plus the array of full results, including output schemas and structured +results. Arrays are tokenized jointly rather than summing independently tokenized +cells. Both estimates include two extra correctness-proof exports: eight total +calls before and four after. No model ran; reasoning, retries, JSON-RPC framing +and initialization traffic are unmeasured. + +Whole-verifier cost includes discovery, browser setup, proof/oracle work and +cleanup, distinct from timed task calls. PocketBase runs externally: its CPU +and memory are **unmeasured**. REA/Chromium families use two CPUs, a 3 GiB Node +heap and an 8 GiB family RSS guard. RSS is summed every 100 ms, may double-count +shared pages and miss short peaks. No guard stopped the completed runs. + +Historical pre-feature comparisons (Playwright 1.58.2) remain observations: + +| Candidate | First task before→after s | Warm before→after s | Whole elapsed before→after s | CPU before→after s | Peak GB before→after | +| ----------- | ------------------------: | ------------------: | ---------------------------: | -----------------: | -------------------: | +| `355e2dcb8` | 7.703→3.531 | 1.390→1.605 | 14.990→17.198 | 15.735→15.679 | 2.080→1.636 | +| `dd3085bf5` | 5.145→1.621 | 1.020→0.762 | 11.723→7.653 | 13.444→8.683 | 2.028→1.638 | +| `439a389fe` | 16.121→4.429 | 2.944→1.738 | 34.296→20.053 | 32.166→19.053 | 1.996→1.646 | +| `e7acc5d85` | 10.778→2.627 | 1.467→1.129 | 22.310→12.661 | 26.148→14.759 | 2.103→1.632 | +| `43aaaabbe` | 7.751→3.220 | 1.210→0.996 | 15.527→11.301 | 18.047→12.777 | 2.064→1.628 | +| `854b7a2c0` | 6.965→1.923 | 1.100→0.874 | 15.657→9.213 | 17.222→10.753 | 2.053→1.650 | +| `938052704` | 5.983→1.720 | 1.040→0.787 | 13.103→8.146 | 15.194→9.497 | 2.033→1.635 | + +The first candidate was slower for warm/whole elapsed. Later differences also +reflect host/cache variance. Historical model estimates include 34,899→25,897 +at `355e2dcb8`, 34,831→25,902 at `e7acc5d85`, 34,816→25,866 at `43aaaabbe`, +34,742→25,845 at `854b7a2c0`, and 34,820→25,897 at `938052704`. These are not +summed as independent model usage. + +## Executed checks and negative controls + +- Final feature branch: **560 tests in 12 source suites**, type/lint and build passed. +- Current integration/Playwright 1.64.1: **561 tests in the same 12 suites**, + build and `check:fast` passed; the additional test comes from main. +- Twenty explicit schema/profile tests passed earlier; all 41 distinct cases in + the broader five-suite run passed across its run and isolated rerun. One case + timed out at 30 s under two-CPU contention, then passed alone in 17.36 s. + An older-Node attempt failed before tests. Final real SDK calls also validate + the named tool definitions and advertised results. +- Negative controls fail before their fixes: decoded bounds/accounting/numeric/ + slug cases; seven source-format/UI ambiguity cases; three source-publication + cases; two whole-JSON cases; two backslash substitutions plus immediate + follow-up; five final event/path cases; and ten screenshot cases. The + screenshot nonsecret control passes on the old revision. +- Final reconciliation re-ran the eight-step real CLI task and all four privacy + probes plus the positive PNG case on `221ba53af` successfully. A further + two-step public CLI case verifies the parsed query spelling of `ab'cd~ef` + is redacted. Together these checks used 16.550 s elapsed, 18.581 s CPU and + 1,675,153,408 B sampled family peak; this is qualification, not a new timing pair. +- The final upstream query/slug regressions fail three cases on `34dc2c1c4`. + The query spelling lower-bound calculation also includes the new encoding + before allocating it. +- Three prospective-page/Basic regressions fail on `ed110935f`; final tests also + cover promotion, scope-cache invalidation and preservation of the first marker. +- The prior CI's own event-budget assertion was fixed with JSON admission order + and the whole-body guard. It was not bypassed. Two invalid newly added header + fixtures were corrected to canonical shapes before the final source run. +- A focused CI-fixture harness initially closed its server too early. Cleanup + order was corrected and the real CLI rerun passed. Failed receipts remain. + +Initial CI/main controls reproduced a load-sensitive prewarm failure and two +SAZ partial-observation assertions on their exact parents. The prewarm case +passes alone on later main and is not claimed as a stable current failure. +Subsequent CI failures comprise those two SAZ assertions and Java diagnostic +`keeps empty Java bitness unresolved`; all three independently reproduce on +actual CI main parent `c6893d1ac6a9ccb8a11d64b4e9ba1b57e79cbfd8` with its isolated +1.64.1 locked dependencies. The first preparation refused stale shared 1.58.2 +dependencies before any tests; the shared installation was preserved. +On head `34dc2c1c4`, all 18 distinct CI failures (artifact/inventory, snapshot, +cleanup, Apple-resource coverage and the two SAZ assertions) reproduce on exact +CI main parent `29ebd6740550bf3989129c39c65f17e5c61e3dd2`. The additional +control run also timed out two retained-ZIP cases and emitted two connection +closures; these were control-only incomplete results, not passing checks. +All fifteen actionable review threads were fixed and resolved. A further +final-head Codex review request was refused by the repository usage limit; no +fresh successful automated review is claimed. +Cancelled lanes remain incomplete, including cancelled shards that emitted +passing summaries. CI counts from overlapping revisions are not added together. + +## Reproducing and limitations + +Use an isolated worktree with the pinned Node/npm toolchain. For the primary +comparison, recreate the public main/PR integration inputs above, apply the +response-scope fix, install its lockfile, build once, and use **that same checkout** +as `--repository` for both modes below. Historical pre-feature measurements used +separate pinned checkouts. + +On an owned loopback PocketBase instance, create the collection/rules described +above and a temporary superuser. Serve the committed `index.html`, +`whole-json.html` and `immediate-followup.html` from +`experiments/analysis-efficiency/fixtures/browser-saved-values/`. Store only +`{"email":"...","password":"..."}` in a private credentials file, mode 0600; +keep it and full retained Evidence out of Git. + +Serve `escaped-values.json` with these exact minified bytes (unmarked spelling +is part of the oracle): + +```text +{"token":"\u0073ource-token","code":1e3,"unknown":1e-400} +``` + +Run the committed driver with fresh private output directories: + +```bash +node experiments/analysis-efficiency/browser-saved-values.mjs \ + --repository /path/to/integration --mode before --output /private/before \ + --origin http://127.0.0.1:18762 --credentials /private/credentials.json \ + --browser /usr/bin/chromium +node experiments/analysis-efficiency/browser-saved-values.mjs \ + --repository /path/to/integration --mode after --output /private/after \ + --origin http://127.0.0.1:18762 --credentials /private/credentials.json \ + --browser /usr/bin/chromium +node experiments/analysis-efficiency/browser-saved-values.mjs \ + --repository /path/to/integration --mode after --source-only \ + --output /private/privacy --origin http://127.0.0.1:18762 \ + --credentials /private/credentials.json --browser /usr/bin/chromium +``` + +Run these under a process-family CPU/RSS supervisor for comparable resource +measurements. The driver owns its MCP client and browser/profile cleanup, checks +payload outcomes, records arguments/results/catalog, and exports retained parent +Evidence. Current legacy runs observed pre-read login bodies in both tasks; if +that ordering is not observed, the receipt says so rather than inventing timing. + +Real-provider scope is Linux Chromium and the owned PocketBase/frontend plus +shared CI fixture. Compressed/cache-unknown UI saving is unsupported. Other +platforms, real Basic-auth servers, service-worker integrations and historical +multi-app/model evaluations were not rerun here. No model task success or actual +model-token billing is claimed. diff --git a/docs/verification/browser-scenario-validation-2026-10-27.md b/docs/verification/browser-scenario-validation-2026-10-27.md new file mode 100644 index 0000000000..c0f3e1e89f --- /dev/null +++ b/docs/verification/browser-scenario-validation-2026-10-27.md @@ -0,0 +1,57 @@ +# Browser scenario offline validation + +Executed on Linux x64 with Node 24.22.1/npm 11.16.0, based on main +`3fcbabe593611435ebc26e1eac86706fe60abb6e`. Integrates Ace's #7149 +(`463c8bd73`), with one shared CLI input/admission helper and stronger boundary +coverage. The domain scenario contract remains the semantic owner. + +The input is the preserved scenario from the successful real PocketBase 0.40.6 +CLI workflow described in [saved-values verification](browser-saved-values-2026-10-26.md). +Its SHA-256 is `cfbb3428292250519a5a6f7f9c4b5fea494eb9e3ceccf7b777fd44ffe0f12223`. +The backend was stopped and its loopback endpoint confirmed unreachable before +these checks. Both declared secret environment variables were removed from each +child environment. The original scenario file was verified unchanged. + +Each invocation used the public `validate-browser-scenario FILE --json` command. +All returned `{"valid":true,"actions":7,"secrets":2}` and exit 0. The seven +caller actions exclude the implicit initial capture step. + +| Input / fresh CLI process | Elapsed s | CPU s | Sampled family peak bytes | +| ----------------------------------------------- | --------: | ----: | ------------------------: | +| Recorded scenario | 1.712 | 2.013 | 217,907,200 | +| Same input repeated | 1.722 | 1.989 | 217,239,552 | +| Browser replaced with a nonexistent executable | 1.667 | 1.933 | 219,025,408 | +| Browser replaced with disconnected loopback CDP | 1.557 | 1.880 | 218,779,648 | + +Two CPUs, a 3 GiB Node heap and an 8 GiB family guard bounded execution. Family +RSS is the sum sampled every 100 ms; shared pages can be counted twice and short +peaks can be missed. No resource guard stopped a task. These are fresh processes +with warm filesystem caches, not cold-OS measurements or an analysis-cache claim. +There was no equivalent earlier public offline workflow, so no comparative +speedup is claimed. Profiling/tokenizer driver overhead is outside these tasks. + +Each JSON reply is 38 bytes / **14 estimated o200k tokens**. The complete Node executable/script/CLI argument array plus parsed reply, +encoded as compact JSON `{argv, result}`, uses 75–77 estimated tokens, including +the local paths. Shell/profiler framing is unmeasured. The documents themselves are 1,926–1,942 bytes / 500–508 tokens if +read into context; this file-based task does not require returning them. There +is no model invocation/billing or MCP schema/discovery in these CLI tasks. + +Boundary checks cover unavailable launch/connect resources, throwing provider +factory and enumerable secret getter spies, duplicate/reserved IDs, undeclared +secrets, duplicate secret IDs/environment names, ordered saved references, +unknown fields, unreadable/malformed files, caller-relative executable paths, +stdin and public CLI exit codes. Existing capture diagnostics and summary +publication remain covered. Initial test-helper type errors were corrected +before the final static gate; no passing result is claimed for that failed run. + +Validation establishes schema and cross-reference validity. It does not establish +browser availability, secret availability, selector correctness or successful +execution, and it emits no analysis Evidence. Real-browser capture was verified +in the preceding feature; these offline checks construct no browser provider. + +To reproduce with an existing scenario: + +```bash +rea validate-browser-scenario ./browser-scenario.json --json +cat ./browser-scenario.json | rea validate-browser-scenario - --json +``` diff --git a/docs/verification/javascript-retained-facts-2026-10-15.md b/docs/verification/javascript-retained-facts-2026-10-15.md index 258ba54fd8..6dbde3eecf 100644 --- a/docs/verification/javascript-retained-facts-2026-10-15.md +++ b/docs/verification/javascript-retained-facts-2026-10-15.md @@ -36,6 +36,13 @@ not a claim of globally complete analysis. Full Evidence remains exportable. The domain projector and shared application service own both CLI/MCP behavior; no new provider, executor, persistent cache or live-state freshness claim exists. +The dated measurements below requested the then-emitted derived package +`renderer` property. New analyses use `renderer_declarations` for source fields +and package `loads` edges for the selected candidate; use +`"renderer_declarations"` in place of `"renderer"` when repeating the fact query +against a new analysis. Historical retained Evidence and measurements remain +unchanged. + ## Real input and reproduction Use the official [Joplin v3.7.21 Linux AppImage](https://github.com/laurent22/joplin/releases/download/v3.7.21/Joplin-3.7.21.AppImage). diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 913158157d..e35237b699 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -780,8 +780,16 @@ sorted stays sorted, unless a call stored its strings out of order as sent), and a field neither rules holds one value. Members either call sent are accepted, only those every call sent are required, and an echoed member a call left out takes the value that call stored (`body.defaults`). A clock rule widens to `trimmed` only when no held entity or -earlier call showed that clock at a fixed width. A later create that disagrees -leaves the plan as it was. A create whose observed reference differs from the +earlier call showed that clock at a fixed width, and a slug to spelling `&` as +the word (`ampersand: "and"`) only when no call the plan accepted nor entity the +type held dropped one. A later +create that disagrees leaves the plan as it was. A field the first create left +unexplained, such as an address whose two parts were the same text, takes the +`composed` rule a later create learns for it when that rule spells the first +create's value too, and the first create's body contract is learned again with +it. A `composed` string or `template` a later create's answer shows otherwise +or leaves out is `unexplained-change`. A create whose observed reference +differs from the entity the plan rebuilds, whether or not it sent the reference member, is `unexplained-change`. So is a create that left out an echoed member yet shows a value different from the learned default, or that stored a field no rule writes @@ -789,9 +797,23 @@ otherwise than the plan keeps it. A `composed` string is literal text and parts read from the fields other rules write: a field's value, its slug, or a clock's calendar digits (`YYYYMMDDhhmmss`, `YYYYMMDD`, `YYYY-MM-DD` or `YYYY/MM/DD`, in the clock's -zone). A create composes a string only when at least one other entity of the -type spells it with the same parts and none spells it otherwise, and never when -one text could come from two sources serving could tell apart. A clock format +zone). A slug drops `&` unless the application spells it as the word, as Go's +gosimple/slug and JavaScript's slugify do (`ampersand: "and"`, on a `slug` rule +or part). When a create slugs no `&`, the entities the type holds show which, +and no `slug` rule is learned when they spell it both ways; a create whose slug +spells `&` otherwise than they do, or alongside one whose +slug neither spelling gives, has no `slug` rule; such a held slug also keeps a +later create from widening the spelling. An update learns the spelling the same way: a later update whose slug spells +`&` as the word refines a rule an earlier update learned from a name without +`&`, unless an accepted call or a held entity dropped it, and every earlier +update must still replay. An update whose slug spells no `&` takes the spelling the +entity's earlier value and the type's other entities show, as a create does, +and learns no slug rule when they spell it both ways. A create composes a string only when at least one other entity of the +type spells it with the same parts and none spells it otherwise. Where one text +could come from two sources serving could tell apart, such as an id that equals +the title's slug, the entities the type holds must leave exactly one reading. A +`composed` string that reads a field besides the generated value a `template` +would rebuild it around replaces the template. A clock format is `trimmed` when the field's values differ only in how many fraction digits they show and none ends a fraction in a zero, as Go spells nanoseconds: its `fraction` is the most it shows, and serving drops trailing zeros and the point diff --git a/experiments/analysis-efficiency/README.md b/experiments/analysis-efficiency/README.md index 154a09249f..0077a0cb6e 100644 --- a/experiments/analysis-efficiency/README.md +++ b/experiments/analysis-efficiency/README.md @@ -1,5 +1,9 @@ # Analysis efficiency measurements +The [Android component checkpoint](android-components.md) records exact selected +declaration views, real JADX verification and completed-task token estimates, +including increased schema cost and no demonstrated speed or memory gain. + The [2026-10-21 coverage reconciliation](coverage-review-20261021.md) maps all 46 source directories to concrete workflow evidence, including original feature PRs, historical no-benefit results and remaining verification boundaries. diff --git a/experiments/analysis-efficiency/android-component-costs.py b/experiments/analysis-efficiency/android-component-costs.py new file mode 100644 index 0000000000..9515440437 --- /dev/null +++ b/experiments/analysis-efficiency/android-component-costs.py @@ -0,0 +1,65 @@ +"""Count completed Android MCP tasks; estimates do not establish model billing.""" +import argparse +import hashlib +import json +from pathlib import Path + +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()) + calls = json.loads((path / "calls.json").read_text()) + assert len(report["runs"]) == 2 and len(calls) == 4 + assert all(run["answer_verified"] and run["calls"] == 2 for run in report["runs"]) + count = lambda text: len(encoding.encode(text, disallowed_special=())) + inputs = [{key: tool[key] for key in ["name", "description", "inputSchema"]} + for tool in catalog["tools"]] + question = (f"For {report['target']}, find the receiver declaring " + "android.provider.Telephony.SMS_RECEIVED and report its identity, permission, " + "enabled/process declarations, per-filter priority and source position. " + "Preserve source coverage and export complete parent Evidence. " + "Static declarations do not establish runtime behavior.") + rows = [] + for index, run in enumerate(report["runs"]): + selected = calls[index * 2:index * 2 + 2] + assert [call["name"] for call in selected] == ["inspect_android_package", "export_evidence_bundle"] + assert not any(call["result"].get("isError") for call in selected) + arguments = sum(count(compact({"name": call["name"], "arguments": call["arguments"]})) + for call in selected) + text = sum(count(block["text"]) for call in selected + for block in call["result"]["content"] if block["type"] == "text") + wire_calls = [{key: call[key] for key in ["name", "arguments", "result"]} for call in selected] + rows.append({**run, "estimated_tokens": { + "question": count(question), "input_catalog_once": count(compact(inputs)), + "arguments": arguments, "text_replies": text, + "whole_task_text_projection": count(question) + count(compact(inputs)) + arguments + text, + "sdk_catalog_and_calls": count(compact({"catalog": catalog, "calls": wire_calls})), + }, "inspection_sdk_reply_bytes": len(compact(selected[0]["result"]).encode()), + "inspection_sdk_reply_tokens": count(compact(selected[0]["result"]))}) + return {"case": str(path), "mode": report["mode"], "sha256": report["sha256"], + "runs": rows, "actual_model_usage": None, + "receipts_sha256": {name: hashlib.sha256((path / name).read_bytes()).hexdigest() + for name in ["report.json", "catalog.json", "calls.json"]}} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("cases", type=Path, nargs="+") + args = parser.parse_args() + encoding = tiktoken.get_encoding("o200k_base") + records = [inspect(path, encoding) for path in args.cases] + assert len({record["sha256"] for record in records}) == 1 + args.output.write_text(json.dumps({"tokenizer": f"tiktoken {tiktoken.__version__}", + "encoding": encoding.name, "scope": "Text projection counts a fixed question, discovered name/description/input schemas once, all RPC arguments and text replies including proof export. Full SDK transport also counts output schemas and structuredContent. Host context injection and model billing are unmeasured.", + "records": records}, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/experiments/analysis-efficiency/android-components.md b/experiments/analysis-efficiency/android-components.md new file mode 100644 index 0000000000..010853ef20 --- /dev/null +++ b/experiments/analysis-efficiency/android-components.md @@ -0,0 +1,222 @@ +# Android component declarations and selected retained facts + +Addresses [#4448](https://github.com/morluto/rea/issues/4448), extending the inventory +already on main. Builds on Harsh's partial [#7264](https://github.com/morluto/rea/pull/7264) +revision `987b5aceb66c7526e47cf6d064c22029c2f48c04`, including its process budget. +The historical-field correction preserves omission and marks unknown coverage; +new fields are never invented as absent in old Evidence. + +## Owner and compatibility + +The Android manifest parser owns declaration interpretation. A shared domain +contract replaces duplicated provider/domain declarations; a single namespace +reader and one nested-declaration pass produce structured filters and existing +flattened action/category fields. Source positions address the exact retained +decoded text. Unsupported data constraints and filter constructs mark partial +coverage, including scheme-specific-part attributes outside the projected fields. +The producer emits all current fields; historical readers accept omitted fields. + +Existing complete requests and the existing default summary remain available. +An explicit components view selects exact names/kinds/actions/exported declarations, +fields and pages. Component summaries count explicit/default exports separately. +CLI and MCP share the retained projection and retain complete parent Evidence. +Repeated views reuse validated sealed records under the existing session lifetime; +no new persistent provider cache or live-state freshness claim is introduced. +Rows always retain the parent array ordinal and declaration coverage. The APK +subject, engine identity, source status, limitations and parent linkage remain +available. Nothing here establishes runtime enabling, resource resolution, Android +URI matching, signature authenticity or execution of an app. + +## Reproduction inputs + +The old runtime is main `15a8f6847ccac581c7ff717b42062a54b1ba8084`. +The timing/token qualification below was captured on that pinned base before the +feature was rebased onto main `6207843814e5267e3a1d9796d5800346f69dc7da`. +The Android producer, bridge and view sources had no upstream changes in that +range; latest-main integration is checked separately rather than relabeling the +older runtime receipts. +Actual verification uses Linux x86-64, Node 24.22.1, Temurin 21.0.12.1, +JADX headless MCP 0.7.1 and the existing REA Java bridge protocol 7. +JAR SHA-256: `6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75`. +The public [Appium ApiDemos 6.0.18 APK](https://github.com/appium/android-apidemos/releases/download/v6.0.18/ApiDemos-debug.apk) +is 6,521,217 bytes, SHA-256 +`a9eecf37b26cd084855c530db81c2bb1b91f4c1b095a04f47aa7c20e2791f686`. +It has 355 declared components and 152,541 decoded manifest UTF-16 units. +The recorded sorted relative-path/SHA-256 tables cover compiled `dist/*.js` and +JSON recursively, the dispatcher/environment helper and Android bridge. Table +SHA-256: old runtime `9581afe8ba48c15d803dd7ed3ffc4d10e4e4b99857a0b90206c8012e266daffe` +(2,435 files), final candidate +`a78698c12ae1dc514ffd3f09ca1b881747c9dde7d1a8677ceed7de3233334c40` +(2,439 files). Raw tables remain with the private receipts. + +`android-components.mjs` exercises actual public stdio MCP tools and validates +results against the selected named SDK tool definition. Supply `--repository`, +`--target`, a fresh `--output` directory, `--mode before|after`, `--java-home` and +`--jar`. It captures the selected catalog, every RPC, complete exported parents, +first-capture and warm-session results, and checks APK identity and an independent +stdlib ElementTree oracle. No production manifest parser is reused by that oracle. +Keep raw results, APKs and resource receipts outside Git. The selected tool families +are android/application/session, with compact input schemas, two allowed CPUs, +one analysis worker, 1 GiB JVM heap and a 3 GiB Node heap. A separate supervisor +samples process-family memory every 100 ms, with an 8 GiB early stop and a 4 GiB +host-available-memory floor. No guard stop or OOM occurred in these measurements. + +## Equivalent completed question + +Find the receiver declaring `android.provider.Telephony.SMS_RECEIVED` and report +its identity, permission, enabled/process declarations, per-filter priority and +source position. The old runtime needs complete manifest delivery for the new +facets. The selected candidate returns the matching row directly. Both treatments +make one inspection plus one complete Evidence export per capture; the oracle +verifies one positive match and the total count of 355 source declarations. An additional capture +uses the same server/engine session. First capture is a fresh process/session, +not a flushed OS page cache. Export costs and the extra call are included. + +Initial candidate qualification passed the independent oracle. It measured +first/warm inspection RPCs of 11.395/0.329 seconds versus the old runtime's +10.534/0.314 seconds. The complete two-capture process family took 19.065 seconds, +27.687 CPU seconds and 1,193,615,360 peak sampled RSS bytes versus +16.534 seconds, 24.675 CPU seconds and 1,107,955,712 bytes. This run predates the +additional unsupported-data-attribute coverage correction. It establishes no +latency, CPU or memory improvement; retained data has more declaration facts. + +The final coverage-corrected candidate and a repeated immutable-main control +also passed the identical oracle. Preserve all four receipts rather than choosing +a favorable timing: + +| Runtime, execution order | First/warm inspection RPC seconds | Whole two-capture seconds | CPU seconds | Peak sampled family RSS bytes | +| ------------------------ | --------------------------------: | ------------------------: | ----------: | ----------------------------: | +| Main control 1 | 10.534 / 0.314 | 16.534 | 24.675 | 1,107,955,712 | +| Initial candidate | 11.395 / 0.329 | 19.065 | 27.687 | 1,193,615,360 | +| Final candidate | 14.558 / 0.571 | 23.369 | 36.031 | 1,145,073,664 | +| Main control 2 | 26.348 / 0.511 | 36.537 | 36.381 | 1,168,494,592 | + +The repeated main control shows substantial host/run variation. These samples +establish neither a deterministic slowdown nor a speedup; warm candidate RPCs +are slightly slower in both observations. RSS is the sum of family RSS sampled +every 100 ms, so shared pages may count more than once and short peaks may be +missed; it is not a kernel/cgroup peak. Tokenizer processing is outside the timed +SDK runs. Local oracle work, hashing, discovery, serialization, proof export and +cleanup are inside. Cold/warm CPU and memory are not separately measured. + +For the first main control and final candidate, `o200k_base` estimates give: + +| Cost per first capture | Old complete | Selected components | +| ------------------------------------------------------------ | -----------: | ------------------: | +| Inspection reply bytes | 978,381 | 11,307 | +| Inspection reply estimated tokens | 225,926 | 2,715 | +| Selected family name/description/input-schema catalog tokens | 14,855 | 15,346 | +| Question + input catalog + both call arguments/text replies | 118,430 | 16,893 | +| Full SDK catalog, including output schemas, + both calls | 516,533 | 299,566 | + +The primary text projection counts a fixed question, the input catalog once, all +call arguments and every text reply, including export receipts. The SDK transport +row additionally counts output schemas and structuredContent; it is not equated +with model context. Reproduce with `android-component-costs.py --output costs.json +BEFORE_DIRECTORY AFTER_DIRECTORY` (requires tiktoken). The manifest file exported locally is retained +proof, not inline model context. These are tokenizer estimates of scripted SDK +workflows, not billed model usage or a measured autonomous-agent task. They do +not establish fewer model rounds or a generic analysis speedup. Actual provider +work still reads the complete manifest, and schema discovery grows. + +## Global catalog compatibility + +The actual CI merge `10746a4e8f8c622357cf8dd529f98adc8bf06d2b` combines the feature +with main parent `86d4a21215cb0fbafaf555b5c7d9fcda3f255d82`. Real SDK discovery, +using the same complete contract registrations as the existing profile test, gives: + +| Explicit wire profiles | Pure main bytes | CI merge bytes | +| ------------------------------ | --------------: | -------------: | +| Full input / full output | 4,398,561 | 4,425,163 | +| Compact input / compact output | 556,450 | 558,653 | +| Compact input / output off | 302,913 | 305,116 | +| Full input / output off | 1,047,039 | 1,052,239 | + +Input presentation defaults to compact; output presentation defaults to full. +For frame-capped hosts, compact/off outputs are explicit compatibility choices. +The bounded compatibility combinations retain the existing one-megabyte test +limit and per-schema budgets. New selectors add 5,200 bytes with full inputs: +2,699 input bytes and 379 description bytes on `inspect_android_package`, plus +2,122 input bytes on the retained-view operation. Compact-input discovery grows +2,203 bytes. These global discovery costs are distinct from the selected-family +task token estimates above. + +This change crosses the old test assertion for full input with output off. +That assertion was broader than its default-compact test name and the existing +profile documentation: full inputs deliberately preserve complete guidance and +can dominate the frame even without output schemas. The corrected test retains +both compact-input frame limits, compares every full input schema with its named +canonical advertisement under full/off outputs, preserves the entire tool set, +and verifies that off removes only output advertisements. No production limit is +raised and no full input validation/guidance is discarded. The owning guide now +states this full-profile boundary explicitly. + +## Verification boundaries and failed attempts + +The real Java regression exercises the existing raw/default-package class selector, +native absence, references and frozen metadata around neighbouring source generation. +An initial assertion compared the entire usage object: JADX legitimately populated +`def_pos` from 0 to 103 after generating source. The revised assertion excludes only +that presentation position and keeps class/method identity, raw DEX invoke IDs, +descriptor/overload facts and invoke offsets. Production bridge behavior and version +are unchanged. + +One combined focused run passed 121 checks but hit the existing 30-second MCP test +timeout while advertising every unrelated tool family to the SDK. Its failed receipt +is retained. The Android-specific test now selects the same relevant families as +the real SDK workflow; its named schemas, retained manifest/source-removal behavior, +full/summary compatibility and complete Evidence export assertions remain enabled. +A subsequent run exposed a test assertion that incorrectly expected repeated +acquisitions to reuse the first Evidence ID. Each selected capture now proves its +own retained parent exists and has the same artifact and normalized facts; engine +reuse does not collapse distinct acquisition identities. + +ApiDemos contains no URI data declarations. The checked-in manifest/resources and +Smali container permit a second source-built APK with real AAPT2 binary XML and +JADX decoding, including two filters, URI data, metadata, a resource boolean, +explicit process and provider URI grants. Compiled APK/DEX outputs are private. +Static declaration verification does not execute these component classes. +The source-built APK SHA-256 was +`aea6ac64630ad70e93f4d85de0237cc51a7012b077ca0d0e7b24cf22c5737532`. +Its real CLI selected view and retained bundle passed `verify-fixture.py`, including +AAPT2 dump comparison, all source positions, grouping, raw resource references, +metadata names and provider URI grants. The real SDK VIEW query passed the +independent ElementTree oracle on first and warm capture. An initial fixture build +used Smali's CLI and failed because the engine JAR does not bundle optional +JCommander; `BuildDex.java` now calls the already bundled Smali API directly. + +The real metadata helper passed all Java markers and downstream CLI/MCP method +body and direct-invoke parity checks for both classes.dex and classes2.dex: +149.436 seconds, 204.026 CPU seconds and 1,070,514,176 sampled peak family RSS bytes. +No production bridge change was needed for default-package selectors. + +A genuine complete parent from the old runtime also passed a new CLI retained +component query with an explicitly nonexistent engine JAR. Its newly introduced +fields remained omitted and row coverage remained unknown; no APK/provider +reopening was required. The current source checks cover 111 parser cases, +six projection cases, four CLI early-validation cases and four MCP cases, +including named SDK-converted schemas and retained source-removal behavior. + +After the rebase onto `6207843814e5267e3a1d9796d5800346f69dc7da`, type/lint, +all 125 focused cases and the nine documentation conformance cases passed again. +A fresh real ApiDemos SDK qualification also passed the independent oracle: +first/warm RPCs 11.291/0.290 seconds, whole two-capture elapsed 17.008 seconds, +CPU 26.425 seconds and peak sampled family RSS 1,166,245,888 bytes. This is latest-main +integration evidence; it is not paired with a new baseline performance cohort. + +Review follow-up: Greptile correctly identified that the original oracle filtered +before comparison, so the timing cohorts above verified selected-receiver facts +and the inventory count, not every unselected component's facts. The oracle now +returns an unfiltered inventory and derives the selected list from it. The driver +checks every retained parent's identity, enabled/raw-enabled/process/permission, +filter grouping/data/priority, metadata names, declaration coverage and component +source location before checking the selected page. The receipt records both the +verified count and scope; historical mode checks the available identity/permission +facets without inventing the new observations. A fresh real ApiDemos first/warm +run passed all 355 rows in both captures (16.538 seconds, 24.977 CPU seconds, +1,176,817,664 sampled peak family RSS bytes). The older receipts remain unchanged. The unfiltered historical-mode run also +passed all 355 available identity/permission rows in both captures; the compiled +manifest fixture passed all three current rows plus its selected VIEW page. + +Platform coverage is Linux/JDK21/JADX0.7.1 only. Android devices, emulators, actual +runtime enabling/registration, other JDKs and Windows/macOS are unverified here. diff --git a/experiments/analysis-efficiency/android-components.mjs b/experiments/analysis-efficiency/android-components.mjs new file mode 100644 index 0000000000..28dfbd2d5e --- /dev/null +++ b/experiments/analysis-efficiency/android-components.mjs @@ -0,0 +1,294 @@ +/** Real APK task through public MCP, with complete retained Evidence and an independent XML oracle. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { performance } from "node:perf_hooks"; +import { createVerifierCommands } from "../../scripts/lib/verify-commands.mjs"; + +const { values } = parseArgs({ + options: { + repository: { type: "string" }, + target: { type: "string" }, + output: { type: "string" }, + mode: { type: "string" }, + "java-home": { type: "string" }, + jar: { type: "string" }, + action: { + type: "string", + default: "android.provider.Telephony.SMS_RECEIVED", + }, + }, +}); +for (const name of [ + "repository", + "target", + "output", + "mode", + "java-home", + "jar", +]) + assert.ok(values[name], `Missing --${name}`); +assert.ok(["before", "after"].includes(values.mode)); +const repo = resolve(values.repository), + target = resolve(values.target), + output = resolve(values.output); +await mkdir(output, { mode: 0o700 }); +const digest = createHash("sha256"); +for await (const chunk of createReadStream(target)) digest.update(chunk); +const sha256 = digest.digest("hex"); +const require = createRequire(join(repo, "package.json")); +const { Client } = await import( + pathToFileURL(require.resolve("@modelcontextprotocol/client")) +); +const { StdioClientTransport } = await import( + pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio")) +); +const commands = await createVerifierCommands(); +const env = { + PATH: process.env.PATH, + JAVA_HOME: resolve(values["java-home"]), + REA_JADX_MCP_JAR: resolve(values.jar), + REA_JADX_HEAP_MIB: "1024", + REA_JADX_ACTIVE_PROCESSOR_COUNT: "2", + NODE_OPTIONS: "--max-old-space-size=3072", + UV_THREADPOOL_SIZE: "2", + REA_LOG_LEVEL: "silent", + REA_MCP_TOOL_FAMILIES: "android,application,session", + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", +}; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(repo, "scripts/rea.mjs"), "mcp"], + cwd: repo, + env, + stderr: "pipe", + maxBufferSize: 16 * 1024 * 1024, +}); +const diagnostics = []; +let diagnosticBytes = 0; +let omittedDiagnosticBytes = 0; +transport.stderr?.on("data", (chunk) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const retained = bytes.subarray(0, Math.max(0, 65536 - diagnosticBytes)); + if (retained.length > 0) diagnostics.push(retained); + diagnosticBytes += retained.length; + omittedDiagnosticBytes += bytes.length - retained.length; +}); +const client = new Client({ + name: "rea-real-android-components", + version: "1", +}); +let catalog; +const calls = [], + runs = []; +const invoke = async (name, arguments_) => { + const definitions = catalog.tools.filter((tool) => tool.name === name); + assert.equal(definitions.length, 1); + const start = performance.now(); + const result = await client.callTool( + { name, arguments: arguments_ }, + { toolDefinition: definitions[0], timeout: 120000 }, + ); + calls.push({ + name, + arguments: arguments_, + result, + elapsed_ms: performance.now() - start, + }); + assert.notEqual( + result.isError, + true, + `${name}: ${JSON.stringify(result.content)}`, + ); + return result.structuredContent; +}; +const selectedFields = [ + "kind", + "declared_name", + "name", + "exported", + "exported_default", + "permission", + "permission_source", + "enabled", + "enabled_declaration", + "process", + "intent_filters", + "meta_data", + "location", +]; +const stripLocations = (row) => { + const { location: _location, ...fields } = row; + return { + ...fields, + intent_filters: row.intent_filters?.map( + ({ + location: _filterLocation, + source_index: _filterIndex, + coverage: _coverage, + ...filter + }) => ({ + ...filter, + data: filter.data.map(({ location: _dataLocation, ...data }) => data), + }), + ), + meta_data: row.meta_data?.map( + ({ location: _metadataLocation, ...metadata }) => metadata, + ), + }; +}; +const assertOracleFacts = (row, expected, source) => { + assert.equal(row.declaration_coverage, "complete"); + const cleaned = stripLocations(row); + for (const [key, value] of Object.entries(expected)) + assert.deepEqual( + cleaned[key], + value, + `Independent oracle: ${expected.source_index}.${key}`, + ); + assert.equal( + source.slice( + row.location.offset, + row.location.offset + row.kind.length + 1, + ), + "<" + row.kind, + ); +}; +try { + await client.connect(transport); + catalog = await client.listTools(); + await writeFile(join(output, "catalog.json"), JSON.stringify(catalog)); + for (const temperature of ["first-capture", "warm-session"]) { + const firstCall = calls.length; + const start = performance.now(); + const result = await invoke( + "inspect_android_package", + values.mode === "before" + ? { path: target } + : { + path: target, + detail: "summary", + view: { + kind: "android", + facet: "components", + actions: [values.action], + fields: selectedFields, + limit: 64, + }, + }, + ); + const rpcMs = performance.now() - start; + const normalized = result.normalized_result; + const parentId = + values.mode === "before" + ? result.evidence_id + : normalized.parent_evidence_id; + const path = join(output, temperature + "-evidence.json"); + await invoke("export_evidence_bundle", { path }); + const bundle = JSON.parse(await readFile(path, "utf8")); + const parent = bundle.records.find( + (record) => record.evidence_id === parentId, + ); + assert.ok(parent); + assert.equal(parent.subject.digest.sha256, sha256); + const oraclePath = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures/android-components/manifest-oracle.py", + ); + const oracle = JSON.parse( + ( + await commands.exec( + "python3", + [oraclePath, path, parentId, values.action], + { timeout: 15000, maxBuffer: 4 * 1024 * 1024 }, + ) + ).stdout, + ); + assert.ok( + oracle.components.length > 0, + "The completed task must exercise a real positive action match", + ); + const complete = parent.normalized_result; + assert.equal(complete.components.items.length, oracle.inventory.length); + assert.equal(oracle.inventory.length, oracle.source_components); + for (const [index, expected] of oracle.inventory.entries()) { + const row = complete.components.items[index]; + if (values.mode === "after") + assertOracleFacts( + { ...row, source_index: index }, + expected, + complete.manifest.text, + ); + else { + for (const key of ["kind", "declared_name", "name", "permission"]) + assert.deepEqual( + row[key], + expected[key], + `Legacy inventory: ${index}.${key}`, + ); + } + } + if (values.mode === "after") { + assert.equal(normalized.facet, "components"); + assert.equal(normalized.source_status, "complete"); + assert.equal(normalized.source_components, oracle.source_components); + assert.equal(normalized.coverage.exhausted, true); + assert.equal(normalized.coverage.total, oracle.components.length); + assert.equal(normalized.items.length, oracle.components.length); + for (let i = 0; i < oracle.components.length; i++) { + const row = normalized.items[i], + expected = oracle.components[i]; + assertOracleFacts(row, expected, complete.manifest.text); + } + } else { + for (const expected of oracle.components) { + const row = normalized.components.items[expected.source_index]; + assert.equal(row.name, expected.name); + assert.ok(row.actions.includes(values.action)); + } + } + runs.push({ + temperature, + rpc_ms: rpcMs, + calls: calls.length - firstCall, + matched_components: oracle.components.length, + source_components: oracle.source_components, + parent_evidence_id: parentId, + answer_verified: true, + inventory_verified: oracle.inventory.length, + inventory_scope: + values.mode === "after" + ? "declared-structured-facets" + : "legacy-identity-permission", + }); + } +} finally { + // exec drains already adopted owners; adopt the live MCP client only at its final cleanup boundary. + commands.adopt(client, "real Android component MCP client"); + await commands.close(); + await writeFile(join(output, "stderr.log"), Buffer.concat(diagnostics)); + await writeFile(join(output, "calls.json"), JSON.stringify(calls), { + mode: 0o600, + }); + await writeFile( + join(output, "report.json"), + JSON.stringify( + { + mode: values.mode, + target, + sha256, + runs, + tool_count: catalog?.tools.length, + omitted_diagnostic_bytes: omittedDiagnosticBytes, + }, + null, + 2, + ), + ); +} +console.log(JSON.stringify({ mode: values.mode, runs, verified: true })); diff --git a/experiments/analysis-efficiency/browser-saved-values.mjs b/experiments/analysis-efficiency/browser-saved-values.mjs new file mode 100644 index 0000000000..fda13ed6d1 --- /dev/null +++ b/experiments/analysis-efficiency/browser-saved-values.mjs @@ -0,0 +1,713 @@ +/** Compare complete real PocketBase create/update/read tasks through public MCP. */ +import assert from "node:assert/strict"; +import { readFile, writeFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { performance } from "node:perf_hooks"; +import { createVerifierCommands } from "../../scripts/lib/verify-commands.mjs"; +import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs"; +const { values } = parseArgs({ + options: { + repository: { type: "string" }, + mode: { type: "string" }, + output: { type: "string" }, + origin: { type: "string" }, + credentials: { type: "string" }, + browser: { type: "string" }, + "source-only": { type: "boolean", default: false }, + }, +}); +assert.ok( + values.repository && + values.output && + values.origin && + values.credentials && + values.browser, +); +assert.ok(["before", "after"].includes(values.mode)); +const repo = resolve(values.repository), + output = resolve(values.output), + origin = new URL(values.origin).origin; +assert.equal( + new URL(origin).hostname, + "127.0.0.1", + "Use an owned loopback fixture", +); +await mkdir(output, { mode: 0o700 }); +const credentials = JSON.parse(await readFile(values.credentials, "utf8")); +const require = createRequire(join(repo, "package.json")); +const { chromium } = require("playwright-core"); +const { Client } = await import( + pathToFileURL(require.resolve("@modelcontextprotocol/client")) +); +const { StdioClientTransport } = await import( + pathToFileURL(require.resolve("@modelcontextprotocol/client/stdio")) +); +const commands = await createVerifierCommands(); +const profile = await mkdtemp(join(tmpdir(), "rea-saved-values-")); +const env = { + PATH: process.env.PATH, + NODE_OPTIONS: "--max-old-space-size=3072", + UV_THREADPOOL_SIZE: "2", + REA_LOG_LEVEL: "silent", + REA_MCP_TOOL_FAMILIES: "browser,application,session", + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + REA_TEST_EMAIL: credentials.email, + REA_TEST_PASSWORD: credentials.password, + REA_TEST_WHOLE_JSON: JSON.stringify({ token: "private-token-729d" }), +}; +const calls = []; +const runs = []; +let catalog; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(repo, "scripts/rea.mjs"), "mcp"], + cwd: repo, + env, + stderr: "pipe", +}); +transport.stderr?.resume(); +const client = new Client({ name: "rea-saved-values-real-app", version: "1" }); +commands.adopt(client, "saved-values MCP client"); +const tokenHeader = (saved) => ({ + name: "Authorization", + value: saved + ? { source: "saved", name: "token" } + : { source: "local_storage", key: "rea_auth", json_pointer: "/token" }, +}); +const decodeBodies = (value) => { + const bodies = []; + const visit = (x) => { + if (x && typeof x === "object") { + if (x.encoding === "base64" && typeof x.content === "string") { + try { + bodies.push( + JSON.parse(Buffer.from(x.content, "base64").toString("utf8")), + ); + } catch {} + } + for (const v of Object.values(x)) visit(v); + } + }; + visit(value); + return bodies; +}; +async function invoke(name, args) { + const defs = catalog.tools.filter((t) => t.name === name); + assert.equal(defs.length, 1); + const start = performance.now(); + const result = await client.callTool( + { name, arguments: args }, + { toolDefinition: defs[0], timeout: 90000 }, + ); + calls.push({ + name, + arguments: args, + result, + elapsed_ms: performance.now() - start, + }); + assert.notEqual( + result.isError, + true, + `${name} failed; retain the local receipt`, + ); + return result.structuredContent; +} +const loginActions = (saved) => [ + { + step_id: "email", + action: "fill", + locator: { kind: "css", selector: "#email" }, + value: { source: "secret", secret_id: "email" }, + }, + { + step_id: "password", + action: "fill", + locator: { kind: "css", selector: "#password" }, + value: { source: "secret", secret_id: "password" }, + }, + { + step_id: "login", + action: "click", + locator: { kind: "css", selector: "#login" }, + ...(saved + ? { + save_response: { + method: "POST", + path: "/api/collections/_superusers/auth-with-password", + save: [{ name: "token", json_pointer: "/token", secret: true }], + }, + } + : {}), + timeout_ms: 10000, + }, + { + step_id: "ready", + action: "wait_for", + locator: { kind: "css", selector: '#result[data-ready="yes"]' }, + state: "visible", + timeout_ms: 10000, + }, +]; +const edits = (id, saved) => [ + { + step_id: "update", + action: "request", + method: "PATCH", + path: `/api/collections/rea_items/records/${id}`, + headers: [tokenHeader(saved)], + json: { title: `record-${id}`, quantity: saved ? "{saved:quantity}" : 11 }, + expected_status: [200], + }, + { + step_id: "read", + action: "request", + method: "GET", + path: `/api/collections/rea_items/records/${id}?at=${id}`, + headers: [tokenHeader(saved)], + expected_status: [200], + }, + { + step_id: "open", + action: "goto", + destination: { url: `${origin}/api/collections/rea_items/records/${id}` }, + wait_until: "load", + }, +]; +const captureOptions = { + after_each_step: ["url"], + at_end: ["url", "accessibility"], + events: ["network"], + network: { request_body: true, response_body: true, header_values: true }, +}; +try { + const context = await chromium.launchPersistentContext(profile, { + executablePath: resolve(values.browser), + headless: true, + args: ["--remote-debugging-port=0", "--no-sandbox"], + }); + commands.adopt(context, "owned saved-values Chromium context"); + const page = context.pages()[0]; + await page.goto(origin, { waitUntil: "networkidle" }); + const port = ( + await readFile(join(profile, "DevToolsActivePort"), "utf8") + ).split("\n")[0]; + const endpoint = `http://127.0.0.1:${port}`; + const targets = await (await fetch(endpoint + "/json/list")).json(); + const target = targets.find( + (t) => t.type === "page" && t.url === origin + "/", + ); + assert.ok(target); + const browser = { + mode: "connect", + cdp_endpoint: endpoint, + target_id: target.id, + }; + const declared = [ + { secret_id: "email", environment_variable: "REA_TEST_EMAIL" }, + { secret_id: "password", environment_variable: "REA_TEST_PASSWORD" }, + ]; + const scenario = (actions, secrets = declared) => ({ + browser, + start_url: { url: origin + "/" }, + secrets, + actions, + capture: captureOptions, + detail: "summary", + }); + await client.connect(transport); + catalog = await client.listTools(); + await writeFile(join(output, "catalog.json"), JSON.stringify(catalog)); + for (const temperature of values["source-only"] ? [] : ["cold", "warm"]) { + const saved = values.mode === "after"; + const start = performance.now(), + firstCall = calls.length; + const create = { + step_id: "create", + action: "request", + method: "POST", + path: "/api/collections/rea_items/records", + headers: [tokenHeader(saved)], + json: { title: `created-${temperature}`, quantity: 11 }, + expected_status: [200, 201], + ...(saved + ? { + save: [ + { name: "record_id", json_pointer: "/id" }, + { name: "quantity", json_pointer: "/quantity" }, + ], + } + : {}), + }; + const firstActions = [...loginActions(saved), create]; + if (saved) firstActions.push(...edits("{saved:record_id}", true)); + const first = await invoke( + "capture_browser_scenario", + scenario(firstActions), + ); + const firstSummary = first.normalized_result; + assert.ok( + firstSummary.summary.steps.every((step) => step.status === "completed"), + "Initial capture must complete", + ); + let final = first; + let id; + let legacyPreReadObserved; + if (!saved) { + const selected = await invoke("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: firstSummary.parent_evidence_id, + }, + view: { + kind: "browser-scenario", + collection: "events", + step_indices: [firstActions.length], + event_kinds: ["network-content"], + limit: 16, + }, + }); + assert.equal(selected.normalized_result.coverage.exhausted, true); + const records = decodeBodies(selected.normalized_result).filter( + (b) => b?.collectionName === "rea_items" && typeof b.id === "string", + ); + assert.equal(records.length, 1); + id = records[0].id; + final = await invoke( + "capture_browser_scenario", + scenario(edits(id, false), []), + ); + } + assert.ok( + final.normalized_result.summary.steps.every( + (step) => step.status === "completed", + ), + "Completed task must not hide failed actions", + ); + const completedMs = performance.now() - start, + taskCalls = calls.length - firstCall; + const path = join(output, `${temperature}-evidence.json`); + await invoke("export_evidence_bundle", { path }); + const bundle = JSON.parse(await readFile(path, "utf8")); + const parent = bundle.records.find( + (r) => r.evidence_id === final.normalized_result.parent_evidence_id, + ); + assert.ok(parent); + if (!saved) { + const legacy = bundle.records.find( + (record) => record.evidence_id === firstSummary.parent_evidence_id, + ); + assert.ok(legacy); + const login = decodeBodies(legacy.normalized_result).filter( + (body) => typeof body?.token === "string", + ); + assert.equal(login.length, 1); + const preReadLogin = decodeBodies({ + events: legacy.normalized_result.events.items.filter( + (event) => event.step_index < firstActions.length, + ), + }).filter((body) => typeof body?.token === "string"); + legacyPreReadObserved = preReadLogin.length === 1; + if (legacyPreReadObserved) { + assert.ok( + !preReadLogin[0].token.startsWith("[REDACTED:"), + "An observed pre-read login response must preserve its unmarked token", + ); + const later = legacy.normalized_result.events.items.filter( + (event) => event.step_index >= firstActions.length, + ); + assert.ok( + !JSON.stringify({ + events: later, + bodies: decodeBodies({ events: later }), + }).includes(preReadLogin[0].token), + "Post-read facts must use the current page-value policy", + ); + } + } + const recordBodies = decodeBodies(parent.normalized_result).filter( + (b) => b?.collectionName === "rea_items" && typeof b.id === "string", + ); + assert.ok(recordBodies.length >= 2); + if (saved) id = recordBodies[0].id; + assert.ok(recordBodies.every((b) => b.id === id)); + assert.ok( + recordBodies.some((b) => b.title === `record-${id}` && b.quantity === 11), + ); + const read = await fetch( + `${origin}/api/collections/rea_items/records/${id}`, + ); + assert.equal(read.status, 200); + const actual = await read.json(); + assert.equal(actual.title, `record-${id}`); + assert.equal(actual.quantity, 11); + const requestBodies = decodeBodies(parent.normalized_result).filter( + (b) => b?.title === `record-${id}` && !Object.hasOwn(b, "collectionName"), + ); + assert.ok(requestBodies.length > 0); + assert.ok(requestBodies.every((b) => typeof b.quantity === "number")); + if (saved) { + const loginBodies = decodeBodies(parent.normalized_result).filter( + (b) => typeof b?.token === "string", + ); + assert.equal(loginBodies.length, 1); + assert.ok(loginBodies[0].token.startsWith("[REDACTED:")); + } + runs.push({ + temperature, + legacy_pre_read_observed: legacyPreReadObserved, + elapsed_ms: completedMs, + task_calls: taskCalls, + evidence_id: parent.evidence_id, + record_id: id, + quantity_type: typeof actual.quantity, + }); + } + if (values["source-only"]) { + assert.equal(values.mode, "after"); + const sourceScenario = scenario( + [ + { + step_id: "source", + action: "request", + method: "GET", + path: "/escaped-values.json?credential=source-token", + save: [ + { name: "token", json_pointer: "/token", secret: true }, + { name: "code", json_pointer: "/code", secret: true }, + ], + }, + { + step_id: "later", + action: "request", + method: "GET", + path: "/escaped-values.json", + }, + ], + [], + ); + const captured = await invoke("capture_browser_scenario", sourceScenario); + assert.ok( + captured.normalized_result.summary.steps.every( + (step) => step.status === "completed", + ), + ); + const path = join(output, "source-evidence.json"); + await invoke("export_evidence_bundle", { path }); + const bundle = JSON.parse(await readFile(path, "utf8")); + const parent = bundle.records.find( + (record) => + record.evidence_id === captured.normalized_result.parent_evidence_id, + ); + assert.ok(parent); + const sourceRequests = parent.normalized_result.events.items.filter( + (event) => + event.kind === "request" && + event.step_index === 1 && + event.url.url.includes("/escaped-values.json"), + ); + assert.equal(sourceRequests.length, 1); + const transaction = sourceRequests[0].transaction_id; + const sourceEvents = parent.normalized_result.events.items.filter( + (event) => event.transaction_id === transaction, + ); + assert.ok(sourceEvents.some((event) => event.kind === "response")); + assert.ok( + !JSON.stringify(sourceEvents).includes("source-token"), + "Source transaction metadata must not retain the value learned from its response", + ); + const bodies = decodeBodies(parent.normalized_result).filter( + (body) => + Object.hasOwn(body ?? {}, "token") && Object.hasOwn(body, "code"), + ); + assert.equal( + bodies.length, + 2, + "Both actual source and later response must be observed", + ); + for (const body of bodies) { + assert.equal(body.token, "[REDACTED:page:source.token]"); + assert.equal(body.code, "[REDACTED:page:source.code]"); + } + const expected = Buffer.from( + '{"token":"[REDACTED:page:source.token]","code":"[REDACTED:page:source.code]","unknown":1e-400}', + ).toString("base64"); + assert.equal( + JSON.stringify(parent.normalized_result).split(JSON.stringify(expected)) + .length - 1, + 2, + "Both source bodies must preserve the unmarked numeric spelling exactly", + ); + const metadata = await invoke("capture_browser_scenario", { + ...sourceScenario, + capture: { + ...sourceScenario.capture, + network: { + request_body: false, + response_body: false, + header_values: false, + }, + }, + }); + assert.ok( + metadata.normalized_result.summary.steps.every( + (step) => step.status === "completed", + ), + ); + const metadataPath = join(output, "metadata-evidence.json"); + await invoke("export_evidence_bundle", { path: metadataPath }); + const metadataBundle = JSON.parse(await readFile(metadataPath, "utf8")); + const metadataParent = metadataBundle.records.find( + (record) => + record.evidence_id === metadata.normalized_result.parent_evidence_id, + ); + assert.ok(metadataParent); + const metadataRequest = metadataParent.normalized_result.events.items.find( + (event) => + event.kind === "request" && + event.step_index === 1 && + event.url.url.includes("/escaped-values.json"), + ); + assert.ok(metadataRequest); + assert.equal(metadataRequest.url.origin, origin); + assert.ok(metadataRequest.url.url.includes("/escaped-values.json")); + assert.ok(!metadataRequest.url.url.includes("source-token")); + assert.ok( + !metadataParent.normalized_result.events.items.some( + (event) => event.kind === "network-content", + ), + ); + const countUrl = `${origin}/api/collections/rea_items/records?perPage=1`; + const beforeCount = (await (await fetch(countUrl)).json()).totalItems; + assert.ok(Number.isSafeInteger(beforeCount)); + const whole = await invoke("capture_browser_scenario", { + ...sourceScenario, + start_url: { url: origin + "/whole-json.html" }, + secrets: [ + { + secret_id: "whole_json", + environment_variable: "REA_TEST_WHOLE_JSON", + }, + ], + storage: { + local_storage: [ + { + origin, + entries: [ + { + name: "whole_json", + value: { source: "secret", secret_id: "whole_json" }, + }, + ], + }, + ], + }, + actions: [ + { + step_id: "settled", + action: "wait_for", + locator: { kind: "css", selector: '#result[data-ready="yes"]' }, + state: "visible", + timeout_ms: 10000, + }, + ], + }); + assert.ok( + whole.normalized_result.summary.steps.every( + (step) => step.status === "completed", + ), + ); + const wholePath = join(output, "whole-json-evidence.json"); + await invoke("export_evidence_bundle", { path: wholePath }); + const wholeBundle = JSON.parse(await readFile(wholePath, "utf8")); + const wholeParent = wholeBundle.records.find( + (record) => + record.evidence_id === whole.normalized_result.parent_evidence_id, + ); + assert.ok(wholeParent); + const post = wholeParent.normalized_result.events.items.find( + (event) => event.kind === "request" && event.method === "POST", + ); + assert.ok(post); + const postEvents = wholeParent.normalized_result.events.items.filter( + (event) => event.transaction_id === post.transaction_id, + ); + assert.ok( + postEvents.some( + (event) => + event.kind === "network-content" && + event.phase === "request" && + event.body.state === "unavailable", + ), + ); + const postResponse = postEvents.find((event) => event.kind === "response"); + assert.ok(postResponse && [400, 401, 403].includes(postResponse.status)); + assert.ok(!JSON.stringify(wholeParent).includes("private-token-729d")); + assert.equal( + (await (await fetch(countUrl)).json()).totalItems, + beforeCount, + ); + const followup = await invoke("capture_browser_scenario", { + ...sourceScenario, + start_url: { url: origin + "/immediate-followup.html" }, + capture: { + ...sourceScenario.capture, + events: [...sourceScenario.capture.events, "console"], + after_each_step: [ + ...sourceScenario.capture.after_each_step, + "screenshot", + ], + at_end: [...sourceScenario.capture.at_end, "screenshot"], + }, + actions: [ + { + step_id: "exchange", + action: "click", + locator: { kind: "css", selector: "#run" }, + save_response: { + method: "GET", + path: "/escaped-values.json", + save: [{ name: "token", json_pointer: "/token", secret: true }], + }, + timeout_ms: 10000, + }, + { + step_id: "settled", + action: "wait_for", + locator: { kind: "css", selector: '#result[data-ready="yes"]' }, + state: "visible", + timeout_ms: 10000, + }, + ], + }); + const followupPath = join(output, "followup-evidence.json"); + await invoke("export_evidence_bundle", { path: followupPath }); + const followupBundle = JSON.parse(await readFile(followupPath, "utf8")); + const followupParent = followupBundle.records.find( + (record) => + record.evidence_id === followup.normalized_result.parent_evidence_id, + ); + assert.ok(followupParent); + assert.equal(await page.locator("#sensitive").innerText(), "source-token"); + assert.equal(followupParent.normalized_result.steps.length, 3); + assert.ok( + followupParent.normalized_result.steps.every( + (step) => + step.artifacts.screenshot.state === "missing" && + step.completeness.missing_sections.includes("screenshot"), + ), + ); + assert.ok( + followupParent.normalized_result.completeness.missing_sections.includes( + "screenshot", + ), + ); + const consoleEvents = followupParent.normalized_result.events.items.filter( + (event) => event.kind === "console" && event.text.includes("REDACTED"), + ); + assert.equal(consoleEvents.length, 1); + assert.ok( + !JSON.stringify(followupParent.normalized_result.events.items).includes( + "source-token", + ), + ); + assert.ok( + followup.normalized_result.summary.steps.every( + (step) => step.status === "completed", + ), + ); + + const followupPost = followupParent.normalized_result.events.items.find( + (event) => event.kind === "request" && event.method === "POST", + ); + assert.ok(followupPost); + const followupEvents = followupParent.normalized_result.events.items.filter( + (event) => event.transaction_id === followupPost.transaction_id, + ); + assert.ok(!JSON.stringify(followupEvents).includes("source-token")); + const followupBodies = decodeBodies({ events: followupEvents }); + assert.ok( + followupBodies.some( + (body) => body?.token === "[REDACTED:page:exchange.token]", + ), + ); + assert.ok( + followupEvents.some( + (event) => event.kind === "response" && event.status === 403, + ), + ); + assert.equal( + (await (await fetch(countUrl)).json()).totalItems, + beforeCount, + ); + const publicScreenshot = await invoke("capture_browser_scenario", { + ...sourceScenario, + start_url: { url: origin + "/immediate-followup.html" }, + actions: [ + { step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }, + ], + capture: { + ...sourceScenario.capture, + events: [], + network: { + request_body: false, + response_body: false, + header_values: false, + }, + after_each_step: [], + at_end: ["screenshot"], + }, + }); + const publicPath = join(output, "public-screenshot-evidence.json"); + await invoke("export_evidence_bundle", { path: publicPath }); + const publicBundle = JSON.parse(await readFile(publicPath, "utf8")); + const publicParent = publicBundle.records.find( + (record) => + record.evidence_id === + publicScreenshot.normalized_result.parent_evidence_id, + ); + assert.ok(publicParent); + assert.equal(await page.locator("#sensitive").innerText(), ""); + const png = + publicParent.normalized_result.steps.at(-1).artifacts.screenshot; + assert.equal(png.state, "captured"); + assert.equal( + Buffer.from(png.value.data_base64, "base64") + .subarray(0, 8) + .toString("hex"), + "89504e470d0a1a0a", + ); + runs.push({ + verification: "source-and-later-JSON-spellings", + verified_bodies: bodies.length, + metadata_only_source_url: true, + whole_json_secret_withheld: true, + immediate_followup_protected: true, + immediate_console_protected: true, + rendered_secret_screenshots_withheld: true, + nonsecret_screenshot_captured: true, + whole_json_status: postResponse.status, + }); + } + await writeFile(join(output, "calls.json"), JSON.stringify(calls), { + mode: 0o600, + }); + await writeFile( + join(output, "metrics.json"), + JSON.stringify({ mode: values.mode, runs }, null, 2), + ); + console.log(JSON.stringify({ mode: values.mode, runs })); +} finally { + await withVerifierCleanup( + () => commands.close(), + [ + async () => { + if (commands.cleanupResources.length === 0) + await rm(profile, { recursive: true, force: true }); + }, + ], + ); +} diff --git a/experiments/analysis-efficiency/fixtures/android-components/AndroidManifest.xml b/experiments/analysis-efficiency/fixtures/android-components/AndroidManifest.xml new file mode 100644 index 0000000000..948fb865d3 --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/experiments/analysis-efficiency/fixtures/android-components/BuildDex.java b/experiments/analysis-efficiency/fixtures/android-components/BuildDex.java new file mode 100644 index 0000000000..c426c9d7b7 --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/BuildDex.java @@ -0,0 +1,15 @@ +import com.android.tools.smali.smali.Smali; +import com.android.tools.smali.smali.SmaliOptions; +import java.util.List; + +/** Assemble the local fixture without Smali CLI's optional JCommander dependency. */ +class BuildDex { + public static void main(String[] args) throws Exception { + SmaliOptions options = new SmaliOptions(); + options.apiLevel = 30; + options.jobs = 1; + options.outputDexFile = args[1]; + if (!Smali.assemble(options, List.of(args[0]))) + throw new IllegalStateException("Manifest fixture assembly failed"); + } +} diff --git a/experiments/analysis-efficiency/fixtures/android-components/ManifestFixture.smali b/experiments/analysis-efficiency/fixtures/android-components/ManifestFixture.smali new file mode 100644 index 0000000000..9f3ae3716a --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/ManifestFixture.smali @@ -0,0 +1,3 @@ +# A DEX container for a static manifest fixture; no Android app is executed. +.class public Lcom/rea/components/ManifestFixture; +.super Ljava/lang/Object; diff --git a/experiments/analysis-efficiency/fixtures/android-components/README.md b/experiments/analysis-efficiency/fixtures/android-components/README.md new file mode 100644 index 0000000000..d42ebd75ff --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/README.md @@ -0,0 +1,43 @@ +# Static compiled-manifest fixture + +These source-owned declarations cover URI data/filter association, exact resource +references, metadata names, component process/enablement and provider URI grants. +`ManifestFixture.smali` supplies a DEX container; it does not implement or execute +the declared Android components. The verification uses existing AAPT2 36.1.0, +Android 36 `android.jar`, JDK 21 and the digest-pinned JADX headless MCP 0.7.1 JAR. +No Android device, emulator or app execution is involved. + +From the repository root, choose existing `AAPT2`, `ANDROID_JAR`, `JAVA_HOME` and +`REA_JADX_MCP_JAR` paths, then build into a fresh private directory: + +```sh +fixture=experiments/analysis-efficiency/fixtures/android-components +work=$(mktemp -d) +"$AAPT2" compile --dir "$fixture/res" -o "$work/resources.zip" +"$AAPT2" link --manifest "$fixture/AndroidManifest.xml" -I "$ANDROID_JAR" \ + -o "$work/manifest.apk" "$work/resources.zip" +"$JAVA_HOME/bin/java" -Xmx512m -XX:ActiveProcessorCount=2 \ + -cp "$REA_JADX_MCP_JAR" "$fixture/BuildDex.java" \ + "$fixture/ManifestFixture.smali" "$work/classes.dex" +python3 - "$work" <<'PY' +from pathlib import Path +import sys, zipfile +root = Path(sys.argv[1]) +with zipfile.ZipFile(root / "manifest.apk", "a") as apk: + apk.write(root / "classes.dex", "classes.dex") +PY +"$AAPT2" dump xmltree --file AndroidManifest.xml "$work/manifest.apk" > "$work/aapt2-xmltree.txt" +"$AAPT2" dump resources "$work/manifest.apk" > "$work/aapt2-resources.txt" +REA_JADX_HEAP_MIB=1024 REA_JADX_ACTIVE_PROCESSOR_COUNT=2 \ + node scripts/rea.mjs inspect-android-package "$work/manifest.apk" \ + --detail summary --view '{"kind":"android","facet":"components"}' \ + --evidence_output "$work/evidence.json" --json > "$work/cli.json" +python3 "$fixture/verify-fixture.py" "$work" +``` + +`verify-fixture.py` cross-checks concrete compiled XML facts with the independent +AAPT2 dump, structured CLI facts, parent APK identity and every retained manifest +source location. It deliberately keeps resource declarations unresolved even +though the resource table contains values. `manifest-oracle.py` is a separate +stdlib ElementTree oracle used by the public MCP task driver. Generated binaries, +Evidence and dumps stay outside Git. diff --git a/experiments/analysis-efficiency/fixtures/android-components/manifest-oracle.py b/experiments/analysis-efficiency/fixtures/android-components/manifest-oracle.py new file mode 100644 index 0000000000..334e27bf63 --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/manifest-oracle.py @@ -0,0 +1,70 @@ +"""Independent stdlib XML oracle over retained manifests in this verification corpus.""" +import json +import sys +import xml.etree.ElementTree as ET + +ANDROID = "{http://schemas.android.com/apk/res/android}" +KINDS = {"activity", "activity-alias", "service", "receiver", "provider"} +DATA_ATTRIBUTES = { + "scheme": "scheme", "host": "host", "port": "port", "path": "path", + "path_prefix": "pathPrefix", "path_pattern": "pathPattern", + "path_advanced_pattern": "pathAdvancedPattern", "path_suffix": "pathSuffix", + "mime_type": "mimeType", +} + + +def filter_facts(element): + def names(tag): + return [value.get(ANDROID + "name") for value in element.findall(tag) + if value.get(ANDROID + "name") is not None] + + return { + "priority": element.get(ANDROID + "priority"), + "actions": names("action"), "categories": names("category"), + "data": [{key: value.get(ANDROID + attribute) + for key, attribute in DATA_ATTRIBUTES.items()} + for value in element.findall("data")], + } + + +def main(): + with open(sys.argv[1], encoding="utf-8") as source: + bundle = json.load(source) + parent = next(record for record in bundle["records"] if record["evidence_id"] == sys.argv[2]) + root = ET.fromstring(parent["normalized_result"]["manifest"]["text"]) + package = root.get("package") + application = root.find("application") + assert application is not None and package, "Verification corpus requires a known package/application" + inventory = [] + ordinal = 0 + for component in application: + if component.tag not in KINDS: + continue + declared = component.get(ANDROID + "name") + if declared is None: + continue + filters = [filter_facts(value) for value in component.findall("intent-filter")] + enabled = component.get(ANDROID + "enabled") + permission = component.get(ANDROID + "permission") + if permission is None and component.tag != "activity-alias": + permission = application.get(ANDROID + "permission") + name = (package + declared if declared.startswith(".") else + package + "." + declared if "." not in declared else declared) + inventory.append({ + "source_index": ordinal, "kind": component.tag, + "declared_name": declared, "name": name, + "enabled": True if enabled == "true" else False if enabled == "false" else None, + "enabled_declaration": enabled, "process": component.get(ANDROID + "process"), + "permission": permission or None, "intent_filters": filters, + "meta_data": [{"name": value.get(ANDROID + "name")} + for value in component.findall("meta-data")], + }) + ordinal += 1 + selected = [row for row in inventory + if any(sys.argv[3] in value["actions"] for value in row["intent_filters"])] + print(json.dumps({"components": selected, "inventory": inventory, "source_components": ordinal}, + ensure_ascii=False, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/experiments/analysis-efficiency/fixtures/android-components/res/values/values.xml b/experiments/analysis-efficiency/fixtures/android-components/res/values/values.xml new file mode 100644 index 0000000000..ada5fa0999 --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/res/values/values.xml @@ -0,0 +1,4 @@ + + false + 80 + diff --git a/experiments/analysis-efficiency/fixtures/android-components/verify-fixture.py b/experiments/analysis-efficiency/fixtures/android-components/verify-fixture.py new file mode 100644 index 0000000000..9cc8d9e782 --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/android-components/verify-fixture.py @@ -0,0 +1,58 @@ +"""Check real AAPT2/JADX/CLI facts against the source-built manifest corpus.""" +import hashlib +import json +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +reply = json.loads((root / "cli.json").read_text())["summary"] +view = reply["normalized_result"] +bundle = json.loads((root / "evidence.json").read_text()) +parent = next(record for record in bundle["records"] + if record["evidence_id"] == view["parent_evidence_id"]) +sha256 = hashlib.sha256((root / "manifest.apk").read_bytes()).hexdigest() +assert parent["subject"]["digest"]["sha256"] == view["artifact"]["sha256"] == sha256 +assert reply["evidence_links"] == [parent["evidence_id"]] +assert view["source_status"] == "complete" and view["source_components"] == 3 +assert view["coverage"]["exhausted"] and view["coverage"]["total"] == 3 +activity, receiver, provider = view["items"] +assert [row["source_index"] for row in view["items"]] == [0, 1, 2] +assert all(row["declaration_coverage"] == "complete" for row in view["items"]) +assert activity["enabled"] is False and activity["process"] == ":activity" +first, second = activity["intent_filters"] +assert first["actions"] == ["android.intent.action.VIEW"] and first["priority"] == "7" +assert first["data"][0]["scheme"] == "https" +assert first["data"][0]["host"] == "example.test" and first["data"][0]["path_prefix"] == "/open" +assert second["actions"] == ["android.intent.action.EDIT"] +assert second["priority"] == "@integer/filter_priority" +assert second["data"][0]["scheme"] == "content" and second["data"][0]["mime_type"] == "text/plain" +assert activity["meta_data"][0]["name"] == "public.configuration" +assert "value" not in activity["meta_data"][0] +assert receiver["enabled"] is None and receiver["enabled_declaration"] == "@bool/receiver_enabled" +assert receiver["process"] == ":sms" and receiver["permission"] == "android.permission.BROADCAST_SMS" +assert receiver["intent_filters"][0]["priority"] == "-10" +assert provider["enabled"] is True and provider["grant_uri_permissions"] is True +assert provider["grant_uri_permissions_declaration"] == "true" and provider["process"] is None +manifest = parent["normalized_result"]["manifest"]["text"] +assert manifest.isascii() and "retained-in-manifest" in manifest +for row in view["items"]: + nodes = [(row, row["kind"])] + for element in row["intent_filters"]: + nodes.append((element, "intent-filter")) + nodes.extend((data, "data") for data in element["data"]) + nodes.extend((metadata, "meta-data") for metadata in row["meta_data"]) + for node, tag in nodes: + location = node["location"] + offset = location["offset"] + assert manifest[offset:].startswith("<" + tag) + assert manifest[:offset].count("\n") + 1 == location["line"] + assert offset - manifest.rfind("\n", 0, offset) == location["column"] +tree = (root / "aapt2-xmltree.txt").read_text() +for fragment in [':scheme(0x01010027)="https"', ':host(0x01010028)="example.test"', + ':pathPrefix(0x0101002b)="/open"', ':priority(0x0101001c)=7', + ':priority(0x0101001c)=-10', ':priority(0x0101001c)=@0x7f020000', + ':enabled(0x0101000e)=@0x7f010000', ':grantUriPermissions(0x0101001b)=true']: + assert fragment in tree, fragment +print(json.dumps({"compiled_manifest_cli": "passed", "sha256": sha256, + "components": 3, "filter_grouping": True, "locations": True, + "resource_values_remain_declarations": True, "aapt2_oracle": True})) diff --git a/experiments/analysis-efficiency/fixtures/browser-saved-values/immediate-followup.html b/experiments/analysis-efficiency/fixtures/browser-saved-values/immediate-followup.html new file mode 100644 index 0000000000..eded9535ba --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/browser-saved-values/immediate-followup.html @@ -0,0 +1,30 @@ + + +Immediate saved-response follow-up fixture + +pending + + diff --git a/experiments/analysis-efficiency/fixtures/browser-saved-values/index.html b/experiments/analysis-efficiency/fixtures/browser-saved-values/index.html new file mode 100644 index 0000000000..0d6f93337f --- /dev/null +++ b/experiments/analysis-efficiency/fixtures/browser-saved-values/index.html @@ -0,0 +1,39 @@ + + PocketBase saved value verifier + + +

+
diff --git a/experiments/analysis-efficiency/fixtures/browser-saved-values/whole-json.html b/experiments/analysis-efficiency/fixtures/browser-saved-values/whole-json.html
new file mode 100644
index 0000000000..d59448448a
--- /dev/null
+++ b/experiments/analysis-efficiency/fixtures/browser-saved-values/whole-json.html
@@ -0,0 +1,15 @@
+
+
+Declared JSON payload privacy fixture
+pending
+
diff --git a/package.json b/package.json
index c5d35afb1e..ab669a9eb2 100644
--- a/package.json
+++ b/package.json
@@ -112,21 +112,21 @@
     "prebuild": "npm run deps:check",
     "build": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run artifacts:skills --force",
     "precheck": "npm run deps:check",
-    "check": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check && npm run verify:architecture-guards && npm run knip:production && npm run lint:typed",
+    "check": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint format:check knip metadata:check && npm run verify:architecture-guards && npm run knip:production && npm run lint:typed",
     "precheck:fast": "npm run deps:check",
     "check:fast": "node scripts/run-turbo.mjs run typecheck lint --output-logs=errors-only",
     "precheck:changed": "npm run deps:check",
     "check:changed": "npm run check:fast && npm run test:changed",
     "precheck:test": "npm run deps:check",
-    "check:test": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run",
+    "check:test": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint format:check knip metadata:check test:run",
     "precheck:pr": "npm run deps:check",
-    "check:pr": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint:built format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos && npm run knip:production && npm run lint:typed",
+    "check:pr": "node scripts/run-exclusive.mjs artifacts node scripts/run-turbo.mjs run typecheck lint format:check knip metadata:check test:run && npm run test:python && npm run docs:check && npm run jscpd && npm run scan:todos && npm run knip:production && npm run lint:typed",
     "precheck:ci": "npm run deps:check",
     "precheck:ci:fast": "npm run deps:check",
     "precheck:ci:deep": "npm run deps:check",
     "check:ci": "npm run check:ci:fast && npm run check:ci:deep",
     "check:ci:fast": "node scripts/run-exclusive.mjs artifacts npm run check:ci:fast:tasks",
-    "check:ci:fast:tasks": "node scripts/run-turbo.mjs run typecheck lint:built format:check",
+    "check:ci:fast:tasks": "node scripts/run-turbo.mjs run typecheck lint format:check",
     "check:ci:deep": "node scripts/run-exclusive.mjs artifacts npm run check:ci:deep:tasks && npm run verify:architecture-guards && npm run verify:test-discovery && npm run test:python && npm run knip:production && npm run lint:typed",
     "check:ci:deep:tasks": "node scripts/run-turbo.mjs run knip metadata:check jscpd scan:todos",
     "ci:static-context": "node scripts/ci/static-task-context.mjs",
@@ -137,7 +137,6 @@
     "format:check": "oxfmt --check .",
     "lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries",
     "lint:details": "oxlint && npm run verify:module-boundaries",
-    "lint:built": "npm run lint",
     "lint:fix": "oxlint --fix && npm run verify:module-boundaries",
     "knip": "knip --cache",
     "jscpd": "node scripts/ci/static-check-report.mjs duplicates",
diff --git a/scripts/ci/artifacts.mjs b/scripts/ci/artifacts.mjs
index 0745c4835f..6e7e6822ab 100644
--- a/scripts/ci/artifacts.mjs
+++ b/scripts/ci/artifacts.mjs
@@ -378,7 +378,7 @@ function blobIdentityStatus(diagnostics, blobs) {
 }
 function sameTaskHashes(diagnostics, left, right) {
   return diagnostics.every((diagnostic) => {
-    const task = diagnostic.kind === "jscpd" ? "jscpd" : "lint:built";
+    const task = diagnostic.kind === "jscpd" ? "jscpd" : "lint";
     return (
       TASK_HASH.test(left.taskHashes[task] ?? "") &&
       left.taskHashes[task] === right.taskHashes[task]
diff --git a/scripts/lib/browser-scenario-verifier.mjs b/scripts/lib/browser-scenario-verifier.mjs
index 7a56f990c0..aeca987183 100644
--- a/scripts/lib/browser-scenario-verifier.mjs
+++ b/scripts/lib/browser-scenario-verifier.mjs
@@ -233,11 +233,19 @@ export function assertScenarioCapture(capture) {
   const final = capture.steps.at(-1);
   if (
     final === undefined ||
-    Object.values(final.artifacts).some(({ state }) => state !== "captured")
+    Object.entries(final.artifacts).some(
+      ([kind, { state }]) => kind !== "screenshot" && state !== "captured",
+    )
   )
     throw new Error(
       `Scenario launch did not capture every requested artifact: ${JSON.stringify(final?.artifacts ?? null)}`,
     );
+  for (const step of capture.steps)
+    assert.ok(
+      step.artifacts.screenshot.state === "missing" &&
+        step.artifacts.screenshot.reason.includes("sensitive"),
+      "Sensitive scenario screenshots must be withheld from its first step",
+    );
   const storageNames =
     final.artifacts.storage.state === "captured"
       ? final.artifacts.storage.value.local_storage.map(({ name }) => name)
@@ -281,10 +289,13 @@ export function assertScenarioCapture(capture) {
   );
   // This fixture polls continuously. A request crossing the capture cutoff
   // makes later activity unknown even when no response body was selected.
-  const expectedMissing = unfinished.length === 0 ? [] : ["events"];
+  const expectedMissing = [
+    ...(unfinished.length === 0 ? [] : ["events"]),
+    "screenshot",
+  ].sort();
   if (
     capture.events.items.length === 0 ||
-    capture.completeness.equality_eligible !== (unfinished.length === 0) ||
+    capture.completeness.equality_eligible !== false ||
     JSON.stringify(capture.completeness.missing_sections) !==
       JSON.stringify(expectedMissing) ||
     capture.completeness.truncated_sections.length !== 0
diff --git a/scripts/rea.mjs b/scripts/rea.mjs
index 27f334994a..15fc98c9a5 100755
--- a/scripts/rea.mjs
+++ b/scripts/rea.mjs
@@ -80,14 +80,15 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
 } else {
   const {
     cliConfigurationError,
-    normalizeCliArguments,
     renderCliOutputArgumentError,
     renderEmptyFilteredCliOutput,
     renderVersionReport,
     parseCliOutputArguments,
   } = await import("../dist/cli-runtime/cliOutput.js");
   guardClosedStdout();
-  const cliArgs = isShellCompletion ? args : normalizeCliArguments(args);
+  const cliArgs = isShellCompletion
+    ? args
+    : builtinArguments.normalizeCliArguments(args);
   // Completion words are transport data, including partial output flags.
   const outputOptions = parseCliOutputArguments(
     isShellCompletion ? [] : cliArgs,
diff --git a/scripts/verify-real-ghidra-inventory.mjs b/scripts/verify-real-ghidra-inventory.mjs
index d5e033a99a..d89f726562 100644
--- a/scripts/verify-real-ghidra-inventory.mjs
+++ b/scripts/verify-real-ghidra-inventory.mjs
@@ -1,3 +1,4 @@
+import { filterGhidraLiteralCandidates } from "../dist/ghidra/GhidraLiteralSearch.js";
 import { parseGhidraInventoryResult } from "../dist/ghidra/GhidraInventoryValues.js";
 
 import { findValue, matchesSymbol } from "./verify-real-ghidra-assertions.mjs";
@@ -16,7 +17,12 @@ export async function call(client, operation, parameters) {
     );
   const parsed = parseGhidraInventoryResult(operation, called.value);
   if (!parsed.ok) throw parsed.error;
-  return parsed.value.value;
+  return (operation === "search_procedures" ||
+    operation === "search_strings") &&
+    parameters.mode === "literal" &&
+    parameters.case_sensitive === false
+    ? filterGhidraLiteralCandidates(parsed.value.value, parameters.pattern)
+    : parsed.value.value;
 }
 
 export async function allItems(client, operation, parameters) {
diff --git a/scripts/verify/android/metadata.mjs b/scripts/verify/android/metadata.mjs
index 31ed5bed3e..c4efc63b12 100644
--- a/scripts/verify/android/metadata.mjs
+++ b/scripts/verify/android/metadata.mjs
@@ -81,6 +81,10 @@ export const verifyAndroidMetadata = async ({
       result.stdout,
       /RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true/u,
     );
+    assert.match(
+      result.stdout,
+      /DEFAULT_PACKAGE_SELECTOR_REGRESSION_PASS raw=true display=true metadata=true method=true references=true frozen=true/u,
+    );
     assert.match(
       result.stdout,
       /INITIALIZER_REGRESSION_PASS folded=2 bounded=true/u,
diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs
index 78a3958e53..1aad503f91 100644
--- a/scripts/verify/architecture-guards.mjs
+++ b/scripts/verify/architecture-guards.mjs
@@ -59,6 +59,13 @@ const check = (label, selected, pattern, allow = []) => {
     );
 };
 
+// Prerequisite inspection lifetime is an application contract shared by adapters.
+check(
+  "provider-owned inspection lifetime (use src/application/ProviderInspectionScope)",
+  files,
+  /providers\/ProviderInspectionScope\.js/,
+);
+
 check(
   "src/domain uses localeCompare (use compareUnicodeCodePoints)",
   inDir("src/domain/"),
diff --git a/src/adb/AdbCommand.capture.test.ts b/src/adb/AdbCommand.capture.test.ts
new file mode 100644
index 0000000000..41b87d2cbb
--- /dev/null
+++ b/src/adb/AdbCommand.capture.test.ts
@@ -0,0 +1,86 @@
+import { expect, it } from "vitest";
+
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import { runAdbCapture } from "./AdbCommand.js";
+
+// Exercise the real subprocess capture and public error projection, substituting
+// only the bring-your-own executable. These are not Android-device checks.
+it.each([
+  {
+    name: "overflow",
+    source: 'process.stdout.write("X".repeat(4096))',
+    maxOutputBytes: 64,
+    code: "resource_constraint",
+    stdout: "X".repeat(64),
+    stderr: "",
+    truncated: true,
+  },
+  {
+    name: "timeout",
+    source:
+      'process.stdout.write("partial-雪"); process.stderr.write("waiting\\n"); setInterval(() => {}, 1000)',
+    code: "provider_timeout",
+    stdout: "partial-雪",
+    stderr: "waiting\n",
+  },
+  {
+    name: "offline device",
+    source:
+      'process.stdout.write("partial-雪"); process.stderr.write("adb: error: device offline\\n"); process.exitCode = 1',
+    code: "unsupported_target",
+    stdout: "partial-雪",
+    stderr: "adb: error: device offline\n",
+  },
+  {
+    name: "local path refusal",
+    source:
+      'process.stdout.write("partial-雪"); process.stderr.write("adb: error: cannot stat \'/host/token file\': Permission denied\\n"); process.exitCode = 1',
+    code: "unsupported_target",
+    stdout: "partial-雪",
+    stderr: "adb: error: cannot stat '/host/token file': Permission denied\n",
+  },
+])("preserves bounded capture on $name", async (testCase) => {
+  const result = await runAdbCapture(
+    "pull_adb_file",
+    {
+      device: "caller-device",
+      path: "/remote/source",
+      localPath: "/host/token file",
+    },
+    {
+      binary: process.execPath,
+      pathSource: "environment",
+      environment: { REA_TEST_PRIVATE: "ambient-value-must-not-be-persisted" },
+    },
+    ["-e", testCase.source],
+    {
+      timeoutMs: testCase.name === "timeout" ? 500 : 5_000,
+      maxOutputBytes: testCase.maxOutputBytes ?? 1024,
+    },
+    undefined,
+  );
+  expect(result.ok).toBe(false);
+  if (result.ok) return;
+  const projected = projectAnalysisError(result.error);
+  expect(projected).toMatchObject({
+    code: testCase.code,
+    details: {
+      captured_output: {
+        stdout: testCase.stdout,
+        stderr: testCase.stderr,
+        stdout_bytes: Buffer.byteLength(testCase.stdout),
+        stderr_bytes: Buffer.byteLength(testCase.stderr),
+        truncated: testCase.truncated ?? false,
+      },
+    },
+  });
+  if (testCase.code === "unsupported_target")
+    expect(projected.details?.path).toBe(
+      testCase.name === "local path refusal"
+        ? "/host/token file"
+        : "caller-device",
+    );
+  expect(JSON.stringify(projected)).not.toContain(
+    "ambient-value-must-not-be-persisted",
+  );
+});
diff --git a/src/adb/AdbCommand.ts b/src/adb/AdbCommand.ts
index af67e9f932..be544ec3e5 100644
--- a/src/adb/AdbCommand.ts
+++ b/src/adb/AdbCommand.ts
@@ -130,6 +130,10 @@ export const runAdbCapture = async (
       return err(
         new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
       );
+    const detail = {
+      cause,
+      capturedOutput: execFileFailureCapturedOutput(failure),
+    };
     if (failure.outputTruncated) {
       const name = `The adb output capture allowance of ${String(limits.maxOutputBytes)} bytes (this release has no runtime override)`;
       const allowance =
@@ -145,7 +149,7 @@ export const runAdbCapture = async (
           "memory",
           `adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
           { max_output_bytes: limits.maxOutputBytes },
-          { cause, ...allowance },
+          { ...detail, ...allowance },
         ),
       );
     }
@@ -181,9 +185,12 @@ export const runAdbCapture = async (
       )
     )
       return err(
-        new AnalysisUnsupportedTargetError(operation, target.device, excerpt, {
-          cause,
-        }),
+        new AnalysisUnsupportedTargetError(
+          operation,
+          target.device,
+          excerpt,
+          detail,
+        ),
       );
     if (
       /No such file or directory|Permission denied|read: Permission denied/iu.test(
@@ -201,13 +208,13 @@ export const runAdbCapture = async (
           ? localPath
           : (target.path ?? target.device);
       return err(
-        new AnalysisUnsupportedTargetError(operation, path, excerpt, { cause }),
+        new AnalysisUnsupportedTargetError(operation, path, excerpt, detail),
       );
     }
     if (failure.killed)
       return err(
         new AnalysisTimeoutError(operation, limits.timeoutMs, {
-          cause,
+          ...detail,
           userMessage: `adb ${operation} reached its ${String(limits.timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`,
           remediationAction: `This ${String(limits.timeoutMs)} ms adb deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
         }),
diff --git a/src/android/AndroidManifestComponentDetails.ts b/src/android/AndroidManifestComponentDetails.ts
new file mode 100644
index 0000000000..a3f1d7ebdc
--- /dev/null
+++ b/src/android/AndroidManifestComponentDetails.ts
@@ -0,0 +1,249 @@
+import type { Element } from "@xmldom/xmldom";
+import type { z } from "zod";
+import type {
+  AndroidManifestComponent,
+  androidIntentDataSchema,
+  androidIntentFilterSchema,
+  androidComponentMetadataSchema,
+} from "../domain/android/androidManifestComponents.js";
+import {
+  androidAttribute,
+  createManifestLocationReader,
+  isAndroidAttribute,
+  isManifestElement,
+} from "./AndroidManifestXml.js";
+
+type Filter = z.output;
+type Data = z.output;
+type Metadata = z.output;
+
+const projectedDataAttributes = new Set([
+  "scheme",
+  "host",
+  "port",
+  "path",
+  "pathPrefix",
+  "pathPattern",
+  "pathAdvancedPattern",
+  "pathSuffix",
+  "mimeType",
+]);
+
+const literalBoolean = (value: string | null): boolean | null =>
+  value === "true" ? true : value === "false" ? false : null;
+
+/** One decoded-manifest lifetime owns nested declarations and cumulative copy budgets. */
+export const createAndroidManifestDetailsReader = (
+  source: string,
+  limits: {
+    readonly intentFilters: number;
+    readonly intentValues: number;
+    readonly processBytes: number;
+    readonly detailBytes: number;
+  },
+) => {
+  const locationOf = createManifestLocationReader(source);
+  let processRemaining = limits.processBytes;
+  let detailsRemaining = limits.detailBytes;
+  return (component: Element, declarationIndex: number) => {
+    let partial = false;
+    let detailLimited = false;
+    let processLimited = false;
+    let unsupported = 0;
+    let remainingRecords = limits.intentValues;
+    const take = (value: string | null): string | null => {
+      if (value === null) return null;
+      const bytes = Buffer.byteLength(value);
+      if (bytes > detailsRemaining) {
+        partial = true;
+        detailLimited = true;
+        return null;
+      }
+      detailsRemaining -= bytes;
+      return value;
+    };
+    const admit = (): boolean => {
+      if (remainingRecords === 0) {
+        partial = true;
+        detailLimited = true;
+        return false;
+      }
+      remainingRecords -= 1;
+      return true;
+    };
+    const enabledDeclaration = androidAttribute(component, "enabled");
+    const enabled = literalBoolean(enabledDeclaration);
+    const grantDeclaration =
+      component.nodeName === "provider"
+        ? androidAttribute(component, "grantUriPermissions")
+        : null;
+    const grant = literalBoolean(grantDeclaration);
+    const declaredProcess = androidAttribute(component, "process");
+    let process: string | null = null;
+    if (declaredProcess !== null) {
+      const bytes = Buffer.byteLength(declaredProcess);
+      if (bytes > processRemaining) {
+        partial = true;
+        processLimited = true;
+      } else {
+        process = declaredProcess;
+        processRemaining -= bytes;
+      }
+    }
+    const filters: Filter[] = [];
+    const metadata: Metadata[] = [];
+    const actions: string[] = [];
+    const categories: string[] = [];
+    const actionNames = new Set();
+    const categoryNames = new Set();
+    let legacyValuesRemaining = limits.intentValues;
+    let legacyTruncated = false;
+    let filterCount = 0;
+    for (let index = 0; index < component.childNodes.length; index += 1) {
+      const child = component.childNodes.item(index);
+      if (!isManifestElement(child)) continue;
+      if (child.nodeName === "meta-data") {
+        if (admit())
+          metadata.push({
+            name: take(androidAttribute(child, "name")),
+            location: locationOf(child),
+          });
+        continue;
+      }
+      if (child.nodeName !== "intent-filter") continue;
+      const filterIndex = filterCount++;
+      if (filterIndex >= limits.intentFilters) {
+        partial = true;
+        legacyTruncated = true;
+        continue;
+      }
+      const filterActions: string[] = [];
+      const filterCategories: string[] = [];
+      const data: Data[] = [];
+      let filterPartial = false;
+      const priority = take(androidAttribute(child, "priority"));
+      for (let at = 0; at < child.childNodes.length; at += 1) {
+        const member = child.childNodes.item(at);
+        if (!isManifestElement(member)) continue;
+        if (member.nodeName === "action" || member.nodeName === "category") {
+          const value = androidAttribute(member, "name");
+          if (value === null) continue;
+          const names =
+            member.nodeName === "action" ? actionNames : categoryNames;
+          if (!names.has(value)) {
+            if (legacyValuesRemaining === 0) legacyTruncated = true;
+            else {
+              legacyValuesRemaining -= 1;
+              names.add(value);
+              (member.nodeName === "action" ? actions : categories).push(value);
+            }
+          }
+          if (!admit()) {
+            filterPartial = true;
+            continue;
+          }
+          const copied = take(value);
+          if (copied === null) {
+            filterPartial = true;
+            continue;
+          }
+          (member.nodeName === "action"
+            ? filterActions
+            : filterCategories
+          ).push(copied);
+        } else if (member.nodeName === "data") {
+          if (!admit()) {
+            filterPartial = true;
+            continue;
+          }
+          for (
+            let attributeIndex = 0;
+            attributeIndex < member.attributes.length;
+            attributeIndex += 1
+          ) {
+            const attribute = member.attributes.item(attributeIndex);
+            if (
+              attribute !== null &&
+              isAndroidAttribute(attribute) &&
+              (attribute.localName === null ||
+                !projectedDataAttributes.has(attribute.localName))
+            ) {
+              // A retained data declaration can have unprojected matching constraints.
+              unsupported += 1;
+              filterPartial = true;
+              partial = true;
+              break;
+            }
+          }
+          data.push({
+            location: locationOf(member),
+            scheme: take(androidAttribute(member, "scheme")),
+            host: take(androidAttribute(member, "host")),
+            port: take(androidAttribute(member, "port")),
+            path: take(androidAttribute(member, "path")),
+            path_prefix: take(androidAttribute(member, "pathPrefix")),
+            path_pattern: take(androidAttribute(member, "pathPattern")),
+            path_advanced_pattern: take(
+              androidAttribute(member, "pathAdvancedPattern"),
+            ),
+            path_suffix: take(androidAttribute(member, "pathSuffix")),
+            mime_type: take(androidAttribute(member, "mimeType")),
+          });
+        } else {
+          // Newer platform filter constructs remain source-accessible, never an empty match claim.
+          unsupported += 1;
+          filterPartial = true;
+          partial = true;
+        }
+      }
+      filters.push({
+        source_index: filterIndex,
+        location: locationOf(child),
+        priority,
+        actions: filterActions,
+        categories: filterCategories,
+        data,
+        coverage: filterPartial || detailLimited ? "partial" : "complete",
+      });
+    }
+    const enabledRaw = take(enabledDeclaration);
+    const grantRaw = take(grantDeclaration);
+    const facts = {
+      declaration_index: declarationIndex,
+      location: locationOf(component),
+      declaration_coverage: partial ? "partial" : "complete",
+      enabled,
+      enabled_declaration: enabledRaw,
+      process,
+      grant_uri_permissions: grant,
+      grant_uri_permissions_declaration: grantRaw,
+      intent_filters: filters,
+      meta_data: metadata,
+    } satisfies Pick<
+      AndroidManifestComponent,
+      | "declaration_index"
+      | "location"
+      | "declaration_coverage"
+      | "enabled"
+      | "enabled_declaration"
+      | "process"
+      | "grant_uri_permissions"
+      | "grant_uri_permissions_declaration"
+      | "intent_filters"
+      | "meta_data"
+    >;
+    return {
+      facts,
+      actions,
+      categories,
+      filterCount,
+      legacyTruncated,
+      partial,
+      detailLimited,
+      processLimited,
+      unsupported,
+      nonliteralEnabled: enabledDeclaration !== null && enabled === null,
+      nonliteralGrant: grantDeclaration !== null && grant === null,
+    };
+  };
+};
diff --git a/src/android/AndroidManifestComponents.ts b/src/android/AndroidManifestComponents.ts
index 3efc4e1e71..92fbc7be63 100644
--- a/src/android/AndroidManifestComponents.ts
+++ b/src/android/AndroidManifestComponents.ts
@@ -1,16 +1,17 @@
-import type { Element, Node } from "@xmldom/xmldom";
-
 import { parseXmlDocument } from "../domain/xmlDocument.js";
-
-/** Component kinds a decoded AndroidManifest can declare under . */
-export const ANDROID_COMPONENT_KINDS = [
-  "activity",
-  "activity-alias",
-  "service",
-  "receiver",
-  "provider",
-] as const;
-export type AndroidComponentKind = (typeof ANDROID_COMPONENT_KINDS)[number];
+import {
+  androidAttribute,
+  plainAttribute,
+  childNodes,
+  isManifestElement,
+} from "./AndroidManifestXml.js";
+import { createAndroidManifestDetailsReader } from "./AndroidManifestComponentDetails.js";
+import {
+  ANDROID_COMPONENT_KINDS,
+  type AndroidComponentKind,
+  type ObservedAndroidManifestComponent,
+  type ObservedAndroidManifestComponentInventory,
+} from "../domain/android/androidManifestComponents.js";
 
 /** Bounds keep a hostile manifest from exhausting the inspection. */
 export const ANDROID_MANIFEST_LIMITS = {
@@ -23,103 +24,23 @@ export const ANDROID_MANIFEST_LIMITS = {
   authorityBytes: 1024 * 1024,
   /** Total copied permission-value bytes, including inherited defaults. */
   permissionBytes: 1024 * 1024,
+  /** Total copied android:process bytes across projected declarations. */
+  processBytes: 1024 * 1024,
+  /** Copied nested declaration values; complete text remains retained. */
+  detailBytes: 1024 * 1024,
 } as const;
 
-/** The attribute namespace every component facet uses. */
-const ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
-
-export interface AndroidManifestComponent {
-  readonly kind: AndroidComponentKind;
-  /** Class name as written in the manifest. */
-  readonly declared_name: string;
-  /**
-   * Fully qualified name with the manifest package applied, or the declared
-   * name unchanged when it cannot be resolved or its syntax is not
-   * established.
-   */
-  readonly name: string;
-  /**
-   * Declared android:exported, or null when the attribute is absent or set
-   * to a value other than the literals true or false, whose effective state
-   * the parser cannot resolve.
-   */
-  readonly exported: boolean | null;
-  /**
-   * Android's default export state when android:exported is absent: the
-   * intent-filter rule for activities, services, receivers and aliases, and
-   * the target-SDK rule for providers. Null when the provider rule cannot be
-   * evaluated because the target SDK is unknown, or when the attribute is
-   * present but not a literal boolean, since the default does not govern a
-   * declared value.
-   */
-  readonly exported_default: boolean | null;
-  readonly permission: string | null;
-  /** Source of the effective permission projection; unknown when its copy is capped. */
-  readonly permission_source: "component" | "application" | "none" | "unknown";
-  readonly intent_filter_count: number;
-  readonly actions: readonly string[];
-  readonly categories: readonly string[];
-  /** Declared android:foregroundServiceType on services; null otherwise. */
-  readonly foreground_service_type: string | null;
-  /** Exact declared provider authority segments, split on ";"; malformed segments remain raw. */
-  readonly authorities: readonly string[] | null;
-  /** Exact declared android:targetActivity on activity-alias; malformed references remain raw and make the inventory partial. */
-  readonly target_activity: string | null;
-}
-
-export interface AndroidManifestComponentInventory {
-  readonly status: "complete" | "partial" | "unavailable";
-  readonly items: readonly AndroidManifestComponent[];
-  readonly limitations: readonly string[];
-}
-
 const PARTIAL_LIMITATION =
   "The decoded manifest text is truncated, so its component inventory is not established; the retained manifest is partial.";
 
 const unavailable = (
   limitation: string,
-): AndroidManifestComponentInventory => ({
+): ObservedAndroidManifestComponentInventory => ({
   status: "unavailable",
   items: [],
   limitations: [limitation],
 });
 
-const childNodes = (element: Element): readonly Node[] =>
-  Array.from(element.childNodes);
-
-/** Manifest tags are unnamespaced; Android's namespace qualifies attributes. */
-const isManifestElement = (node: Node | null): node is Element =>
-  node !== null &&
-  node.nodeType === 1 &&
-  (node as Element).namespaceURI === null;
-
-/** Read an unnamespaced attribute, the shape manifest `package` uses. */
-const plainAttribute = (element: Element, name: string): string | null => {
-  const value = element.getAttribute(name);
-  return value === "" ? null : value;
-};
-
-/**
- * Read an android-namespaced attribute. XML prefixes are aliases, so a
- * same-local-name attribute from another namespace is never a match.
- */
-const androidAttribute = (
-  element: Element,
-  localName: string,
-): string | null => {
-  const attributes = element.attributes;
-  for (let index = 0; index < attributes.length; index += 1) {
-    const node = attributes.item(index);
-    if (node === null || node.localName !== localName) continue;
-    if (node.namespaceURI === ANDROID_NAMESPACE) return node.nodeValue;
-    // Without namespace information (an undeclared prefix), the conventional
-    // android: prefix is the only remaining signal.
-    if (node.namespaceURI === null && node.prefix === "android")
-      return node.nodeValue;
-  }
-  return null;
-};
-
 /** Android application-ID grammar, with the platform package exception. */
 const hasSupportedAndroidPackageNameSyntax = (name: string): boolean =>
   name === "android" ||
@@ -184,37 +105,6 @@ const androidTargetSdkLevel = (targetSdk: string | null): number | null => {
     : null;
 };
 
-/**
- * Collect unique intent-filter actions and categories. The value budget is
- * shared across both arrays, matching the stated per-component bound.
- */
-const intentValues = (
-  filters: readonly Element[],
-): { actions: string[]; categories: string[]; truncated: boolean } => {
-  const actions: string[] = [];
-  const categories: string[] = [];
-  let truncated = filters.length > ANDROID_MANIFEST_LIMITS.intentFilters;
-  let budget = ANDROID_MANIFEST_LIMITS.intentValues;
-  for (const filter of filters.slice(0, ANDROID_MANIFEST_LIMITS.intentFilters))
-    for (const member of childNodes(filter)) {
-      if (!isManifestElement(member)) continue;
-      const element = member;
-      if (element.nodeName !== "action" && element.nodeName !== "category")
-        continue;
-      const value = androidAttribute(element, "name");
-      if (value === null) continue;
-      const target = element.nodeName === "action" ? actions : categories;
-      if (target.includes(value)) continue;
-      if (budget <= 0) {
-        truncated = true;
-        continue;
-      }
-      budget -= 1;
-      target.push(value);
-    }
-  return { actions, categories, truncated };
-};
-
 /** Split a provider authorities declaration within its own bound. */
 const providerAuthorities = (
   declared: string,
@@ -262,7 +152,7 @@ export const parseAndroidManifestComponents = (
   coverage: "complete" | "partial",
   declaredTargetSdk: string | null = null,
   minimumSdk: string | null = null,
-): AndroidManifestComponentInventory => {
+): ObservedAndroidManifestComponentInventory => {
   if (coverage === "partial")
     return {
       status: "partial",
@@ -295,7 +185,7 @@ export const parseAndroidManifestComponents = (
   const packageNameBytes =
     packageName === null ? 0 : Buffer.byteLength(packageName);
   const limitations: string[] = [];
-  const items: AndroidManifestComponent[] = [];
+  const items: ObservedAndroidManifestComponent[] = [];
   let truncated = false;
   let applicationSeen = false;
   let unnamed = 0;
@@ -313,9 +203,18 @@ export const parseAndroidManifestComponents = (
   let capacityTruncated = false;
   let permissionBudgetLimited = false;
   let unresolvedPermissionTokens = 0;
+  let nonliteralEnabled = 0;
+  let nonliteralGrantUriPermissions = 0;
+  let processBudgetLimited = false;
   let nameBudget = ANDROID_MANIFEST_LIMITS.resolvedNameBytes;
   let authorityBudget = ANDROID_MANIFEST_LIMITS.authorityBytes;
   let permissionBudget = ANDROID_MANIFEST_LIMITS.permissionBytes;
+  let detailBudgetLimited = false;
+  let unsupportedFilterMembers = 0;
+  const readDetails = createAndroidManifestDetailsReader(
+    manifestText,
+    ANDROID_MANIFEST_LIMITS,
+  );
   const children = childNodes(root);
   let targetSdkDeclared = false;
   let minimumSdkDeclared = false;
@@ -375,11 +274,21 @@ export const parseAndroidManifestComponents = (
         continue;
       }
       const unresolvedName = containsUnresolvedToken(declared);
-      const filters = childNodes(component).filter(
-        (entry): entry is Element =>
-          isManifestElement(entry) && entry.nodeName === "intent-filter",
-      );
-      const collected = intentValues(filters);
+      const details = readDetails(component, items.length + unnamed);
+      const collected = {
+        actions: details.actions,
+        categories: details.categories,
+        truncated: details.legacyTruncated,
+      };
+      if (details.partial) truncated = true;
+      if (details.detailLimited) detailBudgetLimited = true;
+      unsupportedFilterMembers += details.unsupported;
+      if (details.processLimited) {
+        processBudgetLimited = true;
+        capacityTruncated = true;
+      }
+      if (details.nonliteralEnabled) nonliteralEnabled += 1;
+      if (details.nonliteralGrant) nonliteralGrantUriPermissions += 1;
       if (collected.truncated) {
         truncated = true;
         capacityTruncated = true;
@@ -435,7 +344,7 @@ export const parseAndroidManifestComponents = (
             precedingActivityTargetNames.add(bareName);
         }
       }
-      let authorities: readonly string[] | null = null;
+      let authorities: string[] | null = null;
       if (component.nodeName === "provider") {
         if (
           androidAttribute(component, "readPermission") !== null ||
@@ -549,7 +458,7 @@ export const parseAndroidManifestComponents = (
         declaredExported === null &&
         targetSdkLevel !== null &&
         targetSdkLevel >= 31 &&
-        filters.length > 0 &&
+        details.filterCount > 0 &&
         (component.nodeName === "activity" ||
           component.nodeName === "activity-alias" ||
           component.nodeName === "service" ||
@@ -557,7 +466,7 @@ export const parseAndroidManifestComponents = (
       const filteredExportUnknown =
         declaredExported === null &&
         targetSdkLevel === null &&
-        filters.length > 0 &&
+        details.filterCount > 0 &&
         (component.nodeName === "activity" ||
           component.nodeName === "activity-alias" ||
           component.nodeName === "service" ||
@@ -582,10 +491,10 @@ export const parseAndroidManifestComponents = (
             ? null
             : component.nodeName === "provider"
               ? providerDefault
-              : filters.length > 0,
+              : details.filterCount > 0,
         permission,
         permission_source: effectivePermissionSource,
-        intent_filter_count: filters.length,
+        intent_filter_count: details.filterCount,
         actions: collected.actions,
         categories: collected.categories,
         foreground_service_type:
@@ -594,6 +503,7 @@ export const parseAndroidManifestComponents = (
             : null,
         authorities,
         target_activity: targetActivity,
+        ...details.facts,
       });
     }
   }
@@ -646,6 +556,14 @@ export const parseAndroidManifestComponents = (
     limitations.push(
       `${String(nonliteralExported)} component declaration(s) set android:exported to a value other than the literals true or false (for example a resource reference), so their effective export state is unknown and the Android default does not apply.`,
     );
+  if (nonliteralEnabled > 0)
+    limitations.push(
+      `${String(nonliteralEnabled)} component declaration(s) set android:enabled to a nonliteral boolean; the declared enabled state is unknown.`,
+    );
+  if (nonliteralGrantUriPermissions > 0)
+    limitations.push(
+      `${String(nonliteralGrantUriPermissions)} provider(s) set android:grantUriPermissions to a nonliteral boolean; the declared URI-grant state is unknown.`,
+    );
   if (unresolvedPackages > 0) {
     limitations.push(
       `${String(unresolvedPackages)} package-relative component name(s) remain unresolved because the manifest package is absent or contains a resource/placeholder token, or its syntax is unsupported; the declared name is retained.`,
@@ -668,6 +586,18 @@ export const parseAndroidManifestComponents = (
     limitations.push(
       `Projected component permission values are capped at ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total bytes; omitted copies have permission_source "unknown".`,
     );
+  if (detailBudgetLimited)
+    limitations.push(
+      `Nested component declarations reached the ${String(ANDROID_MANIFEST_LIMITS.intentValues)}-record per-component or ${String(ANDROID_MANIFEST_LIMITS.detailBytes)}-byte shared copy bound; affected declaration coverage is partial. Read the retained manifest at each source location for omitted facts.`,
+    );
+  if (unsupportedFilterMembers > 0)
+    limitations.push(
+      `${String(unsupportedFilterMembers)} intent-filter child declaration(s) use unsupported constructs; affected filter coverage is partial and the declarations remain in the retained manifest.`,
+    );
+  if (processBudgetLimited)
+    limitations.push(
+      `Projected component process declarations are capped at ${String(ANDROID_MANIFEST_LIMITS.processBytes)} total bytes; omitted values are null.`,
+    );
   if (unresolvedPermissionTokens > 0) {
     limitations.push(
       `${String(unresolvedPermissionTokens)} permission value(s) contain unresolved resource or manifest-placeholder tokens; their effective values are unknown.`,
@@ -691,7 +621,7 @@ export const parseAndroidManifestComponents = (
   }
   if (capacityTruncated)
     limitations.push(
-      `The component inventory reached a capacity limit: ${String(ANDROID_MANIFEST_LIMITS.components)} declarations, ${String(ANDROID_MANIFEST_LIMITS.intentFilters)} filters or ${String(ANDROID_MANIFEST_LIMITS.intentValues)} values per component, ${String(ANDROID_MANIFEST_LIMITS.authorities)} authorities per provider, ${String(ANDROID_MANIFEST_LIMITS.authorityBytes)} total authority bytes, or ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total permission bytes.`,
+      `The component inventory reached a capacity limit: ${String(ANDROID_MANIFEST_LIMITS.components)} declarations, ${String(ANDROID_MANIFEST_LIMITS.intentFilters)} filters or ${String(ANDROID_MANIFEST_LIMITS.intentValues)} values per component, ${String(ANDROID_MANIFEST_LIMITS.authorities)} authorities per provider, ${String(ANDROID_MANIFEST_LIMITS.authorityBytes)} total authority bytes, or ${String(ANDROID_MANIFEST_LIMITS.permissionBytes)} total permission bytes, or ${String(ANDROID_MANIFEST_LIMITS.processBytes)} total process bytes.`,
     );
   return {
     status: truncated ? "partial" : "complete",
diff --git a/src/android/AndroidManifestXml.ts b/src/android/AndroidManifestXml.ts
new file mode 100644
index 0000000000..7255c720cb
--- /dev/null
+++ b/src/android/AndroidManifestXml.ts
@@ -0,0 +1,50 @@
+import type { Attr, Element, Node } from "@xmldom/xmldom";
+import type { AndroidManifestLocation } from "../domain/android/androidManifestComponents.js";
+import { XML_LINE_ENDING } from "../domain/xmlDocument.js";
+
+const ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
+export const childNodes = (element: Element): readonly Node[] =>
+  Array.from(element.childNodes);
+export const isManifestElement = (node: Node | null): node is Element =>
+  node !== null &&
+  node.nodeType === 1 &&
+  (node as Element).namespaceURI === null;
+export const plainAttribute = (
+  element: Element,
+  name: string,
+): string | null => {
+  const value = element.getAttribute(name);
+  return value === "" ? null : value;
+};
+/** Namespace identity, never the spelling of a bound prefix, selects a fact. */
+export const isAndroidAttribute = (attribute: Attr): boolean =>
+  attribute.namespaceURI === ANDROID_NAMESPACE ||
+  (attribute.namespaceURI === null && attribute.prefix === "android");
+
+export const androidAttribute = (
+  element: Element,
+  localName: string,
+): string | null => {
+  for (let index = 0; index < element.attributes.length; index += 1) {
+    const attribute = element.attributes.item(index);
+    if (attribute === null || attribute.localName !== localName) continue;
+    if (isAndroidAttribute(attribute)) return attribute.nodeValue;
+  }
+  return null;
+};
+/** Build the source line index once; all locations address the same retained text. */
+export const createManifestLocationReader = (source: string) => {
+  const starts = [0];
+  for (const match of source.matchAll(XML_LINE_ENDING))
+    starts.push(match.index + match[0].length);
+  return (element: Element): AndroidManifestLocation | null => {
+    const line = element.lineNumber;
+    const column = element.columnNumber;
+    if (line === undefined || column === undefined || line < 1 || column < 1)
+      return null;
+    const start = starts[line - 1];
+    if (start === undefined) return null;
+    const offset = start + column - 1;
+    return source[offset] === "<" ? { line, column, offset } : null;
+  };
+};
diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts
index 97c3db74bc..e1538cc6c5 100644
--- a/src/android/JadxAnalysis.ts
+++ b/src/android/JadxAnalysis.ts
@@ -1,6 +1,7 @@
 import type { z } from "zod";
 import type { AndroidRequest } from "../domain/android/androidAnalysis.js";
 import { androidResultSchemas } from "../domain/android/androidAnalysis.js";
+import { observedAndroidManifestComponentsSchema } from "../domain/android/androidManifestComponents.js";
 import { parseAndroidManifestComponents } from "./AndroidManifestComponents.js";
 import {
   AnalysisInputError,
@@ -39,6 +40,11 @@ type Engine = z.infer<
 >["engine"];
 type ClassSummary = z.infer;
 
+const observedPackageSchema =
+  androidResultSchemas.inspect_android_package.extend({
+    components: observedAndroidManifestComponentsSchema,
+  });
+
 const methodsOf = (summary: ClassSummary) => {
   const seen = new Map();
   return summary.methods.map((method) => {
@@ -127,7 +133,7 @@ const inspectPackage = async (
   const manifest = normalizeJadxText(
     await tools.text("get_android_manifest", {}),
   );
-  return androidResultSchemas.inspect_android_package.parse({
+  return observedPackageSchema.parse({
     engine,
     package_name: app.package || null,
     version_name: app.version_name || null,
diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts
index b216afa1f8..5a06c5c79b 100644
--- a/src/application/AnalysisProvider.ts
+++ b/src/application/AnalysisProvider.ts
@@ -1,5 +1,5 @@
 import type { AnalysisObservation } from "../domain/analysisObservation.js";
-import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
+import type { ProviderInspectionScope } from "./ProviderInspectionScope.js";
 import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
 import type { OfficialToolName } from "../contracts/officialToolContracts.js";
 import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts
index 3303b44478..955ed7a239 100644
--- a/src/application/AppBundleExecutable.ts
+++ b/src/application/AppBundleExecutable.ts
@@ -119,6 +119,9 @@ export const findAppBundleResourceRoot = async (
     options.cwd === undefined ? {} : { cwd: options.cwd },
   );
   try {
+    // Admit the selected spelling before Darwin realpath can collapse file/..
+    // into a different, otherwise valid bundle resource root.
+    await stat(callerPath);
     const canonicalInput = await realpath(callerPath);
     const directLayout = await presentLayout(canonicalInput);
     const wrapped =
diff --git a/src/application/AppBundleResourceRoot.test.ts b/src/application/AppBundleResourceRoot.test.ts
index c8f76a12bb..fd1a716a08 100644
--- a/src/application/AppBundleResourceRoot.test.ts
+++ b/src/application/AppBundleResourceRoot.test.ts
@@ -2,6 +2,7 @@ import {
   chmod,
   mkdir,
   mkdtemp,
+  realpath,
   rm,
   symlink,
   writeFile,
@@ -57,6 +58,32 @@ it("resolves resource roots without requiring or hashing CFBundleExecutable", as
   }
 });
 
+it.skipIf(process.platform === "win32")(
+  "preserves valid symlink-parent traversal when selecting resource roots",
+  async () => {
+    const temp = await mkdtemp(join(tmpdir(), "rea-resource-parent-"));
+    try {
+      await mkdir(join(temp, "physical", "child"), { recursive: true });
+      await mkdir(join(temp, "physical", "Fixture.app"));
+      await writeFile(
+        join(temp, "physical", "Fixture.app", "Info.plist"),
+        "",
+      );
+      await symlink(join(temp, "physical", "child"), join(temp, "alias"));
+      const selected = `${temp}/alias/../Fixture.app`;
+      const resolved = await resolveAppBundleResourceRoot(selected);
+      expect(resolved.ok).toBe(true);
+      if (!resolved.ok) throw resolved.error;
+      expect(resolved.value.callerPath).toBe(selected);
+      expect(resolved.value.canonicalPath).toBe(
+        await realpath(join(temp, "physical", "Fixture.app")),
+      );
+    } finally {
+      await rm(temp, { recursive: true, force: true });
+    }
+  },
+);
+
 it("resolves a flat bundle resource root without inferring executable identity", async () => {
   const temp = await mkdtemp(join(tmpdir(), "rea-ios-resource-root-"));
   try {
diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts
index 449d0a20ca..3c8f46f2e3 100644
--- a/src/application/BrowserEvidence.ts
+++ b/src/application/BrowserEvidence.ts
@@ -31,6 +31,7 @@ import type {
   BrowserCaptureComparisonInput,
 } from "../domain/browserCaptureComparison.js";
 import { commitBrowserScenarioNormalization } from "../domain/browserScenarioNormalization.js";
+import { digestCanonicalValue } from "../domain/canonicalDigest.js";
 import type {
   CaptureWebScreenshotInput,
   CompareWebScreenshotsInput,
@@ -81,13 +82,26 @@ export const createBrowserEvidence = (
 ): Evidence => {
   const acquisition = isBundleAcquisition(result);
   const observed = acquisition ? result.inspection : result;
+  const comparison =
+    operation === "compare_web_captures" ||
+    operation === "compare_web_screenshots";
   return createEvidence(undefined, provider, {
     predicateType: browserPredicate(operation),
     operation,
-    parameters: browserParameters(input, result),
+    parameters: {
+      ...browserParameters(input, result),
+      ...(comparison
+        ? {
+            comparison_input_sha256: digestCanonicalValue(
+              input,
+              "Browser comparison input",
+            ),
+          }
+        : {}),
+    },
     result,
-    confidence: "observed",
-    authority: "external-service",
+    confidence: comparison ? "derived" : "observed",
+    authority: comparison ? "analyst-inference" : "external-service",
     environment:
       "browser" in observed
         ? {
diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts
index d38d1e1d1e..1cd7eb2609 100644
--- a/src/application/BrowserObservationService.test.ts
+++ b/src/application/BrowserObservationService.test.ts
@@ -11,6 +11,7 @@ import {
   AnalysisCancelledError,
   AnalysisOutputError,
 } from "../domain/analysisErrorCore.js";
+import { resolvedAnalysisError } from "../domain/analysisErrorBase.js";
 import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
 import {
   expectDroppedPartial,
@@ -55,6 +56,21 @@ const provider = (
     listTargets,
   }) as unknown as BrowserObservationPort;
 
+it("keeps live browser inventory Evidence observed with external-service authority", async () => {
+  const result = await listBrowserTargets(
+    provider(async () => ok(report)),
+    input,
+  );
+  expect(result).toMatchObject({
+    ok: true,
+    value: { confidence: "observed", authority: "external-service" },
+  });
+  if (result.ok)
+    expect(result.value.parameters).not.toHaveProperty(
+      "comparison_input_sha256",
+    );
+});
+
 it("retains completed browser inventory Evidence when the provider completes after abort", async () => {
   const controller = new AbortController();
   const result = await listBrowserTargets(
@@ -135,6 +151,49 @@ it("converts a raw page inspection retained after browser cleanup failure", asyn
   }
 });
 
+it.each([true, false])(
+  "promotes cleanup primary facts once when wrapper retains them: %s",
+  async (wrapperRetainsFacts) => {
+    const inspection = passiveNetworkCapture();
+    const primary = new AnalysisCancelledError("inspect_web_page", {
+      partialObservation: inspection,
+    });
+    const failure = new BrowserObservationError(
+      "inspect_web_page",
+      "cleanup_failed",
+      {
+        cleanupPrimary: primary,
+        cleanup: {
+          reason: "target detach failed",
+          resources: ["browser target"],
+        },
+        ...(wrapperRetainsFacts ? { partialObservation: inspection } : {}),
+      },
+    );
+    const browser = {
+      ...provider(async () => ok(report)),
+      inspectPage: async () => err(failure),
+    } as unknown as BrowserObservationPort;
+    const result = await inspectWebPage(browser, pageInput());
+    expect(result.ok).toBe(false);
+    expect(parseEvidence(primary.partialObservation)).toMatchObject({
+      operation: "inspect_web_page",
+      normalized_result: inspection,
+    });
+    if (wrapperRetainsFacts)
+      expect(failure.partialObservation).toBe(primary.partialObservation);
+    failure.resolveCleanup();
+    expect(resolvedAnalysisError(failure)).toBe(primary);
+    expect(
+      parseEvidence(
+        projectAnalysisError(resolvedAnalysisError(failure)).details?.[
+          "partial_observation"
+        ],
+      ),
+    ).toEqual(primary.partialObservation);
+  },
+);
+
 it("retains captured page Evidence when bundle analysis fails after capture", async () => {
   const inspection = passiveNetworkCapture();
   const failure = new BrowserObservationError(
diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts
index 5508a9186e..2d61e6e649 100644
--- a/src/application/BrowserObservationService.ts
+++ b/src/application/BrowserObservationService.ts
@@ -288,44 +288,71 @@ const retainBrowserObservation = (
   provider: BrowserObservationPort,
   errorOperation: "inspect_web_page" | "analyze_web_bundle",
 ): Result => {
-  const partial = error.partialObservation;
+  const seen = new Set();
+  const promoted = new Map();
+  let current: AnalysisError | undefined = error;
+  while (current !== undefined && !seen.has(current)) {
+    seen.add(current);
+    const partial = current.partialObservation;
+    if (partial !== undefined) {
+      const evidence =
+        promoted.get(partial) ??
+        promoteBrowserObservation(partial, input, provider, errorOperation);
+      if (evidence !== undefined) {
+        promoted.set(partial, evidence);
+        current.partialObservation = evidence;
+      }
+    }
+    current = current.cleanupPrimary;
+  }
+  return err(
+    keepIncompleteObservation(
+      error,
+      errorOperation,
+      NON_JSON_BROWSER_OBSERVATION,
+    ),
+  );
+};
+
+/** Each cleanup owner can become the selected error after cleanup succeeds. */
+const promoteBrowserObservation = (
+  partial: NonNullable,
+  input: InspectWebPageInput | AnalyzeWebBundleInput,
+  provider: BrowserObservationPort,
+  errorOperation: "inspect_web_page" | "analyze_web_bundle",
+): Evidence | undefined => {
   if (
     errorOperation === "analyze_web_bundle" &&
-    partial !== undefined &&
     "kind" in partial &&
     partial.kind === "web-bundle-acquisition"
   ) {
     const acquisition =
       webBundleAcquisitionPartialObservationSchema.safeParse(partial);
     if (acquisition.success) {
-      error.partialObservation = createBrowserEvidence(
+      return createBrowserEvidence(
         "analyze_web_bundle",
         input,
         acquisition.data,
         provider.identity(),
       );
-      return err(error);
     }
   }
   if (
     errorOperation === "analyze_web_bundle" &&
-    partial !== undefined &&
     !("evidence_id" in partial) &&
     "observations" in partial &&
     "capture" in partial
   ) {
     const bundle = webBundleAnalysisSchema.safeParse(partial);
     if (bundle.success) {
-      error.partialObservation = createBrowserEvidence(
+      return createBrowserEvidence(
         "analyze_web_bundle",
         input,
         bundle.data,
         provider.identity(),
       );
-      return err(error);
     }
   } else if (
-    partial !== undefined &&
     !("evidence_id" in partial) &&
     "target" in partial &&
     "network" in partial &&
@@ -337,20 +364,13 @@ const retainBrowserObservation = (
         "fetch_source_maps" in input
           ? (({ fetch_source_maps: _fetchSourceMaps, ...page }) => page)(input)
           : input;
-      error.partialObservation = createBrowserEvidence(
+      return createBrowserEvidence(
         "inspect_web_page",
         pageInput,
         inspection.data,
         provider.identity(),
       );
-      return err(error);
     }
   }
-  return err(
-    keepIncompleteObservation(
-      error,
-      errorOperation,
-      NON_JSON_BROWSER_OBSERVATION,
-    ),
-  );
+  return undefined;
 };
diff --git a/src/application/BrowserScenarioEvidence.test.ts b/src/application/BrowserScenarioEvidence.test.ts
new file mode 100644
index 0000000000..ad564ad65e
--- /dev/null
+++ b/src/application/BrowserScenarioEvidence.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+
+import { scenarioCaptureFixture } from "../../tests/fixtures/browserScenarioCapture.js";
+import { browserScenarioSchema } from "../domain/browserScenario.js";
+import { digestCanonicalValue } from "../domain/canonicalDigest.js";
+import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
+
+const ORIGIN = "http://127.0.0.1:3101";
+
+const parameters = (actions: readonly unknown[]) =>
+  createBrowserScenarioEvidence(
+    browserScenarioSchema.parse({
+      browser: { mode: "launch", executable_path: "/opt/chromium/chrome" },
+      start_url: { url: `${ORIGIN}/` },
+      actions,
+    }),
+    scenarioCaptureFixture(),
+    { id: "fixture", name: "fixture", version: "1" },
+  ).parameters;
+
+const open = (fields: Record = {}) => ({
+  step_id: "open",
+  action: "goto",
+  destination: { url: `${ORIGIN}/items` },
+  wait_until: "load",
+  ...fields,
+});
+
+const create = (fields: Record = {}) => ({
+  step_id: "create",
+  action: "request",
+  method: "POST",
+  path: "/api/items",
+  json: { title: "Item 1" },
+  ...fields,
+});
+
+describe("browser scenario evidence parameters", () => {
+  it("records a scenario that saves nothing as it was recorded before saved values existed", () => {
+    const recorded = parameters([open(), create()]);
+    const actions = recorded["actions"] as Record[];
+    expect(Object.keys(actions[1]!)).not.toContain("save");
+    expect(parameters([open(), create({ save: [] })])).toEqual(recorded);
+    const { scenario_sha256, ...scenario } = recorded;
+    expect(scenario_sha256).toBe(
+      digestCanonicalValue(scenario, "Browser scenario"),
+    );
+  });
+
+  it("records the values a step saves and the placeholders that send them", () => {
+    const recorded = parameters([
+      create({ save: [{ name: "item", json_pointer: "/id" }] }),
+      open({ destination: { url: `${ORIGIN}/items/{saved:item}` } }),
+    ]);
+    expect(recorded["actions"]).toMatchObject([
+      { save: [{ name: "item", json_pointer: "/id", secret: false }] },
+      { destination: { url: `${ORIGIN}/items/%7Bsaved:item%7D` } },
+    ]);
+    expect(recorded["scenario_sha256"]).not.toBe(
+      parameters([create(), open()])["scenario_sha256"],
+    );
+  });
+});
diff --git a/src/application/BrowserScenarioEvidence.ts b/src/application/BrowserScenarioEvidence.ts
index 7fd9c2bd2e..1ef0b7a399 100644
--- a/src/application/BrowserScenarioEvidence.ts
+++ b/src/application/BrowserScenarioEvidence.ts
@@ -9,12 +9,27 @@ import {
 } from "../domain/evidence.js";
 import { jsonObjectSchema } from "../domain/jsonValue.js";
 
+/** A step as Evidence records it: a request step leaves out an empty `save`, so a scenario that saves nothing records and hashes as it did before saved values existed. */
+const recordedAction = (
+  action: BrowserScenario["actions"][number],
+): Record => {
+  if (action.action !== "request" || action.save.length > 0) return action;
+  const { save, ...rest } = action;
+  return rest;
+};
+
 const browserScenarioParameters = (
   scenario: BrowserScenario,
-): EvidenceObservation["parameters"] => ({
-  ...jsonObjectSchema.parse(scenario),
-  scenario_sha256: digestCanonicalValue(scenario, "Browser scenario"),
-});
+): EvidenceObservation["parameters"] => {
+  const recorded = {
+    ...scenario,
+    actions: scenario.actions.map(recordedAction),
+  };
+  return {
+    ...jsonObjectSchema.parse(recorded),
+    scenario_sha256: digestCanonicalValue(recorded, "Browser scenario"),
+  };
+};
 
 /** Create Evidence without retaining resolved scenario secret values. */
 export const createBrowserScenarioEvidence = (
diff --git a/src/application/ClientRegistration.ts b/src/application/ClientRegistration.ts
index 02d78f6f1f..dd1a74dc1f 100644
--- a/src/application/ClientRegistration.ts
+++ b/src/application/ClientRegistration.ts
@@ -1,4 +1,5 @@
 import { z } from "zod";
+import { PRODUCT_IDENTITY } from "../identity.js";
 import { jsonRecordSchema } from "../domain/jsonRecord.js";
 
 import { ConfigurationError } from "../domain/configurationErrors.js";
@@ -15,7 +16,12 @@ import {
   type ClientRegistrationDialect,
 } from "./ClientConfigurationDocument.js";
 import type { SetupClient } from "./SupportedClients.js";
-import { equivalentClientRegistrationCommands } from "./ClientRegistrationIdentity.js";
+import {
+  classifyClientRegistrationCommand,
+  equivalentClientRegistrationCommands,
+  type ClientRegistrationCommandIdentity,
+} from "./ClientRegistrationIdentity.js";
+import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
 
 const tomlTimeoutSchema = z.union([
   z.number().positive(),
@@ -107,6 +113,27 @@ export const parseClientRegistrationLauncher = (
     : err(registrationError(parsed.error));
 };
 
+/** Launcher ownership is independent of readiness and caller-owned policy. */
+export const clientRegistrationOwnership = (
+  value: unknown,
+  client: SetupClient,
+): ClientRegistrationCommandIdentity => {
+  const parsed = parseClientRegistrationLauncher(value, client);
+  return parsed.ok
+    ? classifyClientRegistrationCommand(
+        [parsed.value.command, ...parsed.value.args],
+        CURRENT_ENTRYPOINT_PATH,
+      )
+    : "unknown";
+};
+
+/** Explain the same ownership admission decision in setup and doctor. */
+export const clientRegistrationOwnershipRemediation = (
+  ownership: Exclude,
+  paths: readonly string[],
+): string =>
+  `The existing "${PRODUCT_IDENTITY.mcpServerKey}" registration read from ${paths.join(", ")} has ${ownership} ownership and was not changed. Review its launcher and manually rename or remove that registration before rerunning setup. A different installation path does not prove REA ownership.`;
+
 /** Policy belongs to the client dialect, and is validated before refresh writes. */
 const validateClientPolicy = (
   value: unknown,
diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts
index 9bed5d4e3f..c30b5755e1 100644
--- a/src/application/ClientRegistrationStatus.ts
+++ b/src/application/ClientRegistrationStatus.ts
@@ -41,6 +41,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
 
 import {
   parseClientRegistration,
+  clientRegistrationOwnershipRemediation,
   setupPreservesDisabledActivation,
   type ClientRegistration,
 } from "./ClientRegistration.js";
@@ -332,7 +333,12 @@ export const readClientRegistrationInspections = async (
                 policyBlock === undefined
               ? "aligned"
               : "stale",
-          policyBlock ??
+          (parsed.dialect !== "dsh" && commandFacts.identity !== "owned"
+            ? clientRegistrationOwnershipRemediation(commandFacts.identity, [
+                configPath,
+              ])
+            : undefined) ??
+            policyBlock ??
             (packageVersionDrift
               ? packageVersionRemediation(packageReference ?? "", client)
               : alignment === "unknown"
diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts
index 242f133847..c25d83f95d 100644
--- a/src/application/DirectAnalysis.ts
+++ b/src/application/DirectAnalysis.ts
@@ -8,6 +8,10 @@ import type { JsonValue } from "../domain/jsonValue.js";
 import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
 import type { BinarySession } from "./binary/BinarySession.js";
 import type { SessionProviderRoute } from "./binary/SessionProviderRouter.js";
+import {
+  routeAllowsSnapshotReplay,
+  routeProviderIdentity,
+} from "./binary/SessionProviderPolicy.js";
 import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js";
 import { silentLogger } from "../logger.js";
 import type { Logger } from "pino";
@@ -128,7 +132,7 @@ export const runDirectAnalysis = async (
     readonly architecture?: BinaryArchitecture;
     readonly entryPoint?: string;
   } = {},
-): Promise =>
+): Promise> =>
   withProcessCancellation(options.signal, (signal) =>
     runAnalysis(dependencies, path, tool, arguments_, {
       logger: options.logger ?? silentLogger,
@@ -171,31 +175,35 @@ export const runProviderAnalysis = async (
           readonly evidenceBundlePath?: string;
         },
       ]
-): Promise => {
+): Promise> => {
   if (delivery !== undefined && tool !== "inspect_managed_members")
-    return cliError(
-      new AnalysisInputError(tool, undefined, [
-        {
-          path: ["delivery"],
-          reason: "invalid_value",
-          message:
-            "Evidence delivery options are only supported by inspect_managed_members",
-        },
-      ]),
+    return err(
+      cliError(
+        new AnalysisInputError(tool, undefined, [
+          {
+            path: ["delivery"],
+            reason: "invalid_value",
+            message:
+              "Evidence delivery options are only supported by inspect_managed_members",
+          },
+        ]),
+      ),
     );
   if (
     delivery?.detail === "summary" &&
     delivery.evidenceBundlePath === undefined
   )
-    return cliError(
-      new AnalysisInputError(tool, undefined, [
-        {
-          path: ["evidence_bundle"],
-          reason: "missing_argument",
-          message:
-            "summary detail requires --evidence-bundle so complete Evidence remains available to later CLI commands",
-        },
-      ]),
+    return err(
+      cliError(
+        new AnalysisInputError(tool, undefined, [
+          {
+            path: ["evidence_bundle"],
+            reason: "missing_argument",
+            message:
+              "summary detail requires --evidence-bundle so complete Evidence remains available to later CLI commands",
+          },
+        ]),
+      ),
     );
   return withProcessCancellation(signal, async (operationSignal) => {
     // The selected CLI path is a request fact. Keep it out of provider inputs
@@ -211,14 +219,16 @@ export const runProviderAnalysis = async (
         operationSignal,
       );
       await dispatchQueuedInterrupts();
-      if (!original.ok) return cliError(original.error);
+      if (!original.ok) return err(cliError(original.error));
       const evidence = withEvidenceCallerInputPath(original.value, path);
       if (operationSignal.aborted)
-        return cliError(
-          new AnalysisCancelledError(tool, {
-            cause: operationSignal.reason,
-            partialObservation: evidence,
-          }),
+        return err(
+          cliError(
+            new AnalysisCancelledError(tool, {
+              cause: operationSignal.reason,
+              partialObservation: evidence,
+            }),
+          ),
         );
       const result =
         delivery?.evidenceBundlePath === undefined
@@ -229,7 +239,7 @@ export const runProviderAnalysis = async (
               delivery.detail,
               operationSignal,
             );
-      return result.ok ? result.value : cliError(result.error);
+      return result.ok ? ok(result.value) : err(cliError(result.error));
     }
     return runAnalysis(dependencies, path, tool, arguments_, {
       logger,
@@ -317,7 +327,7 @@ const runAnalysis = async (
     readonly architecture?: BinaryArchitecture;
     readonly entryPoint?: string;
   },
-): Promise => {
+): Promise> => {
   const { logger, signal, snapshotPath } = options;
   const evidenceParameters = options.evidenceParameters ?? arguments_;
   let session: BinarySession;
@@ -325,7 +335,7 @@ const runAnalysis = async (
     session = await options.createSession();
   } else {
     const config = dependencies.readConfiguration();
-    if (!config.ok) return cliError(config.error);
+    if (!config.ok) return err(cliError(config.error));
     session = await (
       tool === "inspect_native_dispatch_metadata" && snapshotPath === undefined
         ? dependencies.createNativeDispatchBinarySession
@@ -406,7 +416,9 @@ const runAnalysis = async (
         !requiresNativeSourceAdmissionBeforeReplay(
           route.binding?.identity.id,
         ) &&
-        allowsSnapshotReplay(route, tool)
+        tool !== "trace_native_ui_action" &&
+        tool !== "decompile_all" &&
+        routeAllowsSnapshotReplay(route, tool)
       ) {
         const cached = snapshotEvidenceForQuery(snapshot, {
           target: preview.value.target,
@@ -415,7 +427,7 @@ const runAnalysis = async (
           parameters: arguments_,
           provider: isWorkflowEvidenceTool(tool)
             ? REA_WORKFLOW_PROVIDER
-            : providerIdentityForRoute(route, tool),
+            : routeProviderIdentity(route, tool),
           evidenceProfile,
         });
         if (cached !== undefined)
@@ -543,9 +555,9 @@ const runAnalysis = async (
       outcome.cleanupError.retainDiagnostics({
         analysis_snapshot: { ...publishedSnapshot },
       });
-    return directAnalysisCleanupFailure(result, outcome.cleanupError);
+    return err(directAnalysisCleanupFailure(result, outcome.cleanupError));
   }
-  return result.ok ? result.value : cliError(result.error);
+  return result.ok ? ok(result.value) : err(cliError(result.error));
 };
 
 const executeAnalysisTool = async (input: {
@@ -749,24 +761,6 @@ const inspectBundleResourceInput = async (
   }
 };
 
-const allowsSnapshotReplay = (
-  route: SessionProviderRoute,
-  tool:
-    | NativeToolName
-    | ArtifactAnalysisOperation
-    | ManagedToolName
-    | DirectAnalysisTool,
-): boolean => {
-  if (tool === "trace_native_ui_action" || tool === "decompile_all")
-    return false;
-  const descriptor = route.capabilities?.get(tool);
-  return descriptor === undefined
-    ? ![...(route.capabilities?.values() ?? [])].some(
-        ({ cachePolicy }) => cachePolicy === "live",
-      )
-    : descriptor.cachePolicy !== "live";
-};
-
 const analysisProfileForRoute = (
   route: SessionProviderRoute,
   tool:
@@ -788,7 +782,7 @@ const analysisProfileForRoute = (
   }
   if (isWorkflowEvidenceTool(tool))
     return workflowAnalysisProfile(profile, tool);
-  const provider = providerIdentityForRoute(route, tool);
+  const provider = routeProviderIdentity(route, tool);
   return provider.id === profile.provider.id ? profile : undefined;
 };
 
@@ -800,13 +794,6 @@ const nativeDispatchBindingProfile = (
   return profilelessNativeDispatchProfile(route);
 };
 
-/** Mirror BinarySession.providerIdentity for a route not opened yet. */
-const providerIdentityForRoute = (
-  route: SessionProviderRoute,
-  operation: string,
-): ProviderIdentity =>
-  route.capabilities?.get(operation)?.provider ?? route.identity;
-
 const analysisProfileForEvidence = (
   session: BinarySession,
   tool:
diff --git a/src/application/DirectAnalysisCleanup.ts b/src/application/DirectAnalysisCleanup.ts
index ccfbcc521a..36c6c39c72 100644
--- a/src/application/DirectAnalysisCleanup.ts
+++ b/src/application/DirectAnalysisCleanup.ts
@@ -38,6 +38,7 @@ export const directAnalysisCleanupFailure = (
   const cleanup = projectAnalysisError(error);
   if (!result.ok) {
     const primary = projectAnalysisError(result.error);
+    const combined = analysisErrorWithCleanupFailure(result.error, error);
     return {
       error: "Analysis failed",
       ...primary,
@@ -45,8 +46,10 @@ export const directAnalysisCleanupFailure = (
       retryable: false,
       remediation: cleanup.remediation,
       details: {
-        resources: error.cleanupResources.slice(),
         ...primary.details,
+        // The domain composition owns the union of still-retained resources;
+        // neither projection may overwrite the other owner's cleanup state.
+        resources: [...combined.cleanupResources],
         cleanup: "incomplete",
         execution_failure: primary.details?.execution_failure ?? primary.code,
         cleanup_error: cleanup,
diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts
index 1c3a3adf74..7d5854e3b5 100644
--- a/src/application/Doctor.test.ts
+++ b/src/application/Doctor.test.ts
@@ -495,22 +495,28 @@ describe("doctor runtime check", () => {
   );
 });
 
-it.each([undefined, "", "not-a-version", "14garbage"])(
-  "retains unknown host support for macOS probe value %s",
-  async (version) => {
-    const result = await runDoctor(
-      undefined,
-      host({ macosVersion: () => Promise.resolve(version) }),
-    );
-    expect(result.checks).toContainEqual(
-      expect.objectContaining({
-        name: "host",
-        ok: false,
-        classification: "unknown",
-      }),
-    );
-  },
-);
+it.each([
+  undefined,
+  "",
+  "not-a-version",
+  "14garbage",
+  "14.garbage",
+  "14.",
+  "14.0\n",
+  "99999999999999999",
+])("retains unknown host support for macOS probe value %s", async (version) => {
+  const result = await runDoctor(
+    undefined,
+    host({ macosVersion: () => Promise.resolve(version) }),
+  );
+  expect(result.checks).toContainEqual(
+    expect.objectContaining({
+      name: "host",
+      ok: false,
+      classification: "unknown",
+    }),
+  );
+});
 
 it.skipIf(process.platform !== "darwin")(
   "reads the macOS version without PATH utilities",
diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts
index b927c3f2a4..af3d81e80a 100644
--- a/src/application/DoctorDiagnostics.ts
+++ b/src/application/DoctorDiagnostics.ts
@@ -3,6 +3,7 @@ import {
   supportsJavaScriptRuntime,
 } from "../domain/runtimeVersion.js";
 import { hopperLauncherDiscovery } from "./HopperLauncherDiscovery.js";
+import { macosMajorVersion } from "../domain/macosVersion.js";
 import { LINUX_HOPPER_SETUP_HOSTS, supportsReaHost } from "./LinuxHopper.js";
 import type {
   DoctorCheck,
@@ -129,10 +130,7 @@ const hostCheck = async (host: DoctorHost): Promise => {
     host.platform === "darwin" ? await host.macosVersion() : undefined;
   const linuxDistribution =
     host.platform === "linux" ? await host.linuxDistribution() : undefined;
-  const macosMajor =
-    macosVersion !== undefined && /^\d+(?:\.\d+)*$/u.test(macosVersion)
-      ? Number(macosVersion.split(".")[0])
-      : undefined;
+  const macosMajor = macosMajorVersion(macosVersion);
   if (host.platform === "darwin" && macosMajor === undefined)
     return {
       name: "host",
diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts
index 35ecd27dc1..98f20980d0 100644
--- a/src/application/EvidenceBundleCommands.ts
+++ b/src/application/EvidenceBundleCommands.ts
@@ -31,26 +31,36 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
 import {
   readEvidenceBundle,
   readImmutableEvidenceBundle,
+  readComparisonEvidenceBundles,
   writeEvidenceBundle,
 } from "./EvidenceBundleFiles.js";
+import { inspectEvidenceBundle } from "./investigation/InspectEvidenceBundle.js";
+import type { EvidenceRecordFilters } from "../domain/evidenceRecordSummary.js";
 import { inspectAnalysisViewValidated } from "./analysisView/AnalysisViewService.js";
 
 /** Validate a standalone bundle and report its complete record/revision counts. */
 export const importEvidenceBundleCommand = async (
   path: string,
+  signal?: AbortSignal,
 ): Promise> => {
-  const loaded = await readEvidenceBundle(
-    path,
-    undefined,
-    undefined,
+  return withBundleReadResources(
+    async (resources) => {
+      const loaded = await readEvidenceBundle(
+        path,
+        signal,
+        resources,
+        "import_evidence_bundle",
+      );
+      if (!loaded.ok) return loaded;
+      return ok({
+        imported: loaded.value.records.length,
+        unknowns_added: loaded.value.unknowns.length,
+        total: loaded.value.records.length,
+      });
+    },
     "import_evidence_bundle",
+    signal,
   );
-  if (!loaded.ok) return loaded;
-  return ok({
-    imported: loaded.value.records.length,
-    unknowns_added: loaded.value.unknowns.length,
-    total: loaded.value.records.length,
-  });
 };
 
 /** Validate a source bundle and atomically export canonical bytes. */
@@ -58,19 +68,37 @@ export const exportEvidenceBundleCommand = async (
   sourcePath: string,
   outputPath: string,
   overwrite: boolean,
+  signal?: AbortSignal,
 ): Promise> => {
   // The sealed snapshot lets the writer reuse this authentication.
-  const loaded = await readImmutableEvidenceBundle(
-    sourcePath,
-    undefined,
-    undefined,
+  const loaded = await withBundleReadResources(
+    (resources) =>
+      readImmutableEvidenceBundle(
+        sourcePath,
+        signal,
+        resources,
+        "export_evidence_bundle",
+      ),
     "export_evidence_bundle",
+    signal,
   );
   if (!loaded.ok) return loaded;
-  return projectWrite(
+  const written = await persistEvidenceBundleCommand(
     loaded.value,
-    await persistEvidenceBundleCommand(loaded.value, outputPath, overwrite),
+    outputPath,
+    overwrite,
+    signal,
+    "export_evidence_bundle",
   );
+  await dispatchQueuedInterrupts();
+  if (written.ok && signal?.aborted)
+    return err(
+      new AnalysisCancelledError("export_evidence_bundle", {
+        cause: signal.reason,
+        diagnostics: { evidence_bundle: { ...written.value } },
+      }),
+    );
+  return projectWrite(loaded.value, written);
 };
 
 /** Publish complete parent Evidence before delivering it or its linked summary. */
@@ -223,35 +251,32 @@ export const compareEvidenceBundlesCommand = async (input: {
   readonly leftPath: string;
   readonly rightPath: string;
   readonly recordPairs?: readonly BundleRecordPair[];
+  readonly signal?: AbortSignal;
 }): Promise> => {
   // Sealed snapshots let comparison and its bundle digests reuse this
   // authentication instead of re-validating every record twice more.
-  const [left, right] = await Promise.all([
-    readImmutableEvidenceBundle(
-      input.leftPath,
-      undefined,
-      undefined,
-      "compare_bundles",
-    ),
-    readImmutableEvidenceBundle(
-      input.rightPath,
-      undefined,
-      undefined,
-      "compare_bundles",
-    ),
-  ]);
-  if (!left.ok) return left;
-  if (!right.ok) return right;
-  try {
-    return ok(
-      jsonValueSchema.parse(
-        compareBundles(left.value, right.value, input.recordPairs),
-      ),
-    );
-  } catch (cause: unknown) {
-    if (cause instanceof AnalysisInputError) return err(cause);
-    throw cause;
-  }
+  return withBundleReadResources(
+    async (resources) => {
+      const loaded = await readComparisonEvidenceBundles(
+        input.leftPath,
+        input.rightPath,
+        input.signal,
+        resources,
+      );
+      if (!loaded.ok) return loaded;
+      const { left, right } = loaded.value;
+      try {
+        return ok(
+          jsonValueSchema.parse(compareBundles(left, right, input.recordPairs)),
+        );
+      } catch (cause: unknown) {
+        if (cause instanceof AnalysisInputError) return err(cause);
+        throw cause;
+      }
+    },
+    "compare_bundles",
+    input.signal,
+  );
 };
 
 const projectWrite = (
@@ -265,3 +290,55 @@ const projectWrite = (
         records: bundle.records.length,
       })
     : err(written.error);
+
+/** Standalone bundle discovery shares the same read owner as import and comparison. */
+export const inspectEvidenceBundleCommand = async (
+  input: {
+    readonly path: string;
+    readonly detail: "complete" | "summary";
+    readonly filters?: EvidenceRecordFilters | undefined;
+  },
+  signal?: AbortSignal,
+) => {
+  return withBundleReadResources(
+    async (resources) => {
+      const loaded = await readEvidenceBundle(
+        input.path,
+        signal,
+        resources,
+        "get_evidence_bundle",
+      );
+      return loaded.ok ? inspectEvidenceBundle(loaded.value, input) : loaded;
+    },
+    "get_evidence_bundle",
+    signal,
+  );
+};
+
+/** Final read cleanup retries the exact leases already bound to a failed read. */
+const withBundleReadResources = async (
+  read: (
+    resources: ArtifactResourceScope,
+  ) => Promise>,
+  operation: string,
+  signal?: AbortSignal,
+): Promise> => {
+  const resources = new ArtifactResourceScope();
+  let result: Result;
+  try {
+    result = await read(resources);
+  } finally {
+    // Read errors own their retained leases and observe resolution or remaining
+    // failure in place. A retry must not replace the original read failure.
+    await resources.close().catch(() => undefined);
+    await dispatchQueuedInterrupts();
+  }
+  return result.ok && signal?.aborted
+    ? err(
+        new AnalysisCancelledError(operation, {
+          cause: signal.reason,
+          diagnostics: { completed_result: result.value },
+        }),
+      )
+    : result;
+};
diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts
index d20cfb9771..1bfda4d1d5 100644
--- a/src/application/EvidenceBundleFiles.ts
+++ b/src/application/EvidenceBundleFiles.ts
@@ -1,3 +1,5 @@
+import type { AnalysisError } from "../domain/analysisErrorBase.js";
+import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
 import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js";
 import { absoluteCallerPath } from "../filesystem/callerPath.js";
 
@@ -52,6 +54,38 @@ export const readImmutableEvidenceBundle = (
 ): Promise> =>
   readBundle(path, true, signal, resources, operation);
 
+/** Join both comparison reads and preserve cleanup owned by either failed input. */
+export const readComparisonEvidenceBundles = async (
+  leftPath: string,
+  rightPath: string,
+  signal?: AbortSignal,
+  resources?: ArtifactResourceScope,
+  operation = "compare_bundles",
+): Promise<
+  Result<
+    { readonly left: EvidenceBundle; readonly right: EvidenceBundle },
+    AnalysisError
+  >
+> => {
+  const [left, right] = await Promise.all([
+    readImmutableEvidenceBundle(leftPath, signal, resources, operation),
+    readImmutableEvidenceBundle(rightPath, signal, resources, operation),
+  ]);
+  if (!left.ok) {
+    if (!right.ok && right.error.cleanupIncomplete)
+      return err(
+        analysisErrorWithCleanupFailure(
+          left.error,
+          right.error,
+          operation,
+        ).bindCleanupDependencies([left.error, right.error]),
+      );
+    return left;
+  }
+  if (!right.ok) return right;
+  return ok({ left: left.value, right: right.value });
+};
+
 const readBundle = async (
   path: string,
   immutable: boolean,
diff --git a/src/application/FunctionAnalysisEvidence.test.ts b/src/application/FunctionAnalysisEvidence.test.ts
index 9d8cc3bbaf..9a8cee9ab4 100644
--- a/src/application/FunctionAnalysisEvidence.test.ts
+++ b/src/application/FunctionAnalysisEvidence.test.ts
@@ -7,6 +7,7 @@ import { createAnalysisProfile } from "../domain/analysisProfile.js";
 import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
 import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
 import { err, ok } from "../domain/result.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
 
 const provider = {
   id: "dossier-fixture",
@@ -157,6 +158,49 @@ it.each(["provider-failure", "invalid-dossier"] as const)(
         _tag: "AnalysisOutputError",
         operation: "analyze_function",
       });
-    expect(result.error.partialObservation).toBeUndefined();
+    if (kind === "provider-failure")
+      expect(result.error.partialObservation).toBeUndefined();
+    else {
+      const retained = parseEvidence(result.error.partialObservation);
+      expect(retained.normalized_result).toEqual({ procedure: "incomplete" });
+      expect(retained.subject?.local_path).toBe(target.path);
+    }
   },
 );
+
+it("retains malformed dossier facts and source links through public error projection", async () => {
+  const source = createEvidence(undefined, provider, {
+    operation: "health",
+    parameters: {},
+    result: "retained source",
+  });
+  const malformed = { procedure: "incomplete", partial: ["caller fact"] };
+  const result = await executeFunctionAnalysisEvidence(
+    {
+      execute: async () =>
+        ok(
+          createAnalysisExecution(malformed, provider, {
+            subject: null,
+            rawResult: { text: "original provider output" },
+            limitations: ["Incomplete provider response"],
+            locations: [{ kind: "address", address: "0x401000" }],
+            sourceEvidence: [source],
+          }),
+        ),
+    },
+    parameters,
+    target,
+  );
+  if (result.ok) throw new Error("Expected invalid dossier failure");
+  expect(result.sourceEvidence).toEqual([source]);
+  const projected = projectAnalysisError(result.error);
+  const retained = parseEvidence(projected.details?.partial_observation);
+  expect(retained.normalized_result).toEqual(malformed);
+  expect(retained.raw_result).toEqual({ text: "original provider output" });
+  expect(retained.subject).toBeNull();
+  expect(retained.evidence_links).toEqual([source.evidence_id]);
+  expect(retained.locations).toEqual([
+    { kind: "address", address: "0x401000" },
+  ]);
+  expect(retained.limitations).toContain("Incomplete provider response");
+});
diff --git a/src/application/FunctionAnalysisEvidence.ts b/src/application/FunctionAnalysisEvidence.ts
index 835f81ed80..2b0d97ea1b 100644
--- a/src/application/FunctionAnalysisEvidence.ts
+++ b/src/application/FunctionAnalysisEvidence.ts
@@ -49,9 +49,25 @@ export const executeFunctionAnalysisEvidence = async (
   );
   const sources = sourceLedger.recordsForAnalysis();
   if (!recorded.ok) return withEvidenceSources(recorded, sources);
+  const observation = {
+    ...execution.value,
+    sourceEvidence: sources,
+    ...(execution.value.subject === undefined && target !== undefined
+      ? { subject: target }
+      : {}),
+  };
   const dossier = parseFunctionDossier(execution.value.result);
-  if (!dossier.ok) return withEvidenceSources(dossier, sources);
-  const observation = execution.value;
+  if (!dossier.ok) {
+    dossier.error.retainPartialObservation(
+      createAnalysisExecutionEvidence(
+        "analyze_function",
+        arguments_,
+        observation,
+        "observed",
+      ),
+    );
+    return withEvidenceSources(dossier, sources);
+  }
   return withEvidenceSources(
     completeProviderEvidence(
       createAnalysisExecutionEvidence(
@@ -59,11 +75,7 @@ export const executeFunctionAnalysisEvidence = async (
         arguments_,
         {
           ...observation,
-          sourceEvidence: sources,
           result: dossier.value,
-          ...(observation.subject === undefined && target !== undefined
-            ? { subject: target }
-            : {}),
         },
         "observed",
       ),
diff --git a/src/application/HermesClientHome.ts b/src/application/HermesClientHome.ts
index 11e7f1b6d3..91ec5f0a2a 100644
--- a/src/application/HermesClientHome.ts
+++ b/src/application/HermesClientHome.ts
@@ -1,14 +1,10 @@
 import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
 import { userInfo, type UserInfo } from "node:os";
+import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
 import {
-  basename,
-  dirname,
-  isAbsolute,
-  join,
-  relative,
-  resolve,
-  sep,
-} from "node:path";
+  absoluteCallerPath,
+  appendCallerPath,
+} from "../filesystem/callerPath.js";
 
 /** An unresolved Hermes profile must not be configured as the default home. */
 export class HermesClientPathError extends Error {
@@ -32,7 +28,7 @@ const defaultDirectory = (
         env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
         `hermes${suffix}`,
       )
-    : join(home, `.hermes${suffix}`);
+    : appendCallerPath(home, `.hermes${suffix}`, platform);
 };
 const expandedDirectory = (
   home: string,
@@ -60,7 +56,9 @@ const expandedDirectory = (
     expanded.startsWith("~/") ||
     (platform === "win32" && expanded.startsWith("~\\"))
   )
-    return join(home, expanded.slice(2));
+    return absoluteCallerPath(
+      appendCallerPath(home, expanded.slice(2), platform),
+    );
   if (expanded.startsWith("~")) {
     // Named POSIX homes come from the account database, not HOME/USERPROFILE.
     // Only the current account has a portable Node API; unresolved names must
@@ -78,23 +76,52 @@ const expandedDirectory = (
         );
       }
       if (account.username === username && isAbsolute(account.homedir))
-        return join(
-          account.homedir,
-          separator < 0 ? "" : expanded.slice(separator + 1),
-        );
+        return separator < 0
+          ? account.homedir
+          : absoluteCallerPath(
+              appendCallerPath(account.homedir, expanded.slice(separator + 1)),
+            );
     }
     throw new HermesClientPathError(
       override,
       "Cannot resolve this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.",
     );
   }
-  return resolve(expanded);
+  // Hermes expands environment/user syntax and then opens the resulting Path;
+  // preserve POSIX symlink/.. traversal when making a relative override absolute.
+  return absoluteCallerPath(expanded);
 };
+// Resolve existing directory aliases even when setup will create the final home.
+// An unreadable path or dangling alias cannot prove root identity.
 const physicalPath = (path: string): string => {
+  let ancestor = absoluteCallerPath(path);
+  const missing: string[] = [];
   try {
-    return realpathSync(path);
+    while (true) {
+      try {
+        return join(realpathSync(ancestor), ...missing);
+      } catch (cause: unknown) {
+        if (
+          !(cause instanceof Error) ||
+          !("code" in cause) ||
+          cause.code !== "ENOENT" ||
+          dirname(ancestor) === ancestor ||
+          basename(ancestor) === ".."
+        )
+          throw cause;
+        // ENOENT may mean a dangling symlink rather than an absent entry. Do
+        // not reclassify such an alias as a literal child of its lexical parent.
+        if (lstatSync(ancestor, { throwIfNoEntry: false })?.isSymbolicLink())
+          throw cause;
+        missing.unshift(basename(ancestor));
+        ancestor = dirname(ancestor);
+      }
+    }
   } catch {
-    return resolve(path);
+    throw new HermesClientPathError(
+      path,
+      "Cannot resolve the Hermes home root. Restore access to this path before configuring Hermes.",
+    );
   }
 };
 const within = (root: string, path: string): boolean => {
@@ -119,14 +146,14 @@ const liveProfile = (path: string): boolean => {
   try {
     if (!statSync(path).isDirectory()) return false;
     try {
-      statSync(join(dirname(path), ".deleted", basename(path)));
+      statSync(appendCallerPath(dirname(path), `.deleted/${basename(path)}`));
       return false;
     } catch {
       /* No tombstone. */
     }
     return PROFILE_MARKERS.some((marker) => {
       try {
-        const status = lstatSync(join(path, marker));
+        const status = lstatSync(appendCallerPath(path, marker));
         return status.isFile() || status.isSymbolicLink();
       } catch {
         return false;
@@ -152,7 +179,7 @@ export const resolveHermesClientHome = (
   try {
     // Python uses utf-8-sig and ignores unreadable / invalid-UTF8 active_profile files.
     name = new TextDecoder("utf-8", { fatal: true })
-      .decode(readFileSync(join(stickyRoot, "active_profile")))
+      .decode(readFileSync(appendCallerPath(stickyRoot, "active_profile")))
       .trim();
   } catch {
     return root;
@@ -165,12 +192,12 @@ export const resolveHermesClientHome = (
     RESERVED_PROFILES.has(canonical)
   )
     throw new HermesClientPathError(
-      join(stickyRoot, "active_profile"),
+      appendCallerPath(stickyRoot, "active_profile"),
       `Invalid saved Hermes profile ${JSON.stringify(name)}. Run 'hermes profile use default' or repair active_profile.`,
     );
   // Hermes resolves both sticky selection and named profiles from its default
   // root (including overrides beneath the native home), never mixed roots.
-  const selected = join(stickyRoot, "profiles", canonical);
+  const selected = appendCallerPath(stickyRoot, `profiles/${canonical}`);
   if (!liveProfile(selected))
     throw new HermesClientPathError(
       selected,
diff --git a/src/application/HopperReleaseFeed.test.ts b/src/application/HopperReleaseFeed.test.ts
index 663e6304c1..c5d878899a 100644
--- a/src/application/HopperReleaseFeed.test.ts
+++ b/src/application/HopperReleaseFeed.test.ts
@@ -121,3 +121,35 @@ it("distinguishes malformed UTF-8 from a genuine replacement character and toler
       parseHopperReleaseJson(new TextEncoder().encode(bom + '{"x":"雪�"}')),
     ).toMatchObject({ ok: true, value: { x: "雪�" } });
 });
+
+describe("macOS Hopper public download channel", () => {
+  const macRelease = {
+    filename: "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
+    file_length: "123",
+    file_hash: "a".repeat(40),
+  };
+
+  it.each([
+    "https://www.hopperapp.com/downloader/public/Hopper-demo.dmg",
+    "https://WWW.HOPPERAPP.COM:443/downloader/public/Hopper-demo.dmg",
+    "https://www.hopperapp.com:443/downloader/public/Hopper%20demo.dmg",
+  ])("preserves the admitted source spelling %s", (filename) => {
+    expect(
+      parseMacHopperReleases({ "OS X": { ...macRelease, filename } }),
+    ).toEqual({ ok: true, value: { ...macRelease, filename } });
+  });
+
+  it.each([
+    "https://www.hopperapp.com:443/downloader/public/../private/Hopper-demo.dmg",
+    "https://www.hopperapp.com:443/downloader/public/%2e%2e/private/Hopper-demo.dmg",
+    "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg?",
+    "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg#",
+    "https://user:password@www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
+    "https://other.example/downloader/public/Hopper-demo.dmg",
+    "http://www.hopperapp.com/downloader/public/Hopper-demo.dmg",
+  ])("rejects a URL outside the public channel: %s", (filename) => {
+    expect(
+      parseMacHopperReleases({ "OS X": { ...macRelease, filename } }),
+    ).toMatchObject({ ok: false, error: { reason: "release_metadata" } });
+  });
+});
diff --git a/src/application/HopperReleaseFeed.ts b/src/application/HopperReleaseFeed.ts
index b11e400c3e..dae500bb04 100644
--- a/src/application/HopperReleaseFeed.ts
+++ b/src/application/HopperReleaseFeed.ts
@@ -52,6 +52,14 @@ const selectedPlatformsSchema = z.object({
   Arch: z.unknown().optional(),
 });
 
+/** Interpret channel identity once using the same URL semantics as the downloader. */
+const isPublicHopperDownload = (url: URL): boolean =>
+  url.origin === "https://www.hopperapp.com" &&
+  url.username === "" &&
+  url.password === "" &&
+  !urlDeclaresQueryOrFragment(url) &&
+  url.pathname.startsWith("/downloader/public/");
+
 /** Admit the selected macOS row without projecting away vendor release metadata. */
 export const parseMacHopperReleases = (
   raw: unknown,
@@ -68,7 +76,7 @@ export const parseMacHopperReleases = (
       ),
     );
   const retained = release.data as MacHopperRelease;
-  if (!retained.filename.startsWith(HOPPER_DOWNLOAD_PREFIX))
+  if (!isPublicHopperDownload(new URL(retained.filename)))
     return err(
       new HopperReleaseError(
         "release_metadata",
@@ -154,10 +162,7 @@ export const parseLinuxHopperRelease = (
     );
   const url = new URL(parsed.data.filename);
   if (
-    url.origin !== "https://www.hopperapp.com" ||
-    url.username !== "" ||
-    url.password !== "" ||
-    urlDeclaresQueryOrFragment(url) ||
+    !isPublicHopperDownload(url) ||
     !url.pathname.startsWith("/downloader/public/Hopper-") ||
     !url.pathname.endsWith(`-Linux-demo${suffixes[family]}`)
   )
diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts
index 7df46cff03..420f104088 100644
--- a/src/application/LinuxHopper.ts
+++ b/src/application/LinuxHopper.ts
@@ -1,3 +1,4 @@
+import { appendCallerPath } from "../filesystem/callerPath.js";
 import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
 import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
 import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -525,7 +526,7 @@ export const installLinuxHopper = async (
 
 /** Canonical launcher path for a legacy user-local Linux Hopper installation. */
 export const linuxHopperLauncherPath = (home: string): string =>
-  posix.join(home, ".local/share/rea/hopper/bin/Hopper");
+  appendCallerPath(home, ".local/share/rea/hopper/bin/Hopper", "linux");
 
 /** REA's managed Linux install path, preferred when no explicit launcher is set. */
 export const REA_MANAGED_LINUX_HOPPER_LAUNCHER = "/opt/hopper/bin/Hopper";
diff --git a/src/application/LinuxHopperCacheDirectory.ts b/src/application/LinuxHopperCacheDirectory.ts
index 2e773f225c..e55f6bd550 100644
--- a/src/application/LinuxHopperCacheDirectory.ts
+++ b/src/application/LinuxHopperCacheDirectory.ts
@@ -1,4 +1,5 @@
-import { isAbsolute, join } from "node:path";
+import { isAbsolute } from "node:path";
+import { appendCallerPath } from "../filesystem/callerPath.js";
 import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
 import { err, ok, type Result } from "../domain/result.js";
 import { HopperReleaseError } from "./HopperReleaseFeed.js";
@@ -18,14 +19,13 @@ export const linuxHopperReleaseCacheDirectory = (
         ),
       );
     return ok(
-      join(
+      appendCallerPath(
         supplied ??
-          join(
+          appendCallerPath(
             selectedHome ?? homeDirectoryFromEnvironment(environment, "linux"),
             ".cache",
           ),
-        "rea",
-        "hopper-releases",
+        "rea/hopper-releases",
       ),
     );
   } catch (cause: unknown) {
diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts
index b16e03d83c..da31e86bc1 100644
--- a/src/application/LinuxHopperPublisher.ts
+++ b/src/application/LinuxHopperPublisher.ts
@@ -1,6 +1,7 @@
 import { createHash, randomUUID } from "node:crypto";
 import { copyFile, lstat, mkdir, rename, writeFile } from "node:fs/promises";
 import { join } from "node:path";
+import { appendCallerPath } from "../filesystem/callerPath.js";
 
 import { z } from "zod";
 import {
@@ -299,9 +300,13 @@ export class LinuxHopperPublisher {
           "verification_unavailable",
           "The Hopper release cache is not a caller-owned directory.",
         );
-      const target = join(this.cacheRoot, verified.release.main_exe_hash);
+      const target = appendCallerPath(
+        this.cacheRoot,
+        verified.release.main_exe_hash,
+      );
       const lease = await this.#acquireCacheLease(
         verified.release.main_exe_hash,
+        resources,
         signal,
       );
       await this.#withOwnedResource(
@@ -408,6 +413,7 @@ export class LinuxHopperPublisher {
 
   async #acquireCacheLease(
     digest: string,
+    resources: ArtifactResourceScope,
     signal?: AbortSignal,
   ): Promise {
     const deadline = performance.now() + 120_000;
@@ -419,6 +425,7 @@ export class LinuxHopperPublisher {
         cacheDirectory: this.cacheRoot,
         launcherSha256: digest,
         runId,
+        resources,
       });
       if (acquired.acquired) return acquired.lease;
       const remaining = deadline - performance.now();
@@ -699,7 +706,7 @@ export class LinuxHopperPublisher {
     resources: ArtifactResourceScope,
   ): Promise> {
     try {
-      const root = join(this.cacheRoot, digest);
+      const root = appendCallerPath(this.cacheRoot, digest);
       try {
         const directory = await lstat(root);
         if (!directory.isDirectory() || directory.isSymbolicLink())
@@ -722,9 +729,9 @@ export class LinuxHopperPublisher {
       // Distinguish proven malformed/missing evidence from unavailable reads or
       // inspection tools. Only the former authorize approved cache replacement.
       for (const name of ["release.json", "public-key.asc", "archive"])
-        await this.#assertCacheFile(join(root, name));
+        await this.#assertCacheFile(appendCallerPath(root, name));
       const metadata = await this.#read(
-        join(root, "release.json"),
+        appendCallerPath(root, "release.json"),
         RELEASE_METADATA_BYTES,
         signal,
         resources,
@@ -753,7 +760,7 @@ export class LinuxHopperPublisher {
           "The retained Hopper metadata does not identify this launcher.",
         );
       const keyBytes = await this.#read(
-        join(root, "public-key.asc"),
+        appendCallerPath(root, "public-key.asc"),
         RELEASE_METADATA_BYTES,
         signal,
         resources,
@@ -771,7 +778,7 @@ export class LinuxHopperPublisher {
       return await this.#verifyArchive(
         parsed.data.family,
         release.value,
-        join(root, "archive"),
+        appendCallerPath(root, "archive"),
         signal,
         key,
         resources,
diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts
index a9606020f5..11e4aab67f 100644
--- a/src/application/MacHopper.test.ts
+++ b/src/application/MacHopper.test.ts
@@ -307,3 +307,19 @@ it.each([false, true])(
     expect(host.installs).toBe(1);
   },
 );
+
+it("refuses release URLs that normalize outside the public channel before package download", async () => {
+  const host = new FakeMacHopperHost();
+  const feed = await host.download("files-api");
+  const row = JSON.parse(new TextDecoder().decode(feed.bytes));
+  row["OS X"].filename =
+    "https://www.hopperapp.com:443/downloader/public/%2e%2e/private/Hopper-demo.dmg";
+  host.feedBytes = new TextEncoder().encode(JSON.stringify(row));
+
+  expect(await installMacHopper({}, host)).toMatchObject({
+    status: "failed",
+    reason: "release_metadata",
+  });
+  expect(host.packageDownloads).toBe(0);
+  expect(host.mounts).toBe(0);
+});
diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts
index c9f90475ef..4b3edeb347 100644
--- a/src/application/MacHopper.ts
+++ b/src/application/MacHopper.ts
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
 import { join, posix } from "node:path";
 import { promisify } from "node:util";
 
+import { appendCallerPath } from "../filesystem/callerPath.js";
 import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
 import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
 import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
@@ -194,7 +195,11 @@ export const installMacHopper = async (
             : { cleanupFailure: installation.cleanupFailure }),
         };
       bundleCleanupFailure = installation.cleanupFailure;
-      const launcherPath = posix.join(destination, "Contents/MacOS/hopper");
+      const launcherPath = appendCallerPath(
+        destination,
+        "Contents/MacOS/hopper",
+        "darwin",
+      );
       let openFailure: string | undefined;
       try {
         await host.openApplication(destination);
@@ -260,8 +265,12 @@ export const installMacHopperBundle = async (
   const stagingRoot = await operations.createStagingRoot(
     posix.dirname(destination),
   );
-  const stagedBundle = posix.join(stagingRoot, "new.app");
-  const previousBundle = posix.join(stagingRoot, "previous.app");
+  const stagedBundle = appendCallerPath(stagingRoot, "new.app", "darwin");
+  const previousBundle = appendCallerPath(
+    stagingRoot,
+    "previous.app",
+    "darwin",
+  );
   let originalBackedUp = false;
   let newCommitted = false;
   let failureReason: "copy" | "launcher_missing" = "copy";
@@ -270,7 +279,7 @@ export const installMacHopperBundle = async (
     await operations.copyBundle(source, stagedBundle);
     failureReason = "launcher_missing";
     await operations.requireLauncher(
-      posix.join(stagedBundle, "Contents/MacOS/hopper"),
+      appendCallerPath(stagedBundle, "Contents/MacOS/hopper", "darwin"),
     );
     failureReason = "copy";
     if (replaceExisting) {
@@ -428,7 +437,9 @@ export const systemMacHopperInstallHost = (
         {
           async createStagingRoot(parent) {
             await mkdir(parent, { recursive: true });
-            return mkdtemp(posix.join(parent, ".rea-hopper-stage-"));
+            return mkdtemp(
+              appendCallerPath(parent, ".rea-hopper-stage-", "darwin"),
+            );
           },
           copyBundle: async (bundle, staged) => {
             await execFileAsync("ditto", [bundle, staged], {
diff --git a/src/application/MacHopperPaths.test.ts b/src/application/MacHopperPaths.test.ts
index eb33acabc6..15a32dc0aa 100644
--- a/src/application/MacHopperPaths.test.ts
+++ b/src/application/MacHopperPaths.test.ts
@@ -1,11 +1,24 @@
-import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import {
+  access,
+  mkdtemp,
+  mkdir,
+  readFile,
+  rename,
+  rm,
+  symlink,
+  writeFile,
+} from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join, posix, relative } from "node:path";
+import { installMacHopperBundle } from "./MacHopper.js";
+import { linuxHopperLauncherPath } from "./LinuxHopper.js";
+import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
 import { describe, expect, it } from "vitest";
 
 import {
   localMacHopperRenamedLauncherCandidates,
   macHopperCanonicalLauncherCandidates,
+  macHopperUserApplicationPath,
   macHopperRenamedLauncherCandidates,
   readMacHopperRenamedLauncherCandidates,
 } from "./MacHopperPaths.js";
@@ -36,6 +49,93 @@ describe("macOS Hopper launcher paths", () => {
     ]);
   });
 
+  it.skipIf(process.platform === "win32")(
+    "preserves a selected home through symlink parent traversal",
+    async () => {
+      const temporary = await mkdtemp(join(tmpdir(), "rea-hopper-home-"));
+      try {
+        await mkdir(join(temporary, "actual", "nested"), { recursive: true });
+        await symlink(
+          join(temporary, "actual", "nested"),
+          join(temporary, "alias"),
+        );
+        const home = homeDirectoryFromEnvironment(
+          { HOME: `${temporary}/alias/..` },
+          "darwin",
+        );
+        await mkdir(
+          join(temporary, "actual", "Applications", "Hopper Preview.app"),
+          { recursive: true },
+        );
+        const expected = `${home}/Applications/Hopper Preview.app/Contents/MacOS/hopper`;
+        expect(
+          [...localMacHopperRenamedLauncherCandidates(home)].filter((path) =>
+            path.startsWith(home),
+          ),
+        ).toEqual([expected]);
+        expect(
+          (await readMacHopperRenamedLauncherCandidates(home)).filter((path) =>
+            path.startsWith(home),
+          ),
+        ).toEqual([expected]);
+        expect(macHopperUserApplicationPath(home)).toBe(
+          `${home}/Applications/Hopper Disassembler.app`,
+        );
+        expect(macHopperCanonicalLauncherCandidates(home)[1]).toBe(
+          `${home}/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`,
+        );
+        expect(linuxHopperLauncherPath(home)).toBe(
+          `${home}/.local/share/rea/hopper/bin/Hopper`,
+        );
+        const source = join(temporary, "source.app");
+        await mkdir(join(source, "Contents/MacOS"), { recursive: true });
+        await writeFile(
+          join(source, "Contents/MacOS/hopper"),
+          "inert launcher",
+        );
+        const result = await installMacHopperBundle(
+          source,
+          macHopperUserApplicationPath(home),
+          false,
+          {
+            createStagingRoot: (parent) =>
+              mkdtemp(`${parent}/.rea-hopper-stage-`),
+            copyBundle: async (from, to) => {
+              await mkdir(`${to}/Contents/MacOS`, { recursive: true });
+              await writeFile(
+                `${to}/Contents/MacOS/hopper`,
+                await readFile(`${from}/Contents/MacOS/hopper`),
+              );
+            },
+            requireLauncher: (path) => access(path),
+            pathExists: async (path) =>
+              access(path).then(
+                () => true,
+                () => false,
+              ),
+            rename,
+            removeStagingRoot: (path) => rm(path, { recursive: true }),
+          },
+          () => {
+            throw new Error("Unexpected retained cleanup");
+          },
+        );
+        expect(result).toMatchObject({ status: "installed" });
+        expect(
+          await readFile(
+            join(
+              temporary,
+              "actual/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
+            ),
+            "utf8",
+          ),
+        ).toBe("inert launcher");
+      } finally {
+        await rm(temporary, { recursive: true, force: true });
+      }
+    },
+  );
+
   it("finds the same renamed user bundles through runtime and doctor scans", async () => {
     // Keep native ownership for all I/O, including UNC temporary directories.
     // On Windows, a checkout-relative home models POSIX separators without
diff --git a/src/application/MacHopperPaths.ts b/src/application/MacHopperPaths.ts
index b90a68a017..e30705cf89 100644
--- a/src/application/MacHopperPaths.ts
+++ b/src/application/MacHopperPaths.ts
@@ -1,6 +1,7 @@
 import { readdirSync, type Dirent } from "node:fs";
 import { readdir } from "node:fs/promises";
 import { posix } from "node:path";
+import { appendCallerPath } from "../filesystem/callerPath.js";
 
 export const MAC_HOPPER_APPLICATION_DIRECTORY = "/Applications";
 export const MAC_HOPPER_APPLICATION_NAME = "Hopper Disassembler.app";
@@ -13,26 +14,31 @@ export const MAC_HOPPER_SYSTEM_LAUNCHER = posix.join(
 );
 
 export const macHopperUserApplicationPath = (homeDirectory: string): string =>
-  posix.join(
+  appendCallerPath(
     homeDirectory,
-    MAC_HOPPER_USER_APPLICATIONS_DIRECTORY,
-    MAC_HOPPER_APPLICATION_NAME,
+    `${MAC_HOPPER_USER_APPLICATIONS_DIRECTORY}/${MAC_HOPPER_APPLICATION_NAME}`,
+    "darwin",
   );
 
 export const macHopperApplicationRoots = (
   homeDirectory: string,
 ): readonly string[] => [
   MAC_HOPPER_APPLICATION_DIRECTORY,
-  posix.join(homeDirectory, MAC_HOPPER_USER_APPLICATIONS_DIRECTORY),
+  appendCallerPath(
+    homeDirectory,
+    MAC_HOPPER_USER_APPLICATIONS_DIRECTORY,
+    "darwin",
+  ),
 ];
 
 export const macHopperCanonicalLauncherCandidates = (
   homeDirectory: string,
 ): readonly string[] => [
   MAC_HOPPER_SYSTEM_LAUNCHER,
-  posix.join(
+  appendCallerPath(
     macHopperUserApplicationPath(homeDirectory),
     "Contents/MacOS/hopper",
+    "darwin",
   ),
 ];
 
@@ -50,7 +56,9 @@ export const macHopperRenamedLauncherCandidates = (
             MAC_HOPPER_APPLICATION_NAME.toLocaleLowerCase("en-US"),
       )
       .sort()
-      .map((name) => posix.join(root, name, "Contents/MacOS/hopper")),
+      .map((name) =>
+        appendCallerPath(root, `${name}/Contents/MacOS/hopper`, "darwin"),
+      ),
   );
 
 // A linked application bundle retains its selected spelling. Runtime and
diff --git a/src/providers/ProviderInspectionScope.ts b/src/application/ProviderInspectionScope.ts
similarity index 100%
rename from src/providers/ProviderInspectionScope.ts
rename to src/application/ProviderInspectionScope.ts
diff --git a/src/application/ReferenceSourceGitDirectory.ts b/src/application/ReferenceSourceGitDirectory.ts
index 0cb9257cc8..2804c9eabc 100644
--- a/src/application/ReferenceSourceGitDirectory.ts
+++ b/src/application/ReferenceSourceGitDirectory.ts
@@ -1,4 +1,4 @@
-import { lstat, realpath } from "node:fs/promises";
+import { lstat, realpath, stat } from "node:fs/promises";
 import { dirname, join } from "node:path";
 import { absoluteCallerPath } from "../filesystem/callerPath.js";
 
@@ -40,7 +40,7 @@ export const locateReferenceSourceGitDirectory = async (
     }
     // Derived joins and the Git library normalize paths lexically. Admit the
     // physical directory first, while the source pointer stays in its file.
-    const gitDirectory = await realpath(selectedGitDirectory);
+    const gitDirectory = await resolveGitDirectory(selectedGitDirectory);
     let common: string | undefined;
     try {
       common = (await readText(join(gitDirectory, "commondir"))).replace(
@@ -54,11 +54,27 @@ export const locateReferenceSourceGitDirectory = async (
     const commonDirectory =
       common === undefined
         ? gitDirectory
-        : await realpath(absoluteCallerPath(common, { cwd: gitDirectory }));
+        : await resolveGitDirectory(
+            absoluteCallerPath(common, { cwd: gitDirectory }),
+          );
     return { repository, gitDirectory, commonDirectory };
   }
 };
 
+const resolveGitDirectory = async (path: string): Promise => {
+  // Darwin realpath can collapse file/.. even when native lookup rejects it.
+  // Admit the selected traversal before deriving paths for metadata consumers.
+  if (!(await stat(path)).isDirectory())
+    throw Object.assign(
+      new Error(`Git metadata path is not a directory: ${path}`),
+      {
+        code: "ENOTDIR",
+        path,
+      },
+    );
+  return realpath(path);
+};
+
 const hasCode = (cause: unknown, code: string): boolean =>
   typeof cause === "object" &&
   cause !== null &&
diff --git a/src/application/ReferenceSourceImportEntries.test.ts b/src/application/ReferenceSourceImportEntries.test.ts
index 8d01d079a9..6f934cf886 100644
--- a/src/application/ReferenceSourceImportEntries.test.ts
+++ b/src/application/ReferenceSourceImportEntries.test.ts
@@ -364,3 +364,176 @@ it("resolves extensionless imports and requires to .mts and .cts files and direc
     ]),
   );
 });
+
+it("does not infer missing candidates or manifests from partial directory enumeration", () => {
+  const file = (
+    path: string,
+    content: string,
+  ): ReferenceSourceRead["entries"][number] => ({
+    status: "read",
+    kind: "file",
+    path,
+    bytes: Buffer.from(content),
+    size: Buffer.byteLength(content),
+  });
+  const read: ReferenceSourceRead = {
+    root: "/selected/source",
+    bytesRead: 0,
+    limitations: [],
+    entries: [
+      file("package.json", '{"type":"commonjs"}'),
+      file(
+        "main.cjs",
+        'require("./pkg"); require("./other"); require("./exact.js");',
+      ),
+      file("pkg/index.js", "module.exports = 1;"),
+      file("other/index.js", "module.exports = 1;"),
+      file("exact.js", "module.exports = 1;"),
+      file("pkg/source.js", 'require("../exact.js");'),
+      {
+        status: "failed",
+        kind: "directory",
+        path: "pkg",
+        code: "io",
+        message: "Partial enumeration",
+      },
+      { status: "read", kind: "directory", path: "other" },
+    ],
+  };
+  const parsed = parseReferenceSourceEntries(read, []);
+  expect(
+    parsed.relationships.filter(({ from_path }) => from_path === "main.cjs"),
+  ).toEqual([
+    expect.objectContaining({ to: "pkg", resolution: "unresolved" }),
+    expect.objectContaining({ to: "other/index.js", resolution: "internal" }),
+    expect.objectContaining({ to: "exact.js", resolution: "internal" }),
+  ]);
+  expect(
+    parsed.relationships.filter(
+      ({ from_path }) => from_path === "pkg/source.js",
+    ),
+  ).not.toContainEqual(expect.objectContaining({ resolution: "internal" }));
+  const partialRoot = parseReferenceSourceEntries(
+    { ...read, rootFailure: { code: "io", message: "Partial root" } },
+    [],
+  );
+  expect(
+    partialRoot.relationships.find(
+      ({ from_path, to }) =>
+        from_path === "main.cjs" && to === "other/index.js",
+    ),
+  ).toBeUndefined();
+  expect(partialRoot.relationships).toContainEqual(
+    expect.objectContaining({
+      from_path: "main.cjs",
+      to: "exact.js",
+      resolution: "internal",
+    }),
+  );
+});
+
+it.each(["failed", "excluded"] as const)(
+  "keeps higher priority %s candidates unknown",
+  (state) => {
+    const files = [
+      ["main.cjs", 'require("./pkg"); require("./pkg/index.js");'],
+      ["pkg/index.js", "module.exports = 1;"],
+    ] as const;
+    const read: ReferenceSourceRead = {
+      root: "/selected/source",
+      bytesRead: 0,
+      limitations: [],
+      entries: [
+        ...files.map(([path, content]) => ({
+          status: "read" as const,
+          kind: "file" as const,
+          path,
+          bytes: Buffer.from(content),
+          size: Buffer.byteLength(content),
+        })),
+        ...(state === "failed"
+          ? [
+              {
+                status: "failed" as const,
+                kind: "file" as const,
+                path: "pkg.js",
+                code: "io" as const,
+                message: "Unreadable",
+              },
+            ]
+          : []),
+      ],
+    };
+    const parsed = parseReferenceSourceEntries(
+      read,
+      state === "excluded"
+        ? [
+            {
+              path: "pkg.js",
+              entry_kind: "file",
+              reason: "caller-excluded",
+              pattern: "pkg.js",
+            },
+          ]
+        : [],
+    );
+    expect(parsed.relationships).toEqual([
+      expect.objectContaining({ to: "pkg", resolution: "unresolved" }),
+      expect.objectContaining({ to: "pkg/index.js", resolution: "internal" }),
+    ]);
+  },
+);
+
+it("retains observed manifests and closer completed scopes inside a partial inventory", () => {
+  const files = [
+    ["main.cjs", 'require("./pkg");'],
+    ["pkg/package.json", '{"main":"entry.js","type":"commonjs"}'],
+    ["pkg/entry.js", 'require("./known.js");'],
+    ["pkg/known.js", "module.exports = 1;"],
+    ["complete/source.js", 'require("./known.js");'],
+    ["complete/known.js", "module.exports = 1;"],
+    ["complete/package.json", '{"type":"commonjs"}'],
+  ] as const;
+  const parsed = parseReferenceSourceEntries(
+    {
+      root: "/selected/source",
+      bytesRead: 0,
+      limitations: [],
+      entries: [
+        ...files.map(([path, content]) => ({
+          status: "read" as const,
+          kind: "file" as const,
+          path,
+          bytes: Buffer.from(content),
+          size: Buffer.byteLength(content),
+        })),
+        {
+          status: "failed",
+          kind: "directory",
+          path: "pkg",
+          code: "io",
+          message: "Partial enumeration",
+        },
+        { status: "read", kind: "directory", path: "complete" },
+      ],
+    },
+    [],
+  );
+  expect(parsed.relationships).toEqual([
+    expect.objectContaining({
+      from_path: "main.cjs",
+      to: "pkg/entry.js",
+      resolution: "internal",
+    }),
+    expect.objectContaining({
+      from_path: "pkg/entry.js",
+      to: "pkg/known.js",
+      resolution: "internal",
+    }),
+    expect.objectContaining({
+      from_path: "complete/source.js",
+      to: "complete/known.js",
+      resolution: "internal",
+    }),
+  ]);
+});
diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts
index a1de8ed465..c7571579e4 100644
--- a/src/application/ReferenceSourceImportEntries.ts
+++ b/src/application/ReferenceSourceImportEntries.ts
@@ -124,6 +124,7 @@ const UNKNOWN_PACKAGE: PackageMetadata = {
 const readPackageMetadata = (
   read: ReferenceSourceRead,
   exclusions: HistoricalSourceGraphInput["exclusions"],
+  unobservedPath: (path: string) => boolean,
 ): {
   readonly packages: ReadonlyMap;
   readonly pathLookup: ReferenceSourcePathLookup;
@@ -176,6 +177,22 @@ const readPackageMetadata = (
       packages.set(directory, UNKNOWN_PACKAGE);
     }
   }
+  // An incomplete directory cannot establish that its manifest is absent.
+  const uncertainDirectories = [
+    ...(read.rootFailure === undefined ? [] : ["."]),
+    ...read.entries
+      .filter(
+        (entry) => entry.kind === "directory" && entry.status === "failed",
+      )
+      .map(({ path }) => path),
+  ];
+  for (const directory of uncertainDirectories) {
+    const path = posix.join(directory, "package.json");
+    if (!packages.has(directory) && unobservedPath(path)) {
+      manifestPaths.push(path);
+      packages.set(directory, UNKNOWN_PACKAGE);
+    }
+  }
   return {
     packages,
     pathLookup: createReferenceSourcePathLookup(manifestPaths),
@@ -226,8 +243,74 @@ interface SpecifierContext {
   readonly lookupPath: ReferenceSourcePathLookup;
   readonly packages: ReadonlyMap;
   readonly packageManifestPathLookup: ReferenceSourcePathLookup;
+  readonly unobservedPath: (path: string) => boolean;
 }
 
+/** Earlier unobserved candidates must not be treated as absent. */
+const selectObservedCandidate = (
+  candidates: readonly string[],
+  context: SpecifierContext,
+): Result => {
+  const selected = context.lookupPath.resolveCandidates(candidates);
+  for (const candidate of candidates) {
+    if (
+      context.lookupPath.resolve(candidate) === selected &&
+      selected !== undefined
+    )
+      break;
+    if (context.unobservedPath(candidate)) return err(undefined);
+  }
+  return ok(selected);
+};
+
+/** The closest directory observation owns whether an absent child was examined. */
+const unobservedReferencePath = (
+  read: ReferenceSourceRead,
+  exclusions: HistoricalSourceGraphInput["exclusions"],
+): ((path: string) => boolean) => {
+  const observed = createReferenceSourcePathLookup(
+    read.entries.map(({ path }) => path),
+  );
+  const unavailable = createReferenceSourcePathLookup([
+    ...read.entries
+      .filter(
+        (entry) =>
+          entry.kind !== "directory" &&
+          (entry.status === "failed" || entry.kind === "symlink"),
+      )
+      .map(({ path }) => path),
+    ...exclusions
+      .filter((entry) => entry.entry_kind !== "directory")
+      .map(({ path }) => path),
+  ]);
+  const excludedDirectories = createReferenceSourcePathLookup(
+    exclusions
+      .filter((entry) => entry.entry_kind === "directory")
+      .map(({ path }) => path),
+  );
+  const directories = new Map(
+    read.entries
+      .filter((entry) => entry.kind === "directory")
+      .map((entry) => [entry.path, entry.status]),
+  );
+  const directoryLookup = createReferenceSourcePathLookup(directories.keys());
+  return (path) => {
+    if (unavailable.has(path)) return true;
+    if (observed.has(path)) return false;
+    for (
+      let parent = posix.dirname(path);
+      parent !== "." && parent !== "/";
+      parent = posix.dirname(parent)
+    ) {
+      if (excludedDirectories.has(parent)) return true;
+      const directory = directoryLookup.resolve(parent);
+      if (directory !== undefined)
+        return directories.get(directory) === "failed";
+    }
+    return read.rootFailure !== undefined;
+  };
+};
+
 const packageMainForDirectory = (
   directory: string,
   context: SpecifierContext,
@@ -291,7 +374,9 @@ const selectPackageMain = (
             .filter((suffix) => suffix !== "")
             .map((suffix) => posix.join(target, `index${suffix}`)),
         ];
-  const selected = context.lookupPath.resolveCandidates(candidates);
+  const selection = selectObservedCandidate(candidates, context);
+  if (!selection.ok) return { to: packageDirectory, resolution: "unresolved" };
+  const selected = selection.value;
   return selected === undefined
     ? indexFallback === undefined
       ? { to: packageDirectory, resolution: "unresolved" }
@@ -390,7 +475,26 @@ const resolveInternalSpecifier = (
         ({ path }) => path,
       ),
     );
-  const match = context.lookupPath.resolveCandidates(candidates);
+  const selection = selectObservedCandidate(candidates, context);
+  if (!selection.ok) {
+    // Node consults a known package main before probing directory indexes.
+    // Incomplete index coverage cannot invalidate that observed declaration.
+    if (
+      mode === "runtime-require" &&
+      packageMainForDirectory(normalized, context)?.main.state === "declared"
+    ) {
+      const files = directoryReference
+        ? ok(undefined)
+        : selectObservedCandidate(
+            nodeRequireFileCandidates(normalized),
+            context,
+          );
+      if (files.ok && files.value === undefined)
+        return selectPackageMain(normalized, undefined, context, mode);
+    }
+    return { to: normalized, resolution: "unresolved" };
+  }
+  const match = selection.value;
   if (match === undefined)
     return packageMainForDirectory(normalized, context) !== undefined
       ? selectPackageMain(normalized, undefined, context, mode)
@@ -547,13 +651,19 @@ export const parseReferenceSourceEntries = (
     output.limitations.push(
       `Reference root enumeration is incomplete: ${read.rootFailure.message}`,
     );
-  const packageInventory = readPackageMetadata(read, exclusions);
+  const unobservedPath = unobservedReferencePath(read, exclusions);
+  const packageInventory = readPackageMetadata(
+    read,
+    exclusions,
+    unobservedPath,
+  );
   const context: SpecifierContext = {
     lookupPath: createReferenceSourcePathLookup(
       read.entries
         .filter((entry) => entry.status === "read" && entry.kind === "file")
         .map((entry) => entry.path),
     ),
+    unobservedPath,
     packages: packageInventory.packages,
     packageManifestPathLookup: packageInventory.pathLookup,
   };
diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts
index b621f11356..e06f6bb314 100644
--- a/src/application/Setup.recovery.test.ts
+++ b/src/application/Setup.recovery.test.ts
@@ -683,6 +683,10 @@ describe("setup Hopper install eligibility", () => {
   it.each([
     { version: "", installs: false },
     { version: "14beta", installs: false },
+    { version: "14.garbage", installs: false },
+    { version: "14.", installs: false },
+    { version: "14.0\n", installs: false },
+    { version: "99999999999999999", installs: false },
     { version: "12", installs: true },
     { version: "12.0", installs: true },
   ])(
diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts
index d58d49158e..5102d5b955 100644
--- a/src/application/SetupClientConfiguration.ts
+++ b/src/application/SetupClientConfiguration.ts
@@ -16,6 +16,7 @@ import {
   clientServerListedDisabled,
   clientServerPath,
   legacyClientServerPath,
+  shadowedClientServers,
   OMP_DISABLED_SERVERS_KEY,
   parseClientConfiguration,
   parseZcodeFallbackConfiguration,
@@ -39,7 +40,11 @@ import {
   type ProviderRequestBudgetResolver,
 } from "./ClientRegistrationEnvironment.js";
 
-import { refreshClientRegistration } from "./ClientRegistration.js";
+import {
+  clientRegistrationOwnership,
+  clientRegistrationOwnershipRemediation,
+  refreshClientRegistration,
+} from "./ClientRegistration.js";
 
 import { PRODUCT_IDENTITY } from "../identity.js";
 import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
@@ -169,6 +174,18 @@ const configureClientDocument = async (
       format,
     );
     parsed = clientConfigurationForWrite(parsed, files.value);
+    const ownershipBlocker = registrationOwnershipBlocker(
+      client,
+      parsed,
+      original ?? "",
+      files.value,
+    );
+    if (ownershipBlocker !== undefined)
+      return {
+        status: "failed",
+        reason: "registration_ownership",
+        detail: ownershipBlocker,
+      };
     const policy = await readClientPolicyBlock(
       client,
       parsed,
@@ -195,8 +212,15 @@ const configureClientDocument = async (
   }
   const fallback = await readZcodeFallbackServers(client, parsed);
   if (!fallback.ok) {
-    throwCleanupFailure(fallback.error);
-    return { status: "failed", reason: "readback" };
+    if (fallback.error.kind !== "registration_ownership")
+      throwCleanupFailure(fallback.error);
+    return fallback.error.kind === "registration_ownership"
+      ? {
+          status: "failed",
+          reason: "registration_ownership",
+          detail: fallback.error.detail,
+        }
+      : { status: "failed", reason: "readback" };
   }
   const desired = await clientConfigurationDesired(
     client,
@@ -310,8 +334,7 @@ const persistClientConfiguration = async ({
         ...(legacyPath === undefined ? [] : [legacyPath]),
         ...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []),
       ],
-      // Setup deletes only REA's OpenCode V1 name, which the native V2 entry
-      // supersedes whatever its launcher; every repeat goes, as one member does.
+      // Ownership admission above covers every legacy member removed here.
       { removeShadowedMembersAt: legacyPath === undefined ? [] : [legacyPath] },
     );
   } catch (cause: unknown) {
@@ -542,6 +565,16 @@ const inspectClientDocument = async (
   } catch (cause: unknown) {
     if (cause instanceof RegularFileCleanupFailure) throw cause;
     if (isMissing(cause)) {
+      const inherited = effectiveClientConfiguration(files.value, {
+        client,
+        document: undefined,
+      });
+      const ownershipBlocker =
+        inherited === undefined
+          ? undefined
+          : registrationOwnershipBlocker(client, inherited, "", files.value);
+      if (ownershipBlocker !== undefined)
+        return { status: "invalid", remediation: ownershipBlocker };
       // This read establishes absence; do not reuse an earlier user-file policy.
       const policy = await readClientPolicyBlock(
         client,
@@ -566,7 +599,8 @@ const inspectClientDocument = async (
         return { status: "invalid", remediation: policy.value };
       const fallback = await readZcodeFallbackServers(client);
       if (!fallback.ok) {
-        throwCleanupFailure(fallback.error);
+        if (fallback.error.kind !== "registration_ownership")
+          throwCleanupFailure(fallback.error);
         return { status: "invalid", remediation: fallback.error.detail };
       }
       return { status: "create" };
@@ -581,6 +615,14 @@ const inspectClientDocument = async (
       parseClientConfiguration(original, client.format),
       files.value,
     );
+    const ownershipBlocker = registrationOwnershipBlocker(
+      client,
+      parsed,
+      original,
+      files.value,
+    );
+    if (ownershipBlocker !== undefined)
+      return { status: "invalid", remediation: ownershipBlocker };
     const desired = await clientConfigurationDesired(
       client,
       providerEnvironment,
@@ -603,7 +645,8 @@ const inspectClientDocument = async (
       return { status: "already_current" };
     const fallback = await readZcodeFallbackServers(client, parsed);
     if (!fallback.ok) {
-      throwCleanupFailure(fallback.error);
+      if (fallback.error.kind !== "registration_ownership")
+        throwCleanupFailure(fallback.error);
       return { status: "invalid", remediation: fallback.error.detail };
     }
   } catch (cause: unknown) {
@@ -642,12 +685,71 @@ const throwCleanupFailure = (error: ClientConfigurationFileError): void => {
   if (error.cleanupFailure !== undefined) throw error.cleanupFailure;
 };
 
+/** Admit only absent or proven-owned rows that this setup would replace. */
+const registrationOwnershipBlocker = (
+  client: SetupClient,
+  parsed: ClientConfigurationDocument,
+  original: string,
+  sources: readonly ClientConfigurationSource[] = [],
+): string | undefined => {
+  // Cordis owns registrations by stable entry ID, including malformed launchers.
+  if (parsed.dialect === "dsh") return undefined;
+  const name = PRODUCT_IDENTITY.mcpServerKey;
+  const entries: { value: unknown; paths: readonly string[] }[] = [];
+  const effective = effectiveClientConfiguration(sources, {
+    client,
+    document: parsed,
+  });
+  if (effective !== undefined && Object.hasOwn(effective.servers, name))
+    entries.push({
+      value: effective.servers[name],
+      paths: client.configPaths ?? [client.configPath],
+    });
+  const fragment = parsed.servers[name];
+  // A layered policy fragment inherits its launcher. Classify the effective
+  // launcher above, but also admit any explicit command this write replaces.
+  const inheritsLauncher =
+    effective !== undefined &&
+    effective !== parsed &&
+    typeof fragment === "object" &&
+    fragment !== null &&
+    !Array.isArray(fragment) &&
+    !Object.hasOwn(fragment, "command");
+  if (Object.hasOwn(parsed.servers, name) && !inheritsLauncher)
+    entries.push({ value: parsed.servers[name], paths: [client.configPath] });
+  if (legacyClientServerPath(parsed, name) !== undefined) {
+    entries.push({
+      value: parsed.legacyServers[name],
+      paths: [client.configPath],
+    });
+    entries.push(
+      ...shadowedClientServers(original, parsed, name).legacyServers.map(
+        (value) => ({ value, paths: [client.configPath] }),
+      ),
+    );
+  }
+  for (const entry of entries) {
+    const ownership = clientRegistrationOwnership(entry.value, client);
+    if (ownership !== "owned")
+      return clientRegistrationOwnershipRemediation(ownership, entry.paths);
+  }
+  return undefined;
+};
+
 /** Admit the fallback only when the first native ZCode server would suppress it. */
 const readZcodeFallbackServers = async (
   client: SetupClient,
   parsed?: ClientConfigurationDocument,
 ): Promise<
-  Result>, ClientConfigurationFileError>
+  Result<
+    Readonly>,
+    | ClientConfigurationFileError
+    | {
+        readonly kind: "registration_ownership";
+        readonly path: string;
+        readonly detail: string;
+      }
+  >
 > => {
   if (
     client.format !== "zcode" ||
@@ -667,6 +769,17 @@ const readZcodeFallbackServers = async (
   if (source.value === undefined) return ok({});
   try {
     const parsed = parseZcodeFallbackConfiguration(source.value);
+    const ownershipBlocker = registrationOwnershipBlocker(
+      { ...client, configPath: path, format: "zcode_fallback" },
+      parsed,
+      source.value,
+    );
+    if (ownershipBlocker !== undefined)
+      return err({
+        kind: "registration_ownership",
+        path,
+        detail: ownershipBlocker,
+      });
     const environment = clientRegistrationEnvironment(parsed);
     if (!environment.ok)
       return err({
diff --git a/src/application/SetupClients.ts b/src/application/SetupClients.ts
index 0b76780bec..a8892c74ce 100644
--- a/src/application/SetupClients.ts
+++ b/src/application/SetupClients.ts
@@ -10,6 +10,8 @@ export const failedConfigurationMessage = (
   result: Extract,
 ): string => {
   switch (result.reason) {
+    case "registration_ownership":
+      return result.detail;
     case "conflict":
       return "Agent configuration changed during setup. Review the current file and rerun setup.";
     case "target_changed":
diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts
index f15ff531be..d92d545cc0 100644
--- a/src/application/SetupPlan.ts
+++ b/src/application/SetupPlan.ts
@@ -24,6 +24,7 @@ import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
 import type { DoctorReport } from "./Doctor.js";
 import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
 import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
+import { macosMajorVersion } from "../domain/macosVersion.js";
 
 /** Whether automated Hopper installation can run on this host, and why not. */
 export type HopperInstallEligibility =
@@ -408,11 +409,8 @@ const hopperInstallEligibility = (
   linuxDistribution: LinuxDistribution | undefined,
 ): HopperInstallEligibility => {
   if (platform === "darwin") {
-    const major =
-      macosVersion === undefined
-        ? undefined
-        : /^(\d+)(?:\.|$)/u.exec(macosVersion)?.[1];
-    return major !== undefined && Number(major) >= 12
+    const major = macosMajorVersion(macosVersion);
+    return major !== undefined && major >= 12
       ? { supported: true }
       : { supported: false, remediation: "Upgrade to macOS 12 or newer." };
   }
diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts
index 43cc1d57a1..f27e2f7eaa 100644
--- a/src/application/SetupTypes.ts
+++ b/src/application/SetupTypes.ts
@@ -35,6 +35,11 @@ export interface SetupInitialState {
 
 /** Result of one backup/write/readback transaction. */
 export type ClientConfigurationResult =
+  | {
+      readonly status: "failed";
+      readonly reason: "registration_ownership";
+      readonly detail: string;
+    }
   | {
       readonly status: "failed";
       readonly reason: "conflict";
diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts
index 0cba3dae3a..2dadce7811 100644
--- a/src/application/SupportedClients.ts
+++ b/src/application/SupportedClients.ts
@@ -196,8 +196,9 @@ const kimiDirectory = ({ home, env }: ClientPathContext): string =>
 const qoderDirectory = ({ home, env }: ClientPathContext): string =>
   env.QODER_CONFIG_DIR || join(home, ".qoder");
 
-const factoryDirectory = ({ home }: ClientPathContext): string =>
-  join(home, ".factory");
+/** Droid's override selects the home containing .factory, including personal skills. */
+const factoryDirectory = ({ home, env }: ClientPathContext): string =>
+  join(env.FACTORY_HOME_OVERRIDE || home, ".factory");
 
 /** Match Qwen Code's home override, including tilde and cwd-relative paths. */
 const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
@@ -684,9 +685,9 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
     name: "hermes",
     displayName: "Hermes",
     skillPath: (context: ClientPathContext) =>
-      join(hermesDirectory(context), "skills"),
+      appendCallerPath(hermesDirectory(context), "skills"),
     configPath: (context: ClientPathContext) =>
-      join(hermesDirectory(context), "config.yaml"),
+      appendCallerPath(hermesDirectory(context), "config.yaml"),
     markerPath: hermesDirectory,
     format: "hermes",
   },
diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts
index 60e2bc451b..4b0a4fd64b 100644
--- a/src/application/Uninstall.ts
+++ b/src/application/Uninstall.ts
@@ -24,7 +24,7 @@ import { defaultKunaCacheDirectory } from "../config/kunaCacheDirectory.js";
 import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperCacheDirectory.js";
 
 import { ClientConfigurationPublication } from "./ClientConfigurationPublication.js";
-import { parseClientRegistrationLauncher } from "./ClientRegistration.js";
+import { clientRegistrationOwnership } from "./ClientRegistration.js";
 
 import { PRODUCT_IDENTITY } from "../identity.js";
 import {
@@ -33,8 +33,6 @@ import {
   readSkillFile,
   skillDestinationSelection,
 } from "./SetupSkill.js";
-import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
-import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
 import { dshDocument } from "./DshCordisPatch.js";
 import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
 import {
@@ -785,11 +783,11 @@ const clientRegistrationRemoval = (
   const removeNative =
     (parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry) ||
     (Object.hasOwn(parsed.servers, name) &&
-      isOwnedRegistration(parsed.servers[name], client));
+      clientRegistrationOwnership(parsed.servers[name], client) === "owned");
   const legacyPath = legacyClientServerPath(parsed, name);
   const removeLegacy =
     legacyPath !== undefined &&
-    isOwnedRegistration(parsed.legacyServers[name], client);
+    clientRegistrationOwnership(parsed.legacyServers[name], client) === "owned";
   const ownership: RegistrationRemoval = {
     removeNative,
     legacyPath: removeLegacy ? legacyPath : undefined,
@@ -799,11 +797,11 @@ const clientRegistrationRemoval = (
     if (
       (removeNative &&
         shadowed.servers.some(
-          (member) => !isOwnedRegistration(member, client),
+          (member) => clientRegistrationOwnership(member, client) !== "owned",
         )) ||
       (removeLegacy &&
         shadowed.legacyServers.some(
-          (member) => !isOwnedRegistration(member, client),
+          (member) => clientRegistrationOwnership(member, client) !== "owned",
         ))
     )
       return err({
@@ -1024,19 +1022,6 @@ const resolveUninstallConfigPath = async (
   );
 };
 
-/** Ownership rule for persistent MCP registrations created by REA setup. */
-const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => {
-  const parsed = parseClientRegistrationLauncher(value, client);
-  if (!parsed.ok) return false;
-  const { command, args } = parsed.value;
-  return (
-    classifyClientRegistrationCommand(
-      [command, ...args],
-      CURRENT_ENTRYPOINT_PATH,
-    ) === "owned"
-  );
-};
-
 /** Purge the same default cache namespaces selected by their producers. */
 const purgeSelectedData = async (
   home: string,
diff --git a/src/application/Update.test.ts b/src/application/Update.test.ts
index 241cb7cd08..70102068c4 100644
--- a/src/application/Update.test.ts
+++ b/src/application/Update.test.ts
@@ -1,4 +1,14 @@
 import { join, resolve } from "node:path";
+import {
+  mkdtemp,
+  mkdir,
+  realpath,
+  rm,
+  stat,
+  symlink,
+  writeFile,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
 
 import { describe, expect, it } from "vitest";
 
@@ -397,6 +407,59 @@ describe("Bun installation ownership", () => {
       Promise.resolve(manifests.includes(path.replaceAll("\\", "/"))),
   });
 
+  it.skipIf(process.platform === "win32")(
+    "preserves native symlink traversal through detection and pins the canonical global project",
+    async () => {
+      const directory = await mkdtemp(join(tmpdir(), "rea-bun-global-"));
+      try {
+        await mkdir(join(directory, "outer", "inner"), { recursive: true });
+        await symlink(
+          join(directory, "outer", "inner"),
+          join(directory, "alias"),
+        );
+        const selected = `${directory}/alias/../global`;
+        expect(
+          bunGlobalDirectory({ BUN_INSTALL: selected }, process.platform),
+        ).toBe(resolve(selected, "install", "global"));
+        expect(
+          bunGlobalDirectory({ XDG_CACHE_HOME: selected }, process.platform),
+        ).toBe(resolve(selected, ".bun", "install", "global"));
+        const selectedPackage = join(
+          directory,
+          "outer",
+          "global",
+          "node_modules",
+          "rea-agents",
+        );
+        await mkdir(selectedPackage, { recursive: true });
+        await writeFile(`${selected}/package.json`, "{}");
+        // The lexical alternative is also a valid project; it must not win.
+        await mkdir(join(directory, "global", "node_modules", "rea-agents"), {
+          recursive: true,
+        });
+        await writeFile(join(directory, "global", "package.json"), "{}");
+        const installation = await detectBunInstallation(
+          selectedPackage,
+          bunGlobalDirectory(
+            { BUN_INSTALL_GLOBAL_DIR: selected },
+            process.platform,
+          ),
+          {
+            canonicalPath: realpath,
+            isFile: async (path) => (await stat(path)).isFile(),
+          },
+        );
+        expect(installation).toEqual({
+          manager: "bun",
+          globalDirectory: await realpath(selected),
+          packageRoot: await realpath(selectedPackage),
+        });
+      } finally {
+        await rm(directory, { recursive: true, force: true });
+      }
+    },
+  );
+
   it("identifies the package inside Bun's global project", async () => {
     await expect(
       detectBunInstallation(packageRoot, globalDirectory, host()),
diff --git a/src/application/UpdateRuntime.ts b/src/application/UpdateRuntime.ts
index bd317ff582..97254c6a99 100644
--- a/src/application/UpdateRuntime.ts
+++ b/src/application/UpdateRuntime.ts
@@ -1,5 +1,6 @@
 import { realpath, stat } from "node:fs/promises";
 import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
+import { absoluteCallerPath } from "../filesystem/callerPath.js";
 import { basename, dirname, join, resolve } from "node:path";
 import { fileURLToPath } from "node:url";
 import { lt, valid } from "semver";
@@ -89,7 +90,9 @@ export const bunGlobalDirectory = (
   platform: NodeJS.Platform,
 ): string => {
   const explicit = nonemptyEnvironment(environment.BUN_INSTALL_GLOBAL_DIR);
-  if (explicit !== undefined) return resolve(explicit);
+  // Bun opens the explicit project path with native filesystem traversal;
+  // its derived BUN_INSTALL/XDG paths below are normalized lexically instead.
+  if (explicit !== undefined) return absoluteCallerPath(explicit);
   const installRoot = nonemptyEnvironment(environment.BUN_INSTALL);
   if (installRoot !== undefined)
     return resolve(installRoot, "install", "global");
@@ -112,17 +115,22 @@ export const detectBunInstallation = async (
 ): Promise => {
   try {
     const canonicalPackageRoot = await host.canonicalPath(packageRoot);
+    const canonicalGlobalDirectory = await host.canonicalPath(globalDirectory);
     const globalPackageRoot = await host.canonicalPath(
-      join(globalDirectory, "node_modules", PRODUCT_IDENTITY.packageName),
+      join(
+        canonicalGlobalDirectory,
+        "node_modules",
+        PRODUCT_IDENTITY.packageName,
+      ),
     );
     if (
       canonicalPackageRoot !== globalPackageRoot ||
-      !(await host.isFile(join(globalDirectory, "package.json")))
+      !(await host.isFile(join(canonicalGlobalDirectory, "package.json")))
     )
       return undefined;
     return {
       manager: "bun",
-      globalDirectory,
+      globalDirectory: canonicalGlobalDirectory,
       packageRoot: canonicalPackageRoot,
     };
   } catch (cause: unknown) {
diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts
index ad762d5881..5ceb730528 100644
--- a/src/application/analysisView/AnalysisViewService.test.ts
+++ b/src/application/analysisView/AnalysisViewService.test.ts
@@ -11,7 +11,7 @@ import {
   analysisViewJavaScriptEvidence,
   analysisViewLayoutEvidence,
 } from "../../../tests/fixtures/analysisView.js";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
 
 import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
 import {
@@ -33,6 +33,7 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
 import { err, ok } from "../../domain/result.js";
 import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
 import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
+
 import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
 import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
 import {
@@ -41,6 +42,149 @@ import {
   scenarioCaptureFixture,
 } from "../../../tests/fixtures/browserScenarioCapture.js";
 import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
+import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
+import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
+import * as queuedInterrupts from "../../process/queuedInterrupts.js";
+import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
+import * as evidenceFileSource from "./evidenceFileSource.js";
+
+it("projects retained GPU source facts while delivering the caller's rebound subject", () => {
+  const sha256 = "a".repeat(64);
+  const original = createEvidence(
+    { path: "/original/gpu", sha256, format: "elf" },
+    { id: "fixture", name: "Fixture", version: "1" },
+    {
+      operation: "inspect_gpu_code_objects",
+      parameters: {},
+      result: {
+        artifact: {
+          path: "/original/gpu",
+          file_size: 0,
+          sha256,
+          bytes_read: 0,
+        },
+        format: "elf",
+        analysis: "inventory",
+        objects: [],
+        kernels: [],
+        ptx_artifacts: [],
+        coverage: {
+          discovery_scope: "supported-section-names-and-standalone-formats",
+          discovery: "partial",
+          payloads: "not-requested",
+          examined_sections: 0,
+          unknown_section_names: 0,
+        },
+        limitations: [],
+      },
+    },
+  );
+  const ledger = new EvidenceLedger();
+  expect(ledger.record(original).ok).toBe(true);
+  const retained = ledger.forAnalysis(original.evidence_id)!;
+  const payload = retained.normalized_result;
+  const selected = rebindEvidenceToTargetPath(original, {
+    path: "/selected/gpu",
+    sha256,
+    format: "elf",
+  });
+  expect(selected.evidence_id).toBe(original.evidence_id);
+  const retention = {
+    recordEvidence: (evidence: Evidence) => ledger.record(evidence),
+    evidenceById: (id: string) => ledger.forAnalysis(id),
+  };
+  for (const view of [
+    { kind: "summary" as const },
+    {
+      kind: "gpu-code-objects" as const,
+      collection: "objects" as const,
+      offset: 0,
+      limit: 64,
+    },
+  ]) {
+    const result = projectRetainedAnalysis(selected, retention, view);
+    if (!result.ok) throw result.error;
+    expect(result.value.subject?.local_path).toBe("/selected/gpu");
+    expect(result.value.normalized_result).toMatchObject({
+      parent_evidence_id: original.evidence_id,
+      artifact: { path: "/original/gpu", sha256 },
+    });
+  }
+  expect(ledger.forAnalysis(original.evidence_id)).toBe(retained);
+  // Local path metadata may be rebound before first retention or bundle import.
+  const aliasLedger = new EvidenceLedger();
+  expect(aliasLedger.record(selected).ok).toBe(true);
+  const imported = new EvidenceLedger();
+  expect(imported.import(aliasLedger.export()).ok).toBe(true);
+  // Importing metadata into an existing ledger must preserve view validity too.
+  expect(ledger.import(aliasLedger.export()).ok).toBe(true);
+  for (const sourceLedger of [aliasLedger, imported, ledger]) {
+    const result = projectRetainedAnalysis(
+      selected,
+      {
+        recordEvidence: (evidence) => sourceLedger.record(evidence),
+        evidenceById: (id) => sourceLedger.forAnalysis(id),
+      },
+      { kind: "summary" },
+    );
+    if (!result.ok) throw result.error;
+    expect(result.value.subject?.local_path).toBe("/selected/gpu");
+    expect(result.value.normalized_result).toMatchObject({
+      artifact: { path: "/original/gpu", sha256 },
+    });
+  }
+  expect(retained.normalized_result).toBe(payload);
+  expect(retained.subject?.local_path).toBe("/original/gpu");
+  const mismatched = createEvidence(
+    { path: "/original/gpu", sha256: "b".repeat(64), format: "elf" },
+    original.provider,
+    {
+      operation: original.operation,
+      parameters: {},
+      result: original.normalized_result,
+    },
+  );
+  expect(summarizeRetainedAnalysis(mismatched, retention)).toMatchObject({
+    ok: false,
+    error: { _tag: "AnalysisInputError" },
+  });
+});
+it.each(["inline", "retained-evidence", "invalid"] as const)(
+  "refuses an already cancelled %s view before source admission",
+  async (kind) => {
+    const parent = analysisViewLayoutEvidence();
+    const ledger = new EvidenceLedger();
+    expect(ledger.record(parent).ok).toBe(true);
+    const retained = ledger.forAnalysis(parent.evidence_id);
+    let lookups = 0;
+    const controller = new AbortController();
+    const reason = Object.freeze({ requested: "cancel-before-view-admission" });
+    controller.abort(reason);
+    const result = await inspectAnalysisView(
+      kind === "invalid"
+        ? null
+        : {
+            source:
+              kind === "inline"
+                ? { kind, evidence: parent }
+                : { kind, evidence_id: parent.evidence_id },
+            view: { kind: "summary" },
+          },
+      (id) => {
+        lookups++;
+        return ledger.forAnalysis(id);
+      },
+      { signal: controller.signal },
+    );
+    expect(result).toMatchObject({
+      ok: false,
+      error: { _tag: "AnalysisCancelledError" },
+    });
+    expect(result.ok ? undefined : result.error.cause).toBe(reason);
+    expect(lookups).toBe(0);
+    expect(ledger.forAnalysis(parent.evidence_id)).toBe(retained);
+  },
+);
 
 it.each([false, true])(
   "summarizes the selected observation path rather than older retained display metadata (history=%s)",
@@ -1185,6 +1329,308 @@ describe("inspectAnalysisView", () => {
   });
 });
 
+it("prioritizes queued cancellation over a file authentication failure", async () => {
+  const controller = new AbortController();
+  const reason = Object.freeze({ requested: "cancel-after-authentication" });
+  // Model an event callback which queues the caller's cancellation notification
+  // as authentication finishes. The service must dispatch that notification
+  // before accepting the authentication failure.
+  const read = vi
+    .spyOn(evidenceFileSource, "readEvidenceFileSource")
+    .mockImplementationOnce(async () => {
+      setImmediate(() => setImmediate(() => controller.abort(reason)));
+      return err(new EvidenceIntegrityError("Invalid selected Evidence"));
+    });
+  try {
+    const result = await inspectAnalysisView(
+      {
+        source: { kind: "evidence-file", path: "/selected-evidence.json" },
+        view: manifestView,
+      },
+      undefined,
+      { signal: controller.signal },
+    );
+    expect(result).toMatchObject({
+      ok: false,
+      error: { _tag: "AnalysisCancelledError" },
+    });
+    expect(result.ok ? undefined : result.error.cause).toBe(reason);
+    if (result.ok) throw new Error("Expected cancellation");
+    expect(result.error.secondaryFailures).toHaveLength(1);
+    expect(projectAnalysisError(result.error)).toMatchObject({
+      code: "cancelled",
+      details: {
+        diagnostics: { source_error: { code: "evidence_integrity_mismatch" } },
+      },
+    });
+  } finally {
+    read.mockRestore();
+  }
+});
+
+it("retries the owned descriptor when queued cancellation supersedes file cleanup failure", async () => {
+  const controller = new AbortController();
+  const path = join(
+    await createTestTempDirectory("rea-view-cleanup-"),
+    "evidence.json",
+  );
+  await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
+  const resources = new ArtifactResourceScope();
+  const originalClose = OwnedFileHandle.prototype.close;
+  const owners = new Set();
+  let failClose = true;
+  const close = vi
+    .spyOn(OwnedFileHandle.prototype, "close")
+    .mockImplementation(async function (this: OwnedFileHandle) {
+      owners.add(this);
+      if (failClose) {
+        setImmediate(() => setImmediate(() => controller.abort("stop")));
+        throw new Error("Fixture descriptor close unavailable");
+      }
+      return originalClose.call(this);
+    });
+  try {
+    const result = await inspectAnalysisView(
+      { source: { kind: "evidence-file", path }, view: manifestView },
+      undefined,
+      { signal: controller.signal, resources },
+    );
+    if (result.ok)
+      throw new Error("Expected cancellation with pending cleanup");
+    expect(result.error.cleanupIncomplete).toBe(true);
+    expect(result.error.cleanupResources).toEqual([path]);
+    expect(result.error.cleanupPrimary?.secondaryFailures).toHaveLength(1);
+    expect(projectAnalysisError(result.error)).toMatchObject({
+      details: {
+        diagnostics: {
+          primary_error: {
+            details: {
+              diagnostics: { source_error: { code: "cleanup_incomplete" } },
+            },
+          },
+        },
+      },
+    });
+    expect(resources.hasPending).toBe(true);
+    expect(owners.size).toBe(1);
+    expect([...owners][0]!.handle.fd).toBeGreaterThanOrEqual(0);
+    expect(projectAnalysisError(result.error)).toMatchObject({
+      code: "cleanup_incomplete",
+      details: { diagnostics: { primary_error: { code: "cancelled" } } },
+    });
+    failClose = false;
+    await resources.retryCleanup();
+    expect([...owners][0]!.handle.fd).toBe(-1);
+    expect(resources.hasPending).toBe(false);
+    expect(result.error.cleanupIncomplete).toBe(false);
+    expect(result.error.cleanupResources).toEqual([]);
+  } finally {
+    failClose = false;
+    await resources.close();
+    close.mockRestore();
+  }
+});
+
+it.each(["file", "bundle"] as const)(
+  "preserves the %s source authentication cancellation precedence",
+  async (kind) => {
+    const path = join(
+      await createTestTempDirectory("rea-view-invalid-"),
+      "invalid.json",
+    );
+    await writeFile(path, "{}");
+    const controller = new AbortController();
+    const dispatch = queuedInterrupts.dispatchQueuedInterrupts;
+    const interrupt = vi
+      .spyOn(queuedInterrupts, "dispatchQueuedInterrupts")
+      .mockImplementation(async () => {
+        await dispatch();
+        controller.abort("after-authentication");
+      });
+    try {
+      const result = await inspectAnalysisView(
+        {
+          source:
+            kind === "file"
+              ? { kind: "evidence-file", path }
+              : {
+                  kind: "retained-evidence",
+                  evidence_id: "ev_" + "a".repeat(64),
+                },
+          view: manifestView,
+        },
+        undefined,
+        {
+          signal: controller.signal,
+          ...(kind === "bundle" ? { evidenceBundlePath: path } : {}),
+        },
+      );
+      expect(result).toMatchObject({
+        ok: false,
+        error:
+          kind === "file"
+            ? { _tag: "AnalysisCancelledError", cause: "after-authentication" }
+            : { _tag: "EvidenceIntegrityError" },
+      });
+    } finally {
+      interrupt.mockRestore();
+    }
+  },
+);
+
+it.each(["file", "bundle"] as const)(
+  "preserves authenticated %s parent Evidence when cancellation arrives before projection",
+  async (kind) => {
+    const parent = analysisViewCaptureEvidence();
+    const path = join(
+      await createTestTempDirectory("rea-view-admission-"),
+      "source.json",
+    );
+    await writeFile(
+      path,
+      JSON.stringify(kind === "file" ? parent : createEvidenceBundle([parent])),
+    );
+    const controller = new AbortController();
+    const dispatch = queuedInterrupts.dispatchQueuedInterrupts;
+    const interrupt = vi
+      .spyOn(queuedInterrupts, "dispatchQueuedInterrupts")
+      .mockImplementation(async () => {
+        await dispatch();
+        controller.abort("after-source-admission");
+      });
+    try {
+      const result = await inspectAnalysisView(
+        {
+          source:
+            kind === "file"
+              ? { kind: "evidence-file", path }
+              : { kind: "retained-evidence", evidence_id: parent.evidence_id },
+          view: manifestView,
+        },
+        undefined,
+        {
+          signal: controller.signal,
+          ...(kind === "bundle" ? { evidenceBundlePath: path } : {}),
+        },
+      );
+      if (result.ok) throw new Error("Expected cancellation before projection");
+      expect(result.error).toMatchObject({
+        _tag: "AnalysisCancelledError",
+        cause: "after-source-admission",
+        partialObservation: parent,
+      });
+    } finally {
+      interrupt.mockRestore();
+    }
+  },
+);
+
+it.each(["file", "bundle"] as const)(
+  "preserves completed %s view Evidence when cancellation arrives after projection",
+  async (kind) => {
+    const parent = analysisViewCaptureEvidence();
+    const path = join(
+      await createTestTempDirectory("rea-view-completion-"),
+      "source.json",
+    );
+    await writeFile(
+      path,
+      JSON.stringify(kind === "file" ? parent : createEvidenceBundle([parent])),
+    );
+    const controller = new AbortController();
+    const dispatch = queuedInterrupts.dispatchQueuedInterrupts;
+    let stages = 0;
+    const interrupt = vi
+      .spyOn(queuedInterrupts, "dispatchQueuedInterrupts")
+      .mockImplementation(async () => {
+        await dispatch();
+        stages += 1;
+        if (stages === 2) controller.abort("after-projection");
+      });
+    try {
+      const result = await inspectAnalysisView(
+        {
+          source:
+            kind === "file"
+              ? { kind: "evidence-file", path }
+              : { kind: "retained-evidence", evidence_id: parent.evidence_id },
+          view: manifestView,
+        },
+        undefined,
+        {
+          signal: controller.signal,
+          ...(kind === "bundle" ? { evidenceBundlePath: path } : {}),
+        },
+      );
+      if (result.ok) throw new Error("Expected cancellation after projection");
+      expect(result.error).toMatchObject({
+        _tag: "AnalysisCancelledError",
+        partialObservation: {
+          operation: "inspect_analysis_view",
+          evidence_links: [parent.evidence_id],
+        },
+      });
+    } finally {
+      interrupt.mockRestore();
+    }
+  },
+);
+
+it.each(["file", "bundle"] as const)(
+  "preserves a produced %s projection failure when cancellation arrives",
+  async (kind) => {
+    const parent = createEvidence(
+      undefined,
+      { id: "fixture", name: "Fixture", version: "1" },
+      {
+        operation: "inspect_recorded_crash",
+        parameters: {},
+        result: { path: "/artifacts/crash.core" },
+      },
+    );
+    const path = join(
+      await createTestTempDirectory("rea-view-failure-"),
+      "source.json",
+    );
+    await writeFile(
+      path,
+      JSON.stringify(kind === "file" ? parent : createEvidenceBundle([parent])),
+    );
+    const controller = new AbortController();
+    const dispatch = queuedInterrupts.dispatchQueuedInterrupts;
+    let stages = 0;
+    const interrupt = vi
+      .spyOn(queuedInterrupts, "dispatchQueuedInterrupts")
+      .mockImplementation(async () => {
+        await dispatch();
+        if (++stages === 2) controller.abort("after-failure");
+      });
+    try {
+      const result = await inspectAnalysisView(
+        {
+          source:
+            kind === "file"
+              ? { kind: "evidence-file", path }
+              : { kind: "retained-evidence", evidence_id: parent.evidence_id },
+          view: { kind: "summary" },
+        },
+        undefined,
+        {
+          signal: controller.signal,
+          ...(kind === "bundle" ? { evidenceBundlePath: path } : {}),
+        },
+      );
+      expect(controller.signal.aborted).toBe(true);
+      expect(result).toMatchObject({
+        ok: false,
+        error: { _tag: "AnalysisUnsupportedTargetError" },
+      });
+    } finally {
+      interrupt.mockRestore();
+    }
+  },
+);
+
 describe("inspectAnalysisView scenario parents", () => {
   const view = manifestView;
 
diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts
index 076cb4b25e..e0576ac98e 100644
--- a/src/application/analysisView/AnalysisViewService.ts
+++ b/src/application/analysisView/AnalysisViewService.ts
@@ -1,6 +1,7 @@
+import { projectAnalysisErrorWithoutCapturedOutput } from "../../domain/analysisErrorProjection.js";
+import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
 import { parseAnalysisInput } from "../AnalysisInput.js";
 import { isNativeObservationOperation } from "../../domain/native/nativeObservation.js";
-import { setImmediate } from "node:timers/promises";
 import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js";
 import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js";
 import { INSPECT_ANALYSIS_VIEW_OPERATION } from "../../domain/analysisView/analysisViewErrors.js";
@@ -65,6 +66,7 @@ export const inspectAnalysisView = async (
     INSPECT_ANALYSIS_VIEW_OPERATION,
     rawInput,
     inspectAnalysisViewInputSchema,
+    options.signal,
   );
   if (!parsed.ok) return parsed;
   if (options.evidenceBundlePath !== undefined)
@@ -95,32 +97,24 @@ const inspectFileView = async (
     options,
     input.source.evidence_id,
   );
-  // Authentication and projection are synchronous; yield so a pending abort is dispatched before each check.
-  const cancelled = async (): Promise => {
-    await setImmediate();
-    return options.signal?.aborted === true;
-  };
+  // Authentication and projection are synchronous; dispatch queued callbacks and
+  // the signal notifications they enqueue before accepting either result.
+  await dispatchQueuedInterrupts();
   // A request cancelled during authentication reports cancellation, even when authentication failed.
-  if (await cancelled())
-    return err(
-      new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
-        cause: options.signal?.reason,
-      }),
-    );
-  if (!loaded.ok) return loaded;
-  const id = loaded.value.evidence_id;
+  const admitted = cancelledFileViewResult(
+    loaded,
+    options.signal,
+    loaded.ok ? loaded.value : undefined,
+  );
+  if (!admitted.ok) return admitted;
+  const id = admitted.value.evidence_id;
   const projected = inspectAnalysisViewValidated(
     { ...input, source: { kind: "retained-evidence", evidence_id: id } },
-    (evidenceId) => (evidenceId === id ? loaded.value : lookup?.(evidenceId)),
+    (evidenceId) => (evidenceId === id ? admitted.value : lookup?.(evidenceId)),
   );
-  // A request cancelled during projection delivers and records nothing.
-  if (await cancelled())
-    return err(
-      new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
-        cause: options.signal?.reason,
-      }),
-    );
-  return projected;
+  await dispatchQueuedInterrupts();
+  if (!projected.ok) return projected;
+  return cancelledFileViewResult(projected, options.signal, projected.value);
 };
 
 /** Resolve the caller's exact retained parent from an authenticated portable bundle. */
@@ -148,26 +142,58 @@ const inspectBundleView = async (
   );
   // Authentication and projection are synchronous; dispatch queued interrupts before accepting either result.
   await dispatchQueuedInterrupts();
+  // Preserve an already-produced bundle failure before considering a later abort.
   if (!loaded.ok) return loaded;
-  if (signal?.aborted)
-    return err(
-      new AnalysisCancelledError("inspect_analysis_view", {
-        cause: signal.reason,
-      }),
-    );
+  const parentId = input.source.evidence_id;
+  const admitted = cancelledFileViewResult(
+    loaded,
+    signal,
+    signal?.aborted
+      ? loaded.value.records.find((record) => record.evidence_id === parentId)
+      : undefined,
+  );
+  if (!admitted.ok) return admitted;
   const projected = inspectAnalysisViewValidated(
     { source: input.source, view: input.view },
-    (id) => loaded.value.records.find((record) => record.evidence_id === id),
+    (id) => admitted.value.records.find((record) => record.evidence_id === id),
   );
   await dispatchQueuedInterrupts();
-  if (projected.ok && signal?.aborted)
-    return err(
-      new AnalysisCancelledError("inspect_analysis_view", {
-        cause: signal.reason,
-        partialObservation: projected.value,
-      }),
-    );
-  return projected;
+  if (!projected.ok) return projected;
+  return cancelledFileViewResult(projected, signal, projected.value);
+};
+
+/** Retain completed work and pending cleanup when this stage accepts cancellation.
+ * File-source admission explicitly gives queued cancellation precedence over
+ * authentication errors; callers preserve other already-produced failures.
+ */
+const cancelledFileViewResult = (
+  result: Result,
+  signal?: AbortSignal,
+  completedEvidence?: Evidence,
+): Result => {
+  if (!signal?.aborted) return result;
+  const cancellation = new AnalysisCancelledError(
+    INSPECT_ANALYSIS_VIEW_OPERATION,
+    {
+      cause: signal.reason,
+      ...(completedEvidence !== undefined
+        ? { partialObservation: completedEvidence }
+        : {}),
+    },
+  );
+  if (!result.ok)
+    cancellation.retainSecondaryFailure(result.error, {
+      source_error: projectAnalysisErrorWithoutCapturedOutput(result.error),
+    });
+  return err(
+    !result.ok && result.error.cleanupIncomplete
+      ? analysisErrorWithCleanupFailure(
+          cancellation,
+          result.error,
+          INSPECT_ANALYSIS_VIEW_OPERATION,
+        ).bindCleanupDependencies([result.error])
+      : cancellation,
+  );
 };
 
 /** Project a parsed request whose selected source is inline or retained. */
diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts
index e656b7ddd3..c3e2de8d8d 100644
--- a/src/application/analysisView/NativeObservationViews.test.ts
+++ b/src/application/analysisView/NativeObservationViews.test.ts
@@ -18,7 +18,11 @@ import {
   type AnalysisViewRequest,
   type AnalysisViewResult,
 } from "../../domain/analysisView/analysisView.js";
-import { inspectAnalysisView } from "./AnalysisViewService.js";
+import {
+  inspectAnalysisView,
+  projectRetainedAnalysis,
+} from "./AnalysisViewService.js";
+import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
 
 const provider = { id: "fixture", name: "Fixture provider", version: "1" };
 const parent = (
@@ -494,6 +498,46 @@ it("keeps context identity across authenticated target path aliases while preser
   });
   expect(second.evidence.subject?.local_path).toBe("/other/alias.elf");
   expect(second.evidence.evidence_id).not.toBe(first.evidence.evidence_id);
+  const ledger = new EvidenceLedger();
+  expect(ledger.record(original).ok).toBe(true);
+  const retained = ledger.forAnalysis(original.evidence_id)!;
+  const delivered = projectRetainedAnalysis(
+    rebound,
+    {
+      recordEvidence: (evidence) => ledger.record(evidence),
+      evidenceById: (id) => ledger.forAnalysis(id),
+    },
+    view,
+  );
+  if (!delivered.ok) throw delivered.error;
+  expect(delivered.value.subject?.local_path).toBe("/other/alias.elf");
+  expect(delivered.value.normalized_result).toMatchObject({
+    artifact: { path: "/other/alias.elf" },
+    items: {
+      rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
+    },
+  });
+  expect(ledger.forAnalysis(original.evidence_id)).toBe(retained);
+  expect(retained.subject?.local_path).toBe("/selected/fixture.elf");
+  const imported = new EvidenceLedger();
+  const exporting = new EvidenceLedger();
+  expect(exporting.record(rebound).ok).toBe(true);
+  expect(imported.import(exporting.export()).ok).toBe(true);
+  const replay = await inspectAnalysisView(
+    {
+      source: { kind: "retained-evidence", evidence_id: rebound.evidence_id },
+      view,
+    },
+    (id) => imported.forAnalysis(id),
+  );
+  if (!replay.ok) throw replay.error;
+  expect(replay.value.subject?.local_path).toBe("/other/alias.elf");
+  expect(replay.value.normalized_result).toMatchObject({
+    artifact: { path: "/other/alias.elf" },
+    items: {
+      rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
+    },
+  });
 });
 
 it("pages recorded empty graph levels without inventing nodes or dropping their identity", async () => {
diff --git a/src/application/android/AndroidPackageInspection.ts b/src/application/android/AndroidPackageInspection.ts
index 07c9312a96..8c65a8109b 100644
--- a/src/application/android/AndroidPackageInspection.ts
+++ b/src/application/android/AndroidPackageInspection.ts
@@ -2,7 +2,7 @@ import type { ExecutionOptions } from "../AnalysisProvider.js";
 import type { AndroidAnalysisService } from "./AndroidAnalysisService.js";
 import {
   requireAnalysisViewRetention,
-  summarizeRetainedAnalysis,
+  projectRetainedAnalysis,
   type AnalysisViewRetention,
 } from "../analysisView/AnalysisViewService.js";
 import type { AnalysisError } from "../../domain/analysisErrorBase.js";
@@ -36,5 +36,9 @@ export const inspectAndroidPackage = async (
     options,
   );
   if (!executed.ok || parsed.value.detail !== "summary") return executed;
-  return summarizeRetainedAnalysis(executed.value, retention);
+  return projectRetainedAnalysis(
+    executed.value,
+    retention,
+    parsed.value.view ?? { kind: "summary" },
+  );
 };
diff --git a/src/application/apkSignature/ApkSignatureService.test.ts b/src/application/apkSignature/ApkSignatureService.test.ts
index 2ca6181cc9..5d6f8f2c07 100644
--- a/src/application/apkSignature/ApkSignatureService.test.ts
+++ b/src/application/apkSignature/ApkSignatureService.test.ts
@@ -76,3 +76,32 @@ it("refuses summary acquisition before reading when complete Evidence cannot be
     error: { _tag: "AnalysisCapabilityUnavailableError" },
   });
 });
+
+it("rejects a live provider subject path that differs from the selected APK", async () => {
+  const root = await mkdtemp(join(tmpdir(), "rea-apk-subject-"));
+  const provider = new ApkSignatureProvider();
+  try {
+    const path = join(root, "selected.apk");
+    const writer = new ZipWriter(new Uint8ArrayWriter());
+    await writer.add("classes.dex", new TextReader("x"));
+    await writeFile(path, await writer.close());
+    const inspected = await provider.inspect({ path });
+    if (!inspected.ok) throw inspected.error;
+    const execution = inspected.value;
+    const service = new ApkSignatureService({
+      close: () => Promise.resolve(),
+      inspect: async () =>
+        ok({
+          ...execution,
+          subject: { ...execution.subject, path: join(root, "other.apk") },
+        }),
+    });
+    expect(await service.inspect({ path, detail: "complete" })).toMatchObject({
+      ok: false,
+      error: { _tag: "AnalysisOutputError" },
+    });
+  } finally {
+    await provider.close();
+    await rm(root, { recursive: true, force: true });
+  }
+});
diff --git a/src/application/apkSignature/ApkSignatureService.ts b/src/application/apkSignature/ApkSignatureService.ts
index 74ec0731a5..019f8d3914 100644
--- a/src/application/apkSignature/ApkSignatureService.ts
+++ b/src/application/apkSignature/ApkSignatureService.ts
@@ -70,6 +70,7 @@ export class ApkSignatureService {
     if (
       !report.success ||
       report.data.artifact.path !== input.value.path ||
+      execution.subject.path !== input.value.path ||
       !apkSignatureMatchesSubject(report.data.artifact, execution.subject)
     )
       return err(
diff --git a/src/application/binary/AnalysisClientCleanup.test.ts b/src/application/binary/AnalysisClientCleanup.test.ts
index 23944836c3..4dff023c8d 100644
--- a/src/application/binary/AnalysisClientCleanup.test.ts
+++ b/src/application/binary/AnalysisClientCleanup.test.ts
@@ -88,3 +88,56 @@ it("preserves the original explanation and collected output alongside cleanup un
     errors: [primary, cleanup],
   });
 });
+
+it.each(["return", "reject"] as const)(
+  "reports a nested owner's failure once when close also %ss it",
+  async (outcome) => {
+    const failure = new ProviderCleanupError("fixture", ["owned-process"], {});
+    const notifications: unknown[] = [];
+    const child: AnalysisClient = {
+      execute: clientClosingWith(failure).execute,
+      close: (options) => {
+        options?.onCleanupFailure?.(failure);
+        return outcome === "return"
+          ? Promise.resolve(err(failure))
+          : Promise.reject(failure);
+      },
+    };
+    const parent: AnalysisClient = {
+      execute: child.execute,
+      close: (options) => closeAnalysisClient(child, "fixture", options),
+    };
+
+    const closed = await closeAnalysisClient(parent, "composite", {
+      onCleanupFailure: (error) => notifications.push(error),
+    });
+
+    expect(closed).toEqual(err(failure));
+    expect(notifications).toEqual([failure]);
+  },
+);
+
+it("reports distinct failures during final cleanup and reports again on retry", async () => {
+  const first = new ProviderCleanupError("first", ["first-resource"], {});
+  const final = new ProviderCleanupError("second", ["second-resource"], {});
+  const notifications: unknown[] = [];
+  const client: AnalysisClient = {
+    execute: clientClosingWith(first).execute,
+    close: (options) => {
+      options?.onCleanupFailure?.(first);
+      return Promise.resolve(err(final));
+    },
+  };
+  const options = {
+    onCleanupFailure: (error: unknown) => notifications.push(error),
+  };
+
+  expect(await closeAnalysisClient(client, "composite", options)).toEqual(
+    err(final),
+  );
+  expect(notifications).toEqual([first, final]);
+  expect(await closeAnalysisClient(client, "composite", options)).toEqual(
+    err(final),
+  );
+  expect(notifications).toEqual([first, final, first, final]);
+});
diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts
index 0a61b66605..3af1805679 100644
--- a/src/application/binary/AnalysisProviderRegistry.ts
+++ b/src/application/binary/AnalysisProviderRegistry.ts
@@ -1,4 +1,4 @@
-import { ProviderInspectionScope } from "../../providers/ProviderInspectionScope.js";
+import { ProviderInspectionScope } from "../ProviderInspectionScope.js";
 import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
 import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
 import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts
index 7053d3efce..1068ce09a0 100644
--- a/src/application/binary/BinarySession.ts
+++ b/src/application/binary/BinarySession.ts
@@ -1,5 +1,7 @@
 import { randomUUID } from "node:crypto";
 
+import { retainSavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
+
 import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
 import {
   analysisProfilesEqual,
@@ -10,7 +12,11 @@ import {
   AnalysisCapabilityUnavailableError,
   AnalysisOutputError,
 } from "../../domain/analysisErrorCore.js";
-import { AnalysisError } from "../../domain/analysisErrorBase.js";
+import {
+  AnalysisError,
+  primaryAnalysisError,
+} from "../../domain/analysisErrorBase.js";
+import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
 import { err, ok, type Result } from "../../domain/result.js";
 import type { JsonValue } from "../../domain/jsonValue.js";
 import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
@@ -22,8 +28,6 @@ import type {
   ProviderIdentity,
   AnalysisOperation,
 } from "../AnalysisProvider.js";
-import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
-import { ENHANCED_TOOL_CONTRACTS } from "../../contracts/enhancedToolContracts.js";
 import type {
   BinarySessionPort,
   SavedAnalysisSnapshot,
@@ -32,6 +36,10 @@ import {
   SessionProviderRouter,
   type SessionProviderRoute,
 } from "./SessionProviderRouter.js";
+import {
+  routeAllowsSnapshotReplay,
+  routeProviderIdentity,
+} from "./SessionProviderPolicy.js";
 import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
 import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js";
 import { BinarySessionRecords } from "./BinarySessionRecords.js";
@@ -68,12 +76,6 @@ import {
   type ActiveBundleResourceSession,
   type BundleResourceClientFactory,
 } from "./BundleResourceSession.js";
-const OFFICIAL_OPERATIONS: ReadonlySet = new Set(
-  OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
-);
-const ENHANCED_OPERATIONS: ReadonlySet = new Set(
-  ENHANCED_TOOL_CONTRACTS.map(({ name }) => name),
-);
 
 interface SessionRun {
   readonly client: AnalysisClient;
@@ -384,38 +386,14 @@ export class BinarySession
 
   /** Identify the provider producing evidence for this session. */
   providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
-    const route = this.#currentRoute();
-    let selected = route.binding?.identity ?? route.identity;
-    if (operation !== undefined) {
-      const exact = route.capabilities.get(operation)?.provider;
-      if (exact !== undefined) selected = exact;
-      else if (ENHANCED_OPERATIONS.has(operation)) {
-        const providers = new Map();
-        for (const descriptor of route.capabilities.values())
-          if (
-            descriptor.available &&
-            OFFICIAL_OPERATIONS.has(descriptor.operation)
-          )
-            providers.set(descriptor.provider.id, descriptor.provider);
-        if (providers.size === 1) {
-          const provider = providers.values().next().value;
-          if (provider !== undefined) selected = provider;
-        }
-      }
-    }
-    return structuredClone(selected);
+    return routeProviderIdentity(this.#currentRoute(), operation);
   }
 
   /** External live state cannot be replayed by a direct or composed CLI workflow. */
   allowsSnapshotReplay(operation: AnalysisOperation): boolean {
     if (operation === "decompile_all") return false;
     if (this.#activeBundleResources !== undefined) return false;
-    const capabilities = this.#active?.route.capabilities;
-    const descriptor = capabilities?.get(operation);
-    if (descriptor !== undefined) return descriptor.cachePolicy !== "live";
-    return ![...(capabilities?.values() ?? [])].some(
-      ({ cachePolicy }) => cachePolicy === "live",
-    );
+    return routeAllowsSnapshotReplay(this.#active?.route, operation);
   }
 
   invalidateSnapshotForExternalMutation(): void {
@@ -848,15 +826,16 @@ export class BinarySession
         overwrite,
       );
       if (!written.ok) return written;
+      const receipt: SavedAnalysisSnapshot = {
+        ...written.value,
+        primitive_entries: snapshot.value.entries.length,
+        workflow_entries: snapshot.value.workflow_entries.length,
+        evidence_records: snapshot.value.evidence_bundle.records.length,
+      };
       const closed = await this.#closeActive(options);
       return closed.ok
-        ? ok({
-            ...written.value,
-            primitive_entries: snapshot.value.entries.length,
-            workflow_entries: snapshot.value.workflow_entries.length,
-            evidence_records: snapshot.value.evidence_bundle.records.length,
-          })
-        : closed;
+        ? ok(receipt)
+        : err(retainSavedAnalysisSnapshot(closed.error, receipt));
     });
   }
 
@@ -1031,9 +1010,15 @@ export class BinarySession
     primary: AnalysisError,
   ): Promise {
     const restored = await this.#restore(previous);
-    return restored.ok
-      ? primary
-      : sessionCleanupFailure(primary, restored.error, "open_binary");
+    if (restored.ok) return primary;
+    primary.retainSecondaryFailure(restored.error, {
+      restoration_error: projectAnalysisError(
+        primaryAnalysisError(restored.error),
+      ),
+    });
+    return restored.error.cleanupIncomplete
+      ? sessionCleanupFailure(primary, restored.error, "open_binary")
+      : primary;
   }
 
   /** Describe the active binary session. */
@@ -1460,7 +1445,7 @@ export class BinarySession
     }
     const closed = await this.#retireClient(run);
     return closed.ok
-      ? ok(null)
+      ? err(started.error)
       : err(sessionCleanupFailure(started.error, closed.error, "open_binary"));
   }
 
diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts
index 9ea8d52e2d..506ec45635 100644
--- a/src/application/binary/BinarySessionBundleResources.test.ts
+++ b/src/application/binary/BinarySessionBundleResources.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, expect, it } from "vitest";
@@ -44,6 +44,35 @@ afterEach(async () => {
   );
 });
 
+it.skipIf(process.platform === "win32")(
+  "refuses invalid selected traversal before creating a bundle resource client",
+  async () => {
+    const root = await mkdtemp(join(tmpdir(), "rea-resource-selected-"));
+    temporaryDirectories.push(root);
+    await mkdir(join(root, "Fixture.app"));
+    await writeFile(
+      join(root, "Fixture.app", "Info.plist"),
+      "",
+    );
+    await writeFile(join(root, "regular"), "file");
+    let created = false;
+    const session = new BinarySession(
+      SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
+      new InvestigationRecords(),
+      {
+        createClient: () => {
+          created = true;
+          throw new Error("Invalid selection must not create a client");
+        },
+      },
+    );
+    const selected = `${root}/regular/../Fixture.app`;
+    await expect(stat(selected)).rejects.toMatchObject({ code: "ENOTDIR" });
+    expect((await session.openBundleResources(selected)).ok).toBe(false);
+    expect(created).toBe(false);
+  },
+);
+
 it("opens a resource-only bundle without executable identity and keeps snapshot operations inapplicable", async () => {
   const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-session-"));
   temporaryDirectories.push(rootPath);
@@ -84,6 +113,7 @@ it("opens a resource-only bundle without executable identity and keeps snapshot
   const opened = await session.openBundleResources(rootPath);
   expect(opened.ok).toBe(true);
   if (!opened.ok) throw opened.error;
+  expect(session.allowsSnapshotReplay("inspect_plist")).toBe(false);
   expect(opened.value.executableSha256).toBeNull();
   expect(openedRoot).toBe(opened.value.canonicalPath);
   expect(
diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts
index 73b95aa8b4..b6fe5cdeb3 100644
--- a/src/application/binary/BinarySessionPort.ts
+++ b/src/application/binary/BinarySessionPort.ts
@@ -24,17 +24,8 @@ import type {
   ProviderIdentity,
 } from "../AnalysisProvider.js";
 
-/** Receipt for an atomically saved snapshot followed by provider cleanup. */
-export interface SavedAnalysisSnapshot {
-  readonly path: string;
-  readonly bytes: number;
-  /** Eligible primitive query bindings. */
-  readonly primitive_entries: number;
-  /** Eligible composed workflow bindings, separate from primitive queries. */
-  readonly workflow_entries: number;
-  /** All retained Evidence records, including observations without a replay binding. */
-  readonly evidence_records: number;
-}
+import type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
+export type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
 
 /** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
 export interface BinarySessionPort
diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts
index 23330f7b7e..5b04f55a07 100644
--- a/src/application/binary/BinarySessionRecords.ts
+++ b/src/application/binary/BinarySessionRecords.ts
@@ -21,6 +21,7 @@ import type {
   UpdateUnknownInput,
 } from "../../domain/residualUnknown.js";
 import type {
+  EvidenceImportReceipt,
   UnknownPage,
   UnknownQuery,
 } from "../investigation/InvestigationRecordPort.js";
@@ -167,12 +168,19 @@ export abstract class BinarySessionRecords {
 
   importEvidenceBundle(
     bundle: unknown,
-  ): Result {
+  ): Result<
+    EvidenceImportReceipt,
+    EvidenceIntegrityError | AnalysisInputError
+  > {
     const imported = this.#records.mergeEvidenceBundle(bundle);
     if (!imported.ok) return imported;
     this.#snapshot.refreshEvidenceMetadata(imported.value.replacements);
     if (imported.value.changed) this.#emitSnapshotChanged();
-    return ok(imported.value.recordsAdded);
+    return ok({
+      recordsAdded: imported.value.recordsAdded,
+      unknownsAdded: imported.value.unknownsAdded,
+      totalRecords: this.#records.immutableEvidenceBundle().records.length,
+    });
   }
 
   protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined;
diff --git a/src/application/binary/BinarySessionRestoration.test.ts b/src/application/binary/BinarySessionRestoration.test.ts
new file mode 100644
index 0000000000..6720d03d99
--- /dev/null
+++ b/src/application/binary/BinarySessionRestoration.test.ts
@@ -0,0 +1,139 @@
+import { expect, it } from "vitest";
+import {
+  AnalysisCancelledError,
+  AnalysisOutputError,
+} from "../../domain/analysisErrorCore.js";
+import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
+import { createAnalysisProfile } from "../../domain/analysisProfile.js";
+import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
+import { err, ok } from "../../domain/result.js";
+import { createAnalysisExecution } from "../AnalysisProvider.js";
+import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
+import { BinarySession } from "./BinarySession.js";
+import {
+  SessionProviderRouter,
+  type SessionProviderRoute,
+} from "./SessionProviderRouter.js";
+
+it.each([
+  { cancelled: false, cleanupFails: false },
+  { cancelled: true, cleanupFails: false },
+  { cancelled: false, cleanupFails: true },
+  { cancelled: true, cleanupFails: true },
+])(
+  "retains failed restoration after switch cancellation=$cancelled, cleanup failure=$cleanupFails",
+  async ({ cancelled, cleanupFails }) => {
+    const session = new BinarySession(
+      SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
+    );
+    const controller = new AbortController();
+    const identity = {
+      id: "restoration-test",
+      name: "Restoration test",
+      version: "1",
+    };
+    const restorationFailure = new AnalysisOutputError(
+      "health",
+      "Previous target could not restart",
+    );
+    const switchFailure = new AnalysisOutputError(
+      "health",
+      "Selected target could not start",
+    );
+    const cleanupFailure = new ProviderCleanupError(
+      identity.id,
+      ["restored-client"],
+      { reason: "close failed" },
+    );
+    let created = 0;
+    let restorationCloseCalls = 0;
+    const route: SessionProviderRoute = {
+      identity,
+      capabilities: new Map(),
+      profile: createAnalysisProfile(identity, { fixture: "original" }),
+      binding: null,
+      selection: undefined,
+      createClient: () => {
+        const restoring = created++ > 0;
+        return {
+          execute: async () =>
+            restoring
+              ? err(restorationFailure)
+              : ok(createAnalysisExecution(null, identity)),
+          close: async () => {
+            if (!restoring) {
+              if (cancelled) controller.abort("cancel switch");
+              return ok(null);
+            }
+            restorationCloseCalls += 1;
+            if (cleanupFails && restorationCloseCalls === 1)
+              return err(cleanupFailure);
+            cleanupFailure.resolveCleanup();
+            return ok(null);
+          },
+        };
+      },
+    };
+    try {
+      const target = await session.resolveTarget(process.execPath);
+      expect(target.ok).toBe(true);
+      if (!target.ok) return;
+      expect(
+        (await session.openResolvedTarget({ target: target.value, route })).ok,
+      ).toBe(true);
+      expect(session.activeTarget()).toEqual(target.value);
+      const result = await session.openResolvedTarget(
+        {
+          target: target.value,
+          route: {
+            ...route,
+            profile: createAnalysisProfile(identity, { fixture: "selected" }),
+            createClient: () => ({
+              execute: async () => err(switchFailure),
+              close: async () => ok(null),
+            }),
+          },
+        },
+        { signal: controller.signal },
+      );
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+      expect(session.activeTarget()).toBeUndefined();
+      expect(await session.status()).toMatchObject({
+        open: false,
+        cleanup_pending: cleanupFails,
+      });
+      expect(projectAnalysisError(result.error)).toMatchObject({
+        code: cleanupFails
+          ? "cleanup_incomplete"
+          : cancelled
+            ? "cancelled"
+            : "unreadable_output",
+      });
+      expect((await session.close()).ok).toBe(true);
+      expect(restorationCloseCalls).toBe(cleanupFails ? 2 : 1);
+      expect(await session.status()).toMatchObject({
+        open: false,
+        cleanup_pending: false,
+      });
+      expect(projectAnalysisError(result.error).code).toBe(
+        cancelled ? "cancelled" : "unreadable_output",
+      );
+      expect(projectAnalysisError(result.error)).toMatchObject({
+        details: {
+          diagnostics: {
+            restoration_error: projectAnalysisError(restorationFailure),
+          },
+        },
+      });
+      if (cancelled) {
+        const primary = result.error.cleanupPrimary ?? result.error;
+        expect(primary).toBeInstanceOf(AnalysisCancelledError);
+      } else {
+        expect(result.error.cleanupPrimary ?? result.error).toBe(switchFailure);
+      }
+    } finally {
+      await session.close();
+    }
+  },
+);
diff --git a/src/application/binary/CompositeProvider.test.ts b/src/application/binary/CompositeProvider.test.ts
index b0e7a37a6b..b35aec68af 100644
--- a/src/application/binary/CompositeProvider.test.ts
+++ b/src/application/binary/CompositeProvider.test.ts
@@ -12,6 +12,8 @@ import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
 import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
 import { err, ok } from "../../domain/result.js";
 import { CompositeProvider } from "./CompositeProvider.js";
+import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
 
 const identity = (id: string): ProviderIdentity => ({
   id,
@@ -54,7 +56,7 @@ const failingProvider = (
     },
     { remediationAction: `Resolve ${resource} before retrying ${id}.` },
   );
-  const close = vi.fn(async () => err(cleanupError));
+  const close = vi.fn(async () => err(cleanupError));
   const provider: AnalysisProvider = {
     identity: () => providerIdentity,
     capabilities: () => [operationCapability(providerIdentity, operation)],
@@ -254,3 +256,85 @@ it.each(["busy", "unknown", "idle", undefined] as const)(
     expect((await client.close()).ok).toBe(true);
   },
 );
+
+it.each([1, 2])(
+  "reports early cleanup failures once across a deferred %s-child composite and retries",
+  async (childCount) => {
+    let release!: () => void;
+    const finalization = new Promise((resolve) => {
+      release = resolve;
+    });
+    const children = [
+      failingProvider("provider-left", "address_name", "left-db"),
+      failingProvider("provider-right", "procedure_info", "right-db"),
+    ].slice(0, childCount);
+    for (const child of children) {
+      let attempts = 0;
+      child.close.mockImplementation(async (options) => {
+        attempts += 1;
+        if (attempts > 2) return ok(null);
+        // The resource owner discovers its failure while final cleanup is
+        // still pending, before the composite can return its result.
+        options?.onCleanupFailure?.(child.cleanupError);
+        await finalization;
+        return err(child.cleanupError);
+      });
+    }
+    const client = new CompositeProvider(
+      children.map((child) => child.provider),
+    ).createClient(executableTarget);
+    await client.execute("address_name", {});
+    if (childCount === 2) await client.execute("procedure_info", {});
+    const notifications: AnalysisError[] = [];
+    const options = {
+      onCleanupFailure: (failure: AnalysisError) => notifications.push(failure),
+    };
+    let settled = false;
+    const closing = closeAnalysisClient(client, "composite", options).then(
+      (result) => {
+        settled = true;
+        return result;
+      },
+    );
+    try {
+      await Promise.resolve();
+      expect(settled).toBe(false);
+      expect(notifications).toEqual(
+        children.map((child) => child.cleanupError),
+      );
+    } finally {
+      release();
+    }
+    const first = await closing;
+    expect(first.ok).toBe(false);
+    if (first.ok) throw new Error("Expected retained child cleanup");
+    expect(notifications).toEqual([
+      ...children.map((child) => child.cleanupError),
+      ...(childCount === 2 ? [first.error] : []),
+    ]);
+    expect(first.error.cleanupResources).toEqual(
+      children.flatMap((child) => child.cleanupError.cleanupResources),
+    );
+
+    notifications.length = 0;
+    const retried = await closeAnalysisClient(client, "composite", options);
+    expect(retried.ok).toBe(false);
+    if (retried.ok) throw new Error("Expected failed cleanup retry");
+    expect(notifications).toEqual([
+      ...children.map((child) => child.cleanupError),
+      ...(childCount === 2 ? [retried.error] : []),
+    ]);
+    if (childCount === 2) expect(retried.error).not.toBe(first.error);
+
+    notifications.length = 0;
+    expect(await closeAnalysisClient(client, "composite", options)).toEqual(
+      ok(null),
+    );
+    expect(notifications).toEqual([]);
+    for (const child of children) expect(child.close).toHaveBeenCalledTimes(3);
+    expect(await closeAnalysisClient(client, "composite", options)).toEqual(
+      ok(null),
+    );
+    for (const child of children) expect(child.close).toHaveBeenCalledTimes(3);
+  },
+);
diff --git a/src/application/binary/SessionProviderPolicy.ts b/src/application/binary/SessionProviderPolicy.ts
new file mode 100644
index 0000000000..c5858af224
--- /dev/null
+++ b/src/application/binary/SessionProviderPolicy.ts
@@ -0,0 +1,49 @@
+import { ENHANCED_TOOL_CONTRACTS } from "../../contracts/enhancedToolContracts.js";
+import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
+import type { ProviderIdentity } from "../AnalysisProvider.js";
+import type { SessionProviderRoute } from "./SessionProviderRouter.js";
+
+const OFFICIAL_OPERATIONS: ReadonlySet = new Set(
+  OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
+);
+const ENHANCED_OPERATIONS: ReadonlySet = new Set(
+  ENHANCED_TOOL_CONTRACTS.map(({ name }) => name),
+);
+
+/** Attribute preview and active operations to the same selected provider. */
+export const routeProviderIdentity = (
+  route: SessionProviderRoute,
+  operation?: string,
+): ProviderIdentity => {
+  let selected = route.binding?.identity ?? route.identity;
+  if (operation !== undefined) {
+    const exact = route.capabilities.get(operation)?.provider;
+    if (exact !== undefined) selected = exact;
+    else if (ENHANCED_OPERATIONS.has(operation)) {
+      const providers = new Map();
+      for (const descriptor of route.capabilities.values())
+        if (
+          descriptor.available &&
+          OFFICIAL_OPERATIONS.has(descriptor.operation)
+        )
+          providers.set(descriptor.provider.id, descriptor.provider);
+      if (providers.size === 1) {
+        const provider = providers.values().next().value;
+        if (provider !== undefined) selected = provider;
+      }
+    }
+  }
+  return structuredClone(selected);
+};
+
+/** Route live-state admission; workflow and resource lifecycle guards belong to callers. */
+export const routeAllowsSnapshotReplay = (
+  route: SessionProviderRoute | undefined,
+  operation: string,
+): boolean => {
+  const descriptor = route?.capabilities.get(operation);
+  if (descriptor !== undefined) return descriptor.cachePolicy !== "live";
+  return ![...(route?.capabilities.values() ?? [])].some(
+    ({ cachePolicy }) => cachePolicy === "live",
+  );
+};
diff --git a/src/application/binaryDiagnostics/ElfNotesService.test.ts b/src/application/binaryDiagnostics/ElfNotesService.test.ts
index da0d0e67f9..1b51f0c763 100644
--- a/src/application/binaryDiagnostics/ElfNotesService.test.ts
+++ b/src/application/binaryDiagnostics/ElfNotesService.test.ts
@@ -6,6 +6,7 @@ import { ok } from "../../domain/result.js";
 import {
   AnalysisCancelledError,
   AnalysisInputError,
+  AnalysisOutputError,
 } from "../../domain/analysisErrorCore.js";
 
 const identity = ELF_NOTES_PROVIDER;
@@ -61,6 +62,51 @@ it("records the selected ELF and an absent note table", async () => {
   });
 });
 
+it.each(["examined_tables", "examined_entries"] as const)(
+  "rejects complete provider coverage with inconsistent %s",
+  async (count) => {
+    const result = await new ElfNotesService({
+      identity,
+      inspect: () =>
+        Promise.resolve(
+          ok({
+            ...report,
+            coverage: { ...report.coverage, [count]: 1 },
+          }),
+        ),
+    }).inspect({ path: report.artifact.path });
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
+  },
+);
+
+it("retains an examined empty table and rejects an uncounted table", async () => {
+  const table = {
+    kind: "program" as const,
+    index: 0,
+    name: null,
+    location: { offset: 128, bytes: 0 },
+    alignment: 4,
+  };
+  for (const examinedTables of [0, 1]) {
+    const result = await new ElfNotesService({
+      identity,
+      inspect: () =>
+        Promise.resolve(
+          ok({
+            ...report,
+            tables: [table],
+            coverage: { ...report.coverage, examined_tables: examinedTables },
+          }),
+        ),
+    }).inspect({ path: report.artifact.path });
+    expect(result.ok).toBe(examinedTables === 1);
+    if (result.ok)
+      expect(result.value.normalized_result).toMatchObject({ tables: [table] });
+    else expect(result.error).toBeInstanceOf(AnalysisOutputError);
+  }
+});
+
 it("discards an observation if the caller cancels while the provider completes", async () => {
   const controller = new AbortController();
   const service = new ElfNotesService({
diff --git a/src/application/binaryDiagnostics/ElfNotesService.ts b/src/application/binaryDiagnostics/ElfNotesService.ts
index 2640172e23..47c9468af2 100644
--- a/src/application/binaryDiagnostics/ElfNotesService.ts
+++ b/src/application/binaryDiagnostics/ElfNotesService.ts
@@ -112,7 +112,8 @@ const validReport = (
   if (
     report.artifact.bytes > maxBytes ||
     report.coverage.examined_entries > maxEntries ||
-    report.entries.length > maxEntries
+    report.entries.length !== report.coverage.examined_entries ||
+    report.tables.length !== report.coverage.examined_tables
   )
     return false;
   return (
diff --git a/src/application/binaryDiagnostics/PeCertificatesService.test.ts b/src/application/binaryDiagnostics/PeCertificatesService.test.ts
index 5c0a3afc81..8ee83c487a 100644
--- a/src/application/binaryDiagnostics/PeCertificatesService.test.ts
+++ b/src/application/binaryDiagnostics/PeCertificatesService.test.ts
@@ -1,3 +1,7 @@
+import { createHash } from "node:crypto";
+import { inspectPeCertificatesInputSchema } from "../../domain/native/peCertificates.js";
+import { peCertificateFixture } from "../../native/pe/PeCertificates.fixture.js";
+import { parsePeCertificates } from "../../native/pe/PeCertificateParser.js";
 import { expect, it } from "vitest";
 import { PeCertificatesService } from "./PeCertificatesService.js";
 import { PE_CERTIFICATES_PROVIDER } from "../../domain/investigationProviders.js";
@@ -6,6 +10,7 @@ import { ok } from "../../domain/result.js";
 import {
   AnalysisCancelledError,
   AnalysisInputError,
+  AnalysisOutputError,
 } from "../../domain/analysisErrorCore.js";
 
 const identity = PE_CERTIFICATES_PROVIDER;
@@ -74,3 +79,81 @@ it("discards an observation if the caller cancels while the provider completes",
   expect(result.ok).toBe(false);
   if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
 });
+
+it("retains complete certificate provenance from the production parser", async () => {
+  const parsed = parsedCertificateReport();
+  const result = await new PeCertificatesService({
+    identity,
+    inspect: () => Promise.resolve(ok(parsed)),
+  }).inspect({ path: parsed.artifact.path });
+  expect(result.ok).toBe(true);
+  if (!result.ok) throw result.error;
+  expect(result.value.normalized_result).toEqual(parsed);
+});
+
+it.each([
+  [
+    "invented examined entry",
+    (value: PeCertificates) => {
+      value.coverage.examined_entries += 1;
+    },
+  ],
+  [
+    "entries without a table",
+    (value: PeCertificates) => {
+      value.table = null;
+    },
+  ],
+  [
+    "data directory outside the artifact",
+    (value: PeCertificates) => {
+      value.table!.data_directory_location.offset = value.artifact.bytes;
+    },
+  ],
+  [
+    "entry outside its table",
+    (value: PeCertificates) => {
+      value.entries[0]!.location.offset = 0;
+    },
+  ],
+  [
+    "payload outside its entry",
+    (value: PeCertificates) => {
+      value.entries[0]!.payload.location.offset = 0;
+    },
+  ],
+  [
+    "certificate outside its payload",
+    (value: PeCertificates) => {
+      value.entries[0]!.pkcs7!.certificates[0]!.location.offset = 0;
+    },
+  ],
+] as const)(
+  "rejects %s before creating observed Evidence",
+  async (_label, mutate) => {
+    const parsed = parsedCertificateReport();
+    mutate(parsed);
+    const result = await new PeCertificatesService({
+      identity,
+      inspect: () => Promise.resolve(ok(parsed)),
+    }).inspect({ path: parsed.artifact.path });
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
+  },
+);
+
+function parsedCertificateReport(): PeCertificates {
+  const bytes = peCertificateFixture(false);
+  const input = inspectPeCertificatesInputSchema.parse({
+    path: report.artifact.path,
+  });
+  return parsePeCertificates(
+    bytes,
+    {
+      path: input.path,
+      sha256: createHash("sha256").update(bytes).digest("hex"),
+      bytes: bytes.length,
+    },
+    input,
+  );
+}
diff --git a/src/application/binaryDiagnostics/PeCertificatesService.ts b/src/application/binaryDiagnostics/PeCertificatesService.ts
index 0868dd6fac..b5abebe206 100644
--- a/src/application/binaryDiagnostics/PeCertificatesService.ts
+++ b/src/application/binaryDiagnostics/PeCertificatesService.ts
@@ -1,11 +1,7 @@
-import { isAbsolute } from "node:path";
 import type { ExecutionOptions } from "../AnalysisProvider.js";
 import type { PeCertificatesPort } from "./PeCertificatesPort.js";
 import type { AnalysisError } from "../../domain/analysisErrorBase.js";
-import {
-  AnalysisInputError,
-  AnalysisOutputError,
-} from "../../domain/analysisErrorCore.js";
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
 import { createEvidence, type Evidence } from "../../domain/evidence.js";
 import {
   inspectPeCertificatesInputSchema,
@@ -13,7 +9,7 @@ import {
   type PeCertificates,
 } from "../../domain/native/peCertificates.js";
 import { peSubjectArchitecture } from "../../domain/peMachine.js";
-import { err, ok, type Result } from "../../domain/result.js";
+import { err, type Result } from "../../domain/result.js";
 import { completeProviderEvidence } from "../ProviderOperationExecution.js";
 import { parseAnalysisInput } from "../AnalysisInput.js";
 
@@ -38,16 +34,6 @@ export class PeCertificatesService {
       options?.signal,
     );
     if (!input.ok) return input;
-    if (!isAbsolute(input.value.path))
-      return err(
-        new AnalysisInputError(OPERATION, undefined, [
-          {
-            path: ["path"],
-            reason: "invalid_format",
-            message: "Expected an absolute filesystem path on this host.",
-          },
-        ]),
-      );
     const result = await this.provider.inspect(input.value, options);
     if (!result.ok) return result;
     const report = peCertificatesSchema.safeParse(result.value);
@@ -113,16 +99,34 @@ const validReport = (
   if (
     report.artifact.bytes > maxBytes ||
     report.coverage.examined_entries > maxEntries ||
+    report.coverage.examined_entries !== report.entries.length ||
     report.coverage.certificates !==
       report.entries.reduce(
         (count, entry) => count + (entry.pkcs7?.certificates.length ?? 0),
         0,
-      ) ||
-    report.entries.length > maxEntries
+      )
   )
     return false;
-  if (report.table !== null && !inside(report.table.location)) return false;
+  if (report.table === null) return report.entries.length === 0;
+  if (
+    !inside(report.table.location) ||
+    !inside(report.table.data_directory_location)
+  )
+    return false;
+  const table = report.table;
+  const contains = (
+    outer: { offset: number; bytes: number },
+    inner: { offset: number; bytes: number },
+  ) =>
+    inner.offset >= outer.offset &&
+    inner.offset - outer.offset <= outer.bytes - inner.bytes;
   return report.entries.every(
-    (entry) => inside(entry.location) && inside(entry.payload.location),
+    (entry) =>
+      contains(table.location, entry.location) &&
+      contains(entry.location, entry.payload.location) &&
+      (entry.pkcs7?.certificates.every((certificate) =>
+        contains(entry.payload.location, certificate.location),
+      ) ??
+        true),
   );
 };
diff --git a/src/application/binaryDiagnostics/PeImportsService.ts b/src/application/binaryDiagnostics/PeImportsService.ts
index 299bfa4fad..60bf227c6c 100644
--- a/src/application/binaryDiagnostics/PeImportsService.ts
+++ b/src/application/binaryDiagnostics/PeImportsService.ts
@@ -1,11 +1,7 @@
-import { isAbsolute } from "node:path";
 import type { ExecutionOptions } from "../AnalysisProvider.js";
 import type { PeImportsPort } from "./PeImportsPort.js";
 import type { AnalysisError } from "../../domain/analysisErrorBase.js";
-import {
-  AnalysisInputError,
-  AnalysisOutputError,
-} from "../../domain/analysisErrorCore.js";
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
 import { createEvidence, type Evidence } from "../../domain/evidence.js";
 import {
   inspectPeImportsInputSchema,
@@ -38,16 +34,6 @@ export class PeImportsService {
       options?.signal,
     );
     if (!input.ok) return input;
-    if (!isAbsolute(input.value.path))
-      return err(
-        new AnalysisInputError(OPERATION, undefined, [
-          {
-            path: ["path"],
-            reason: "invalid_format",
-            message: "Expected an absolute filesystem path on this host.",
-          },
-        ]),
-      );
     const result = await this.provider.inspect(input.value, options);
     if (!result.ok) return result;
     const report = peImportsSchema.safeParse(result.value);
diff --git a/src/application/binaryDiagnostics/PeLoadConfigService.ts b/src/application/binaryDiagnostics/PeLoadConfigService.ts
index 4db23b6f69..0e18362d5d 100644
--- a/src/application/binaryDiagnostics/PeLoadConfigService.ts
+++ b/src/application/binaryDiagnostics/PeLoadConfigService.ts
@@ -1,11 +1,7 @@
-import { isAbsolute } from "node:path";
 import type { ExecutionOptions } from "../AnalysisProvider.js";
 import type { PeLoadConfigPort } from "./PeLoadConfigPort.js";
 import type { AnalysisError } from "../../domain/analysisErrorBase.js";
-import {
-  AnalysisInputError,
-  AnalysisOutputError,
-} from "../../domain/analysisErrorCore.js";
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
 import { createEvidence, type Evidence } from "../../domain/evidence.js";
 import {
   inspectPeLoadConfigInputSchema,
@@ -38,16 +34,6 @@ export class PeLoadConfigService {
       options?.signal,
     );
     if (!input.ok) return input;
-    if (!isAbsolute(input.value.path))
-      return err(
-        new AnalysisInputError(OPERATION, undefined, [
-          {
-            path: ["path"],
-            reason: "invalid_format",
-            message: "Expected an absolute filesystem path on this host.",
-          },
-        ]),
-      );
     const result = await this.provider.inspect(input.value, options);
     if (!result.ok) return result;
     const report = peLoadConfigSchema.safeParse(result.value);
diff --git a/src/application/decompileDirectory/exportResources.test.ts b/src/application/decompileDirectory/exportResources.test.ts
new file mode 100644
index 0000000000..36e83e7763
--- /dev/null
+++ b/src/application/decompileDirectory/exportResources.test.ts
@@ -0,0 +1,60 @@
+import { lstat, readFile, readdir, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it, vi } from "vitest";
+
+import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
+import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
+import { OwnedStagedOutput } from "../../filesystem/OwnedStagedOutput.js";
+import { ExportResources } from "./exportResources.js";
+
+it.each([false, true])(
+  "retains publication outcome and staging cleanup authority (conflict: %s)",
+  async (conflict) => {
+    const root = await createTestTempDirectory("rea-export-resources-");
+    const destination = join(root, "published");
+    if (conflict) await writeFile(destination, "existing");
+    const lease = { release: vi.fn(async () => undefined) };
+    const resources = new ExportResources(
+      root,
+      await lstat(root, { bigint: true }),
+      lease,
+      undefined,
+    );
+    const cleanupFailure = new Error("staging removal denied");
+    const close = vi.spyOn(OwnedStagedOutput.prototype, "close");
+    close.mockRejectedValueOnce(cleanupFailure);
+    try {
+      const cause: unknown = await resources
+        .publish("new", destination, "link")
+        .catch((failure: unknown) => failure);
+      expect(cause).toBeInstanceOf(ArtifactReaderFailure);
+      const failure = cause as ArtifactReaderFailure;
+      expect(failure.cleanup?.reason).toContain("staging removal denied");
+      expect(failure.cleanupLeases).toHaveLength(1);
+      if (conflict) {
+        expect(failure.message).toContain("EEXIST");
+        expect(failure.cause).toBeInstanceOf(ArtifactReaderFailure);
+      } else expect(failure.message).toBe("staging removal denied");
+      expect(await readFile(destination, "utf8")).toBe(
+        conflict ? "existing" : "new",
+      );
+      expect(
+        (await readdir(root)).some((name) =>
+          name.startsWith(".decompile-stage-"),
+        ),
+      ).toBe(true);
+      expect(lease.release).not.toHaveBeenCalled();
+      await expect(resources.close()).resolves.toEqual({
+        ok: true,
+        value: undefined,
+      });
+      expect(await readdir(root)).toEqual(["published"]);
+      expect(failure.cleanupLeases?.[0]?.released).toBe(true);
+      expect(lease.release).toHaveBeenCalledOnce();
+    } finally {
+      close.mockRestore();
+      await resources.close();
+      await rm(root, { recursive: true, force: true });
+    }
+  },
+);
diff --git a/src/application/decompileDirectory/exportResources.ts b/src/application/decompileDirectory/exportResources.ts
index 2d83f38548..77474e4b0e 100644
--- a/src/application/decompileDirectory/exportResources.ts
+++ b/src/application/decompileDirectory/exportResources.ts
@@ -123,6 +123,11 @@ export class ExportResources {
       output: staging,
     };
     this.#files.retain(owner);
+    let outcome:
+      | { readonly kind: "completed" }
+      | { readonly kind: "failed"; readonly cause: unknown } = {
+      kind: "completed",
+    };
     try {
       const path = join(directory, "content");
       const handle = await open(path, "wx", 0o600);
@@ -136,10 +141,13 @@ export class ExportResources {
         await rename(path, destination);
         staging.recordPublishedRename();
       }
-    } finally {
-      const released = await this.#files.release(owner);
-      if (released.kind === "failed") throw released.cause;
+    } catch (cause: unknown) {
+      outcome = { kind: "failed", cause };
     }
+    const released = await this.#files.release(owner);
+    if (released.kind === "failed")
+      throw this.#files.cleanupFailure(outcome, [released]);
+    if (outcome.kind === "failed") throw outcome.cause;
   }
 
   /** Keep the lease through all initialization cleanup, including any retry. */
diff --git a/src/application/decompileDirectory/exportTree.ts b/src/application/decompileDirectory/exportTree.ts
index d6e2b6a259..8f14b997d7 100644
--- a/src/application/decompileDirectory/exportTree.ts
+++ b/src/application/decompileDirectory/exportTree.ts
@@ -10,6 +10,11 @@ import {
   type FileHandle,
 } from "node:fs/promises";
 import { basename, dirname, join } from "node:path";
+import {
+  ArtifactFileCleanupFailure,
+  bindArtifactFileCleanup,
+} from "../../artifacts/ArtifactFileOperation.js";
+import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
 
 import {
   DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY,
@@ -33,6 +38,7 @@ import { isPathWithinRoot } from "../../domain/localPath.js";
 import { err, ok, type Result } from "../../domain/result.js";
 import {
   acquireOwnedLease,
+  OwnedLeaseCleanupFailure,
   type LeaseFailure,
   type OwnedLease,
 } from "../../process/OwnedLease.js";
@@ -249,7 +255,7 @@ export class DecompileExport {
     if (!created.ok) return created;
     // The directory exists before its journal, so a resume that starts in
     // between takes the same lease and cannot write alongside this export.
-    const lease = await acquireExportLease(root);
+    const lease = await acquireExportLease(root, scope);
     // Even an empty directory may now have another lease holder preparing its
     // journal. Without the lease, preserve it rather than disrupting that run.
     if (!lease.ok) {
@@ -286,7 +292,7 @@ export class DecompileExport {
   ): Promise> {
     // The lease is held before the journal is read, so no other run appends
     // between this read and this run's own appends, repair or index.
-    const lease = await acquireExportLease(root);
+    const lease = await acquireExportLease(root, scope);
     if (!lease.ok) return lease;
     const resources = new ExportResources(
       root,
@@ -805,6 +811,7 @@ const DECOMPILE_EXPORT_LEASE_DIRECTORY = join(
  */
 const acquireExportLease = async (
   root: string,
+  scope: RetainedResourceScope | undefined,
 ): Promise<
   Result<
     {
@@ -824,6 +831,7 @@ const acquireExportLease = async (
         "The output directory could not be inspected.",
       ),
     );
+  const leaseResources = new ArtifactResourceScope();
   try {
     const acquisition = await acquireOwnedLease({
       identity: {
@@ -834,6 +842,7 @@ const acquireExportLease = async (
       runId: randomUUID(),
       directory: DECOMPILE_EXPORT_LEASE_DIRECTORY,
       failure: exportLeaseFailure,
+      resources: leaseResources,
     });
     return acquisition.acquired
       ? ok({ lease: acquisition.lease, identity: stat })
@@ -851,6 +860,16 @@ const acquireExportLease = async (
           ),
         );
   } catch (cause: unknown) {
+    if (leaseResources.hasPending)
+      scope?.retain(
+        {
+          close: () => leaseResources.close(),
+          get cleanupResources() {
+            return leaseResources.cleanupResources;
+          },
+        },
+        "decompile lease directory",
+      );
     return err(
       cause instanceof AnalysisError
         ? cause
@@ -866,15 +885,33 @@ const acquireExportLease = async (
 const exportLeaseFailure = (
   failure: LeaseFailure,
   cause?: unknown,
-): AnalysisOutputError =>
-  new AnalysisOutputError(
+): AnalysisOutputError => {
+  const primary =
+    cause instanceof OwnedLeaseCleanupFailure
+      ? cause.primary
+      : cause instanceof ArtifactFileCleanupFailure &&
+          cause.fileCleanupFailure.outcome.kind === "failed"
+        ? cause.fileCleanupFailure.outcome.cause
+        : undefined;
+  const error = new AnalysisOutputError(
     "decompile_all",
     "output directory lease could not be acquired",
     {
       ...(cause === undefined ? {} : { cause }),
-      userMessage: exportLeaseMessage(failure),
+      ...(primary instanceof AnalysisError ? { cleanupPrimary: primary } : {}),
+      ...(cause instanceof ArtifactFileCleanupFailure ||
+      cause instanceof OwnedLeaseCleanupFailure
+        ? { cleanup: cause.cleanup }
+        : {}),
+      userMessage:
+        primary instanceof AnalysisError
+          ? (primary.userMessage ?? exportLeaseMessage(failure))
+          : exportLeaseMessage(failure),
     },
   );
+  bindArtifactFileCleanup(cause, error);
+  return error;
+};
 
 const exportLeaseMessage = (failure: LeaseFailure): string => {
   switch (failure.kind) {
@@ -886,6 +923,10 @@ const exportLeaseMessage = (failure: LeaseFailure): string => {
       return `REA could not remove its lease recovery directory at ${JSON.stringify(failure.path)}. Check its write access before retrying.`;
     case "unrecoverable":
       return "REA could not take this output directory's lease. Retry after other decompile_all runs finish.";
+    case "socket_permissions":
+      return "REA could not secure its lease socket permissions.";
+    case "directory_close":
+      return "REA could not confirm closure of its output lease directory handle.";
     case "directory_access_denied":
       return `REA could not access its lease directory at ${JSON.stringify(failure.directory)}. Restore owner-only permissions (0700) on a directory you own, then retry.`;
     case "directory_create":
diff --git a/src/application/environment/environmentBuild.ts b/src/application/environment/environmentBuild.ts
index 097e7663f2..130bcc32ab 100644
--- a/src/application/environment/environmentBuild.ts
+++ b/src/application/environment/environmentBuild.ts
@@ -25,6 +25,7 @@ import {
 import {
   anonymousOrigin,
   credentialScanWarnings,
+  savedCredentialWarnings,
   nameFromCaptureFile,
   type EnvironmentBuildWarning,
 } from "./environmentBuildReport.js";
@@ -317,7 +318,10 @@ export const buildEnvironment = async (
     name,
     operation: ENVIRONMENT_EXPORT_OPERATION,
   });
-  warnings.push(...credentialScanWarnings(recorded.value));
+  warnings.push(
+    ...credentialScanWarnings(recorded.value),
+    ...savedCredentialWarnings(capture.parameters),
+  );
 
   const packageSource: EnvironmentPackageSource =
     input.packageTarball === undefined
diff --git a/src/application/environment/environmentBuildReport.test.ts b/src/application/environment/environmentBuildReport.test.ts
index c0153fed20..774fa36697 100644
--- a/src/application/environment/environmentBuildReport.test.ts
+++ b/src/application/environment/environmentBuildReport.test.ts
@@ -6,6 +6,7 @@ import {
   anonymousOrigin,
   credentialNamed,
   nameFromCaptureFile,
+  savedCredentialWarnings,
   scanCredentialNames,
   scanScenarioCredentialNames,
 } from "./environmentBuildReport.js";
@@ -455,3 +456,54 @@ it.each([
     });
   },
 );
+
+describe("savedCredentialWarnings", () => {
+  const entry = (name: string, secret?: boolean) => ({
+    name,
+    json_pointer: "/x",
+    ...(secret === undefined ? {} : { secret }),
+  });
+
+  it("points at saved values named like credentials that are not marked secret", () => {
+    expect(
+      savedCredentialWarnings({
+        actions: [
+          { action: "goto" },
+          {
+            action: "request",
+            save: [
+              entry("item_id"),
+              entry("access_token"),
+              entry("api_key", true),
+            ],
+          },
+          {
+            action: "click",
+            save_response: { save: [entry("password", false), entry("list")] },
+          },
+        ],
+      }),
+    ).toEqual([
+      {
+        code: "credential-named-saved-values",
+        message: expect.stringContaining("2 saved values"),
+        pointers: [
+          "/parameters/actions/1/save/1",
+          "/parameters/actions/2/save_response/save/0",
+        ],
+      },
+    ]);
+  });
+
+  it("warns about nothing when every credential-named value is secret, or the parameters are not a scenario", () => {
+    expect(
+      savedCredentialWarnings({
+        actions: [
+          { action: "request", save: [entry("token", true), entry("id")] },
+        ],
+      }),
+    ).toEqual([]);
+    expect(savedCredentialWarnings({ operation: "har" })).toEqual([]);
+    expect(savedCredentialWarnings(undefined)).toEqual([]);
+  });
+});
diff --git a/src/application/environment/environmentBuildReport.ts b/src/application/environment/environmentBuildReport.ts
index 8e8750ebdc..c183968231 100644
--- a/src/application/environment/environmentBuildReport.ts
+++ b/src/application/environment/environmentBuildReport.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
 import { basename } from "node:path";
 import { isIP } from "node:net";
 
@@ -332,6 +333,57 @@ export const credentialScanWarnings = (capture: {
   return warnings;
 };
 
+const savedEntries = z.array(
+  z.object({ name: z.string(), secret: z.boolean().optional() }),
+);
+const scenarioSaves = z.object({
+  actions: z.array(
+    z
+      .object({
+        save: savedEntries.optional(),
+        save_response: z.object({ save: savedEntries }).optional(),
+      })
+      .loose(),
+  ),
+});
+
+/** A browser scenario's saved values named like credentials but not marked secret, which the capture records unredacted. */
+export const savedCredentialWarnings = (
+  parameters: unknown,
+): EnvironmentBuildWarning[] => {
+  const parsed = scenarioSaves.safeParse(parameters);
+  if (!parsed.success) return [];
+  const pointers: string[] = [];
+  parsed.data.actions.forEach((action, index) => {
+    const unmarked = (
+      entries:
+        | readonly {
+            readonly name: string;
+            readonly secret?: boolean | undefined;
+          }[]
+        | undefined,
+      at: string,
+    ) =>
+      entries?.forEach((entry, position) => {
+        if (entry.secret !== true && credentialNamed(entry.name))
+          pointers.push(
+            `/parameters/actions/${String(index)}/${at}/${String(position)}`,
+          );
+      });
+    unmarked(action.save, "save");
+    unmarked(action.save_response?.save, "save_response/save");
+  });
+  return pointers.length === 0
+    ? []
+    : [
+        {
+          code: "credential-named-saved-values",
+          message: `${String(pointers.length)} saved values have credential-like names but are not marked secret, so the capture records them unredacted; mark them \`secret: true\` and record again before sharing.`,
+          pointers: pointers.slice(0, REPORTED_POINTERS),
+        },
+      ];
+};
+
 /** Whether an origin's host is a loopback name or an IP literal, which says nothing about the application. */
 export const anonymousOrigin = (origin: string): boolean => {
   let host: string;
diff --git a/src/application/environment/environmentStateAssignedCoverage.test.ts b/src/application/environment/environmentStateAssignedCoverage.test.ts
index cb4e8bbfe0..9135f19391 100644
--- a/src/application/environment/environmentStateAssignedCoverage.test.ts
+++ b/src/application/environment/environmentStateAssignedCoverage.test.ts
@@ -4,24 +4,45 @@ import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js";
 const uuid = (n: number): string =>
   `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
 
+/** Two held books and a create whose `mixed` joins the generated id and the sent slug; the held books put `heldPrefix` before theirs. */
+const slugBooks = (heldPrefix = "") => {
+  const item = (id: number, slug: string, prefix = "") => ({
+    id,
+    code: slug,
+    url: `/${slug}`,
+    mixed: `/${id}/${prefix}${slug}`,
+  });
+  const first = item(1, "first-slug", heldPrefix);
+  const second = item(2, "second-slug", heldPrefix);
+  const third = item(3, "abcdefgh");
+  return servedFrom([
+    ["GET", "/api/books", [first, second]],
+    ["GET", "/api/books/1", first],
+    ["GET", "/api/books/2", second],
+    ["POST", "/api/books", third, 201, { customSlug: "abcdefgh" }],
+    ["GET", "/api/books/3", third],
+  ]);
+};
+
 describe("assigned values preserve every recorded relation", () => {
-  it("freezes a copied member when another template would retain its old value", () => {
-    const item = (id: number, slug: string) => ({
-      id,
-      code: slug,
-      url: `/${slug}`,
-      mixed: `/${id}/${slug}`,
+  it("rebuilds a field a generated id and a copied member compose, accepting any value of the member", () => {
+    const books = slugBooks();
+    expect(books.state.writes.get("post_books")).toMatchObject({
+      served: true,
+      body: { fixed: [] },
     });
-    const first = item(1, "first-slug");
-    const second = item(2, "second-slug");
-    const third = item(3, "abcdefgh");
-    const books = servedFrom([
-      ["GET", "/api/books", [first, second]],
-      ["GET", "/api/books/1", first],
-      ["GET", "/api/books/2", second],
-      ["POST", "/api/books", third, 201, { customSlug: "abcdefgh" }],
-      ["GET", "/api/books/3", third],
-    ]);
+    expect(
+      books.answer("post_books", { body: { customSlug: "ijklmnop" } }).body,
+    ).toMatchObject({
+      code: "ijklmnop",
+      url: "/ijklmnop",
+      mixed: expect.stringMatching(/^\/\d+\/ijklmnop$/u),
+    });
+  });
+
+  it("freezes a copied member when another template would retain its old value", () => {
+    // The held books spell `mixed` otherwise, so no composition explains it and a template around the id keeps the slug.
+    const books = slugBooks("x-");
     expect(books.state.writes.get("post_books")).toMatchObject({
       served: true,
       body: { fixed: [{ member: "customSlug", sent: "abcdefgh" }] },
diff --git a/src/application/investigation/EvidenceLedger.residualUnknown.test.ts b/src/application/investigation/EvidenceLedger.residualUnknown.test.ts
index 229b868044..efed5ceb10 100644
--- a/src/application/investigation/EvidenceLedger.residualUnknown.test.ts
+++ b/src/application/investigation/EvidenceLedger.residualUnknown.test.ts
@@ -4,7 +4,10 @@ import canonicalize from "canonicalize";
 import { describe, expect, it } from "vitest";
 
 import { EvidenceLedger } from "./EvidenceLedger.js";
-import { createEvidence } from "../../domain/evidence.js";
+import {
+  createEvidence,
+  withEvidenceCallerInputPath,
+} from "../../domain/evidence.js";
 import {
   createEvidenceBundle,
   parseEvidenceBundle,
@@ -405,8 +408,20 @@ describe("residual unknown registry resolution and history", () => {
     const base = ledger();
     const created = base.recordUnknown(input("Divergent?"), mutation("base"));
     if (!created.ok) throw created.error;
+    const source = withEvidenceCallerInputPath(
+      evidence("shared-source"),
+      "./original",
+    );
+    expect(base.record(source).ok).toBe(true);
     const branch = ledger();
     expect(branch.import(base.export()).ok).toBe(true);
+    expect(
+      branch.import(
+        createEvidenceBundle([
+          withEvidenceCallerInputPath(source, "./relocated"),
+        ]),
+      ).ok,
+    ).toBe(true);
     expect(
       base.updateUnknown(update(created.value), mutation("branch-left")).ok,
     ).toBe(true);
@@ -418,6 +433,7 @@ describe("residual unknown registry resolution and history", () => {
     ).toBe(true);
     const before = serializeEvidenceBundle(base.export());
     expect(base.import(branch.export()).ok).toBe(false);
+    expect(base.get(source.evidence_id)?.caller_input_path).toBe("./original");
     expect(serializeEvidenceBundle(base.export())).toBe(before);
   });
 });
diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts
index 3b3bb4cfc7..f4747b6b62 100644
--- a/src/application/investigation/EvidenceLedger.ts
+++ b/src/application/investigation/EvidenceLedger.ts
@@ -100,55 +100,43 @@ export class EvidenceLedger {
       );
     }
     const bundle = parsed.value;
-    // A record-only bundle cannot change the already-valid unknown graph. The
-    // bundle parser has validated every incoming record and its manifests, so
-    // checking ID conflicts and committing only the additions preserves the
-    // atomic merge without rebuilding and reparsing the entire ledger.
-    if (bundle.unknowns.length === 0) {
-      const additions = new Map();
-      const replacements: EvidenceMetadataReplacement[] = [];
-      for (const evidence of bundle.records) {
-        const existing = this.#records.get(evidence.evidence_id);
-        if (existing !== undefined) {
-          if (!recordsAgree(existing, evidence))
-            return err(
-              new EvidenceIntegrityError("Conflicting evidence record"),
-            );
-          if (!displayMetadataAgree(existing, evidence)) {
-            replacements.push({
-              kind: "metadata",
-              previous: existing,
-              current: evidence,
-            });
-          }
-          continue;
-        }
-        additions.set(evidence.evidence_id, evidence);
-      }
-      for (const { current } of replacements)
-        this.#records.set(current.evidence_id, current);
-      for (const [id, evidence] of additions) this.#records.set(id, evidence);
-      return ok({
-        recordsAdded: additions.size,
-        unknownsAdded: 0,
-        changed: additions.size > 0 || replacements.length > 0,
-        replacements,
-      });
-    }
-    const pending = new Map(this.#records);
+    // Stage record conflicts and display metadata once, before either commit
+    // path. Unknown history additionally requires validating the merged graph.
+    const recordChanges = new Map();
+    let recordsAdded = 0;
     const replacements: EvidenceMetadataReplacement[] = [];
     for (const evidence of bundle.records) {
-      const existing = pending.get(evidence.evidence_id);
-      if (existing !== undefined && !recordsAgree(existing, evidence))
+      const existing = this.#records.get(evidence.evidence_id);
+      if (existing === undefined) {
+        recordChanges.set(evidence.evidence_id, evidence);
+        recordsAdded += 1;
+        continue;
+      }
+      if (!recordsAgree(existing, evidence))
         return err(new EvidenceIntegrityError("Conflicting evidence record"));
-      if (existing !== undefined && !displayMetadataAgree(existing, evidence))
+      if (!displayMetadataAgree(existing, evidence)) {
+        recordChanges.set(evidence.evidence_id, evidence);
         replacements.push({
           kind: "metadata",
           previous: existing,
           current: evidence,
         });
-      pending.set(evidence.evidence_id, evidence);
+      }
     }
+    // A record-only bundle cannot change the already-valid unknown graph.
+    if (bundle.unknowns.length === 0) {
+      for (const evidence of recordChanges.values())
+        this.#records.set(evidence.evidence_id, evidence);
+      return ok({
+        recordsAdded,
+        unknownsAdded: 0,
+        changed: recordChanges.size > 0,
+        replacements,
+      });
+    }
+    const pending = new Map(this.#records);
+    for (const evidence of recordChanges.values())
+      pending.set(evidence.evidence_id, evidence);
     const pendingUnknowns = new Map(this.#unknownRevisions);
     let unknownsAdded = 0;
     for (const unknown of bundle.unknowns) {
@@ -170,12 +158,11 @@ export class EvidenceLedger {
         }),
       );
     }
-    const added = pending.size - this.#records.size;
     this.#commit(checked.value);
     return ok({
-      recordsAdded: added,
+      recordsAdded,
       unknownsAdded,
-      changed: added > 0 || unknownsAdded > 0 || replacements.length > 0,
+      changed: recordChanges.size > 0 || unknownsAdded > 0,
       replacements,
     });
   }
diff --git a/src/application/investigation/InvestigationRecordPort.ts b/src/application/investigation/InvestigationRecordPort.ts
index c7c8cf6a98..0d1daf8b6f 100644
--- a/src/application/investigation/InvestigationRecordPort.ts
+++ b/src/application/investigation/InvestigationRecordPort.ts
@@ -77,6 +77,13 @@ export interface UnknownRegistryPort {
   >;
 }
 
+/** Counts from the committed merge, before adapter response projection. */
+export interface EvidenceImportReceipt {
+  readonly recordsAdded: number;
+  readonly unknownsAdded: number;
+  readonly totalRecords: number;
+}
+
 /** Investigation record surface; binary execution and snapshots remain separate. */
 export interface InvestigationRecordPort
   extends
@@ -86,5 +93,5 @@ export interface InvestigationRecordPort
     UnknownRegistryPort {
   importEvidenceBundle(
     bundle: unknown,
-  ): Result;
+  ): Result;
 }
diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts
index d5d6ab5962..67f4615b01 100644
--- a/src/application/javascript/ElectronBoundaryAnalysis.ts
+++ b/src/application/javascript/ElectronBoundaryAnalysis.ts
@@ -43,49 +43,72 @@ export const collectElectronIpcRecords = (
     )
     .sort((left, right) => compareUnicodeCodePoints(left.key, right.key));
 
-/** Match only exact literal channels and retain ambiguous matches separately. */
-export const classifyElectronIpcPairings = (
-  records: readonly ElectronIpcRecord[],
-): readonly ElectronIpcPairingState[] => {
-  const handlers = records.filter(isMainHandler);
-  return records.filter(isPairableRendererTransmission).map((transmission) => {
-    const candidates =
-      transmission.finding.channel === null
-        ? []
-        : handlers.filter(
-            (handler) =>
-              (handler.finding.channel === transmission.finding.channel ||
-                (handler.finding.receiver !== undefined &&
-                  handler.finding.channel === null)) &&
-              compatibleIpcModes(transmission.finding, handler.finding),
-          );
-    return {
-      transmission,
-      candidates,
-      status:
-        candidates.length === 1 && candidates[0]?.finding.receiver === undefined
-          ? "paired"
-          : candidates.length > 0
-            ? "ambiguous"
-            : "unpaired",
-    };
-  });
-};
+/** One interpretation shared by summary and every graph pairing consumer. */
+export interface ElectronIpcContext {
+  readonly records: readonly ElectronIpcRecord[];
+  readonly owners: ReadonlyMap;
+  readonly states: readonly ElectronIpcPairingState[];
+  readonly pairings: readonly ElectronIpcPairing[];
+}
 
-/** Return only pairings supported by one unique literal main handler. */
-export const unambiguousElectronIpcPairings = (
-  records: readonly ElectronIpcRecord[],
-): readonly ElectronIpcPairing[] =>
-  classifyElectronIpcPairings(records).flatMap((state) => {
+/** Equal channel text only establishes a pairing within one proven application owner. */
+export const createElectronIpcContext = (
+  analysis: JavaScriptArtifactAnalysis,
+  applicationOwner: (file: JavaScriptArtifactFile) => string | null,
+): ElectronIpcContext => {
+  const records = collectElectronIpcRecords(analysis);
+  const owners = new Map(
+    records.map((record) => [record.key, applicationOwner(record.file)]),
+  );
+  const handlers = records.filter(isMainHandler);
+  const states: ElectronIpcPairingState[] = records
+    .filter(isPairableRendererTransmission)
+    .map((transmission) => {
+      const owner = owners.get(transmission.key);
+      const matching =
+        transmission.finding.channel === null
+          ? []
+          : handlers.filter(
+              (handler) =>
+                (handler.finding.channel === transmission.finding.channel ||
+                  (handler.finding.receiver !== undefined &&
+                    handler.finding.channel === null)) &&
+                compatibleIpcModes(transmission.finding, handler.finding),
+            );
+      const candidates = matching.filter(
+        (handler) => owners.get(handler.key) === owner,
+      );
+      // Unknown ownership can conceal competing dispatch contexts. Different owners
+      // do not prove runtime isolation, so retain ambiguity rather than absence.
+      const uncertain =
+        owner == null ||
+        matching.some((handler) => owners.get(handler.key) == null);
+      return {
+        transmission,
+        candidates,
+        status:
+          !uncertain &&
+          candidates.length === 1 &&
+          candidates[0]?.finding.receiver === undefined
+            ? "paired"
+            : uncertain || matching.length > 0
+              ? "ambiguous"
+              : "unpaired",
+      };
+    });
+  const pairings = states.flatMap((state) => {
     const handler = state.candidates[0];
     return state.status === "paired" && handler !== undefined
       ? [{ transmission: state.transmission, handler }]
       : [];
   });
+  return { records, owners, states, pairings };
+};
 
 /** Summarize static Electron findings without treating gaps as absence. */
 export const summarizeElectronBoundaries = (
   analysis: JavaScriptArtifactAnalysis,
+  ipcContext: ElectronIpcContext,
   directoryPaths?: ReadonlySet,
 ): ElectronBoundarySummary => {
   const files = new Map(analysis.files.map(({ file }) => [file.path, file]));
@@ -113,8 +136,8 @@ export const summarizeElectronBoundaries = (
       finding,
     })),
   );
-  const ipc = collectElectronIpcRecords(analysis);
-  const pairing = classifyElectronIpcPairings(ipc);
+  const ipc = ipcContext.records;
+  const pairing = ipcContext.states;
   const literalChannels = new Set(
     ipc.flatMap(({ finding }) =>
       finding.channel === null ? [] : [finding.channel],
diff --git a/src/application/javascript/ElectronBoundaryGraph.ts b/src/application/javascript/ElectronBoundaryGraph.ts
index d77e496fbe..752c3294f0 100644
--- a/src/application/javascript/ElectronBoundaryGraph.ts
+++ b/src/application/javascript/ElectronBoundaryGraph.ts
@@ -1,3 +1,4 @@
+import type { ElectronIpcContext } from "./ElectronBoundaryAnalysis.js";
 import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
 import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
 import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
@@ -7,9 +8,10 @@ import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js";
 /** Add every static Electron process and security-boundary fact to the JAG. */
 export const addElectronBoundaries = (
   context: JavaScriptArtifactGraphContext,
+  ipc: ElectronIpcContext,
 ): void => {
   addElectronWindowBoundaries(context);
-  addElectronIpcBoundaries(context);
+  addElectronIpcBoundaries(context, ipc);
   addElectronNativeBoundaries(context);
-  addElectronCallPaths(context);
+  addElectronCallPaths(context, ipc);
 };
diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts
index f835ea092a..efae2e4f00 100644
--- a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts
+++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts
@@ -3,10 +3,9 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicat
 import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
 import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
 import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
-import {
-  collectElectronIpcRecords,
-  unambiguousElectronIpcPairings,
-  type ElectronIpcRecord,
+import type {
+  ElectronIpcContext,
+  ElectronIpcRecord,
 } from "./ElectronBoundaryAnalysis.js";
 import {
   addElectronInferenceEdge,
@@ -30,13 +29,14 @@ interface ProjectedPath {
 /** Retain context-specific wrapper paths, then join only unique IPC records. */
 export const addElectronCallPaths = (
   context: JavaScriptArtifactGraphContext,
+  ipc: ElectronIpcContext,
 ): void => {
   const projected: ProjectedPath[] = [];
   for (const { file, javascript } of context.analysis.files) {
     if (javascript === null) continue;
     const coverage = javascriptAnalysisCoverage(javascript);
     for (const path of javascript.electron.call_paths ?? []) {
-      const pathId = sha256Text(JSON.stringify(path));
+      const pathId = sha256Text(JSON.stringify([file.path, path]));
       const nodes = path.steps.map((step, index) =>
         context.accumulator.addNode({
           kind: "javascript-expression",
@@ -137,9 +137,7 @@ export const addElectronCallPaths = (
       projected.push({ file, path, coverage, nodes });
     }
   }
-  for (const { transmission, handler } of unambiguousElectronIpcPairings(
-    collectElectronIpcRecords(context.analysis),
-  )) {
+  for (const { transmission, handler } of ipc.pairings) {
     const sources = projected.filter(
       (p) => p.path.entry_kind === "bridge-member" && matches(p, transmission),
     );
diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts
index 23b694ffdb..cf96a2865b 100644
--- a/src/application/javascript/ElectronBoundaryGraphIpc.ts
+++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts
@@ -1,4 +1,5 @@
 import {
+  compareJavaScriptSourcePoints,
   javascriptSourceRangeKey,
   javascriptSourceRangeContains,
 } from "../../domain/javascript/javascriptSourceCoordinates.js";
@@ -15,10 +16,9 @@ import {
   astObservationEvidence,
   staticInferenceEvidence,
 } from "./JavaScriptArtifactGraphEvidence.js";
-import {
-  collectElectronIpcRecords,
-  unambiguousElectronIpcPairings,
-  type ElectronIpcRecord,
+import type {
+  ElectronIpcContext,
+  ElectronIpcRecord,
 } from "./ElectronBoundaryAnalysis.js";
 import {
   addElectronAstEdge,
@@ -30,6 +30,7 @@ import {
 interface IpcGraphState {
   readonly context: JavaScriptArtifactGraphContext;
   readonly records: readonly ElectronIpcRecord[];
+  readonly ipc: ElectronIpcContext;
   readonly channels: Map;
   readonly handlers: Map;
   readonly sources: Map;
@@ -54,10 +55,12 @@ interface ValidationObservationInput {
 /** Project IPC syntax, handler locations, validations, and exact pairings. */
 export const addElectronIpcBoundaries = (
   context: JavaScriptArtifactGraphContext,
+  ipc: ElectronIpcContext,
 ): void => {
   const state: IpcGraphState = {
     context,
-    records: collectElectronIpcRecords(context.analysis),
+    records: ipc.records,
+    ipc,
     channels: new Map(),
     handlers: new Map(),
     sources: new Map(),
@@ -82,7 +85,12 @@ const addIpcRecord = (
   );
   if (source === undefined) return;
   state.sources.set(record.key, source);
-  const channel = addChannelNode(context, record, coverage);
+  const channel = addChannelNode(
+    context,
+    record,
+    coverage,
+    state.ipc.owners.get(record.key) ?? null,
+  );
   state.channels.set(record.key, channel);
   if (record.finding.mode === "listen" || record.finding.mode === "handle") {
     const handler = addHandlerNode({
@@ -117,6 +125,7 @@ const addChannelNode = (
   context: JavaScriptArtifactGraphContext,
   record: ElectronIpcRecord,
   coverage: JavaScriptArtifactGraphCoverage,
+  owner: string | null,
 ): ApplicationNode => {
   const { finding, file } = record;
   const channelKey =
@@ -125,7 +134,10 @@ const addChannelNode = (
       : `literal:${finding.channel}`;
   const identityKey =
     finding.receiver === undefined
-      ? channelKey
+      ? JSON.stringify([
+          owner === null ? ["unknown", file.path] : ["application", owner],
+          channelKey,
+        ])
       : JSON.stringify([
           file.path,
           file.sha256,
@@ -204,7 +216,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
   const handlerKey = `${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`;
   const identityKey =
     finding.receiver === undefined
-      ? handlerKey
+      ? JSON.stringify([file.path, handlerKey])
       : JSON.stringify([
           file.path,
           finding.module_runtime ?? null,
@@ -280,7 +292,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
 };
 
 const addLiteralPairings = (state: IpcGraphState): void => {
-  for (const pairing of unambiguousElectronIpcPairings(state.records)) {
+  for (const pairing of state.ipc.pairings) {
     const source = state.sources.get(pairing.transmission.key);
     const handler = state.handlers.get(pairing.handler.key);
     const coverage = coverageFor(state.context, pairing.transmission.file);
@@ -370,31 +382,29 @@ const containingHandler = (
   records: readonly ElectronIpcRecord[],
   file: JavaScriptArtifactFile,
   validation: ElectronSenderValidationFinding,
-): ElectronIpcRecord | undefined =>
-  records
-    .filter(
-      (record) =>
-        record.file.path === file.path &&
-        record.finding.handler_location !== null &&
-        javascriptSourceRangeContains(
-          record.finding.handler_location,
-          validation.location,
-        ),
+): ElectronIpcRecord | undefined => {
+  let selected: ElectronIpcRecord | undefined;
+  for (const record of records) {
+    const location = record.finding.handler_location;
+    if (
+      record.file.path !== file.path ||
+      location === null ||
+      !javascriptSourceRangeContains(location, validation.location)
     )
-    .sort(
-      (left, right) =>
-        rangeMagnitude(left.finding.handler_location) -
-        rangeMagnitude(right.finding.handler_location),
-    )[0];
-
-const rangeMagnitude = (
-  range: ElectronIpcRecord["finding"]["handler_location"],
-): number =>
-  range === null
-    ? Number.MAX_SAFE_INTEGER
-    : (range.end.line - range.start.line) * 1_000_000 +
-      range.end.column -
-      range.start.column;
+      continue;
+    const previous = selected?.finding.handler_location;
+    // Parsed handler ranges nest: the innermost starts latest, then ends
+    // earliest. Columns have no fixed upper bound in minified JavaScript.
+    if (
+      previous == null ||
+      compareJavaScriptSourcePoints(location.start, previous.start) > 0 ||
+      (compareJavaScriptSourcePoints(location.start, previous.start) === 0 &&
+        compareJavaScriptSourcePoints(location.end, previous.end) < 0)
+    )
+      selected = record;
+  }
+  return selected;
+};
 
 const coverageFor = (
   context: JavaScriptArtifactGraphContext,
diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts
index c23436a5dd..1c1ea161f4 100644
--- a/src/application/javascript/ElectronBoundaryGraphNative.ts
+++ b/src/application/javascript/ElectronBoundaryGraphNative.ts
@@ -52,9 +52,7 @@ const addNativeBinding = (input: NativeBindingInput): void => {
     files: context.filesByPath,
     jsonDocuments: context.analysis.jsonDocuments,
     directoryPaths: context.directoryPaths,
-    ...(value.module_kind === undefined
-      ? {}
-      : { moduleKind: value.module_kind }),
+    moduleKind: value.module_kind,
   }).resolved_path;
   // Extension probing can resolve `./addon.node` to `addon.node.js`; only a
   // native-addon artifact can expose the requested binding.
diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts
index 6086968853..dd0f58e97d 100644
--- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts
+++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts
@@ -1,3 +1,4 @@
+import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
 import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js";
 import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
 import {
@@ -13,6 +14,7 @@ import {
   selfReferenceOmissions,
   dependencyManifest,
   applicationContainerIdentity,
+  applicationRootPath,
   type JavaScriptArtifactGraphContext,
 } from "./JavaScriptArtifactGraphContext.js";
 import {
@@ -45,8 +47,9 @@ import {
 import { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js";
 import { addElectronBoundaries } from "./ElectronBoundaryGraph.js";
 import {
-  classifyElectronIpcPairings,
-  collectElectronIpcRecords,
+  createElectronIpcContext,
+  summarizeElectronBoundaries,
+  type ElectronIpcContext,
 } from "./ElectronBoundaryAnalysis.js";
 
 /**
@@ -58,21 +61,33 @@ export function* buildImmutableJavaScriptArtifactGraphSteps(
   snapshot: ArtifactInventorySnapshot,
   fileSet: JavaScriptArtifactFileSet,
   analysis: JavaScriptArtifactAnalysis,
-): Generator {
+): Generator<
+  void,
+  {
+    readonly graph: JavaScriptApplicationGraph;
+    readonly electronSummary: ElectronBoundarySummary;
+  }
+> {
   const input = yield* buildJavaScriptArtifactGraphInputSteps(
     snapshot,
     fileSet,
     analysis,
   );
   // The input was built here and is not retained, so it can be transferred.
-  return yield* sealTransferredJavaScriptApplicationGraphSteps(input);
+  return {
+    graph: yield* sealTransferredJavaScriptApplicationGraphSteps(input.graph),
+    electronSummary: input.electronSummary,
+  };
 }
 
 function* buildJavaScriptArtifactGraphInputSteps(
   snapshot: ArtifactInventorySnapshot,
   fileSet: JavaScriptArtifactFileSet,
   analysis: JavaScriptArtifactAnalysis,
-): Generator {
+): Generator<
+  void,
+  { readonly graph: unknown; readonly electronSummary: ElectronBoundarySummary }
+> {
   const accumulator = new JavaScriptArtifactGraphAccumulator();
   const root = createJavaScriptArtifactRootNode(accumulator, snapshot);
   const parsedPackages = new Set(
@@ -183,7 +198,10 @@ function* buildJavaScriptArtifactGraphInputSteps(
     yield* addJavaScriptModuleRelationshipsSteps(projectionContext);
   const findingLimitations =
     yield* addJavaScriptStaticFindingsSteps(projectionContext);
-  addElectronBoundaries(projectionContext);
+  const ipc = createElectronIpcContext(analysis, (file) =>
+    applicationRootPath(projectionContext, file),
+  );
+  addElectronBoundaries(projectionContext, ipc);
   yield;
   addJavaScriptHtmlRoles(projectionContext);
   yield;
@@ -191,19 +209,31 @@ function* buildJavaScriptArtifactGraphInputSteps(
   yield;
   const coverage = graphCoverage(context);
   return {
-    schema: "JavaScriptApplicationGraph",
-    root_node_ids:
-      packageRoots.length === 0
-        ? [root.node_id]
-        : packageRoots.map(({ node_id: id }) => id),
-    nodes: accumulator.nodes(),
-    edges: accumulator.edges(),
-    coverage,
-    limitations: [
-      ...bundlerLimitations,
-      ...findingLimitations,
-      ...graphLimitations(context, coverage.status, relationshipOmissions),
-    ],
+    electronSummary: summarizeElectronBoundaries(
+      analysis,
+      ipc,
+      context.directoryPaths,
+    ),
+    graph: {
+      schema: "JavaScriptApplicationGraph",
+      root_node_ids:
+        packageRoots.length === 0
+          ? [root.node_id]
+          : packageRoots.map(({ node_id: id }) => id),
+      nodes: accumulator.nodes(),
+      edges: accumulator.edges(),
+      coverage,
+      limitations: [
+        ...bundlerLimitations,
+        ...findingLimitations,
+        ...graphLimitations(
+          context,
+          coverage.status,
+          relationshipOmissions,
+          ipc,
+        ),
+      ],
+    },
   };
 }
 
@@ -295,9 +325,10 @@ const graphLimitations = (
   context: JavaScriptArtifactGraphContext,
   coverage: "complete" | "partial" | "unknown" | "unavailable",
   relationshipOmissions: JavaScriptModuleRelationshipOmissions,
+  ipcContext: ElectronIpcContext,
 ): string[] => {
-  const ipc = collectElectronIpcRecords(context.analysis);
-  const pairings = classifyElectronIpcPairings(ipc);
+  const ipc = ipcContext.records;
+  const pairings = ipcContext.states;
   const electronFindings = context.analysis.files.reduce(
     (count, { javascript }) =>
       count +
@@ -348,7 +379,7 @@ const graphLimitations = (
       : []),
     ...(pairings.some(({ status }) => status === "ambiguous")
       ? [
-          "Some literal IPC channels have multiple compatible main handlers; ambiguous calls are not paired to any handler.",
+          "Some IPC transmissions have competing handlers or unresolved application ownership; ambiguous calls are not paired to any handler.",
         ]
       : []),
     ...(context.analysis.files.some(
diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts
index 8af1cc5f1a..7c2d1775e7 100644
--- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts
+++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts
@@ -1,3 +1,5 @@
+import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
+import { createElectronIpcContext } from "./ElectronBoundaryAnalysis.js";
 import { describe, expect, it } from "vitest";
 import { createHash } from "node:crypto";
 
@@ -443,3 +445,38 @@ it.each([
     }
   },
 );
+
+it("attaches sender validation to the innermost handler after a long minified line", () => {
+  const source = `const {ipcMain}=require('electron');/*${"x".repeat(2_000_010)}*/ipcMain.handle('outer', event=>{
+  ipcMain.handle('inner', event=>{ if(event.senderFrame.url === 'https://trusted.test') return true; });
+});`;
+  const file = {
+    ...skippedFile(),
+    bytes: Buffer.byteLength(source),
+    text: { included: true as const, value: source },
+  };
+  const javascript = analyzeJavaScriptStaticSource(source);
+  expect(javascript.electron.sender_validations).toHaveLength(1);
+  const analysis = analysisFor([{ file, javascript, semantic: null }]);
+  const context = contextFor(file, analysis);
+  addJavaScriptArtifactFiles(context);
+  addElectronIpcBoundaries(
+    context,
+    createElectronIpcContext(analysis, () => "app"),
+  );
+  const handlers = context.accumulator
+    .nodes()
+    .filter(({ kind }) => kind === "ipc-handler");
+  const validated = handlers.filter(({ observations }) =>
+    observations.some(
+      ({ properties }) =>
+        properties.fact_kind === "sender-validation-candidate",
+    ),
+  );
+  expect(validated).toHaveLength(1);
+  expect(
+    validated[0]?.observations.some(
+      ({ properties }) => properties.channel === "inner",
+    ),
+  ).toBe(true);
+});
diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts
index c3ff99f226..4ce33e7e2a 100644
--- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts
+++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts
@@ -553,8 +553,6 @@ export const addJavaScriptPackageNodes = (
             version: packageValue.version,
             main: packageValue.main,
             main_status: packageValue.main_status,
-            // Legacy derived candidate; source fields live in renderer_declarations.
-            renderer: packageValue.renderer_entrypoint?.path ?? null,
             renderer_declarations: packageValue.renderer_declarations,
             parse_status: packageValue.status,
           },
diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts
index 17e03aeee2..b035acfea6 100644
--- a/src/application/javascript/JavaScriptArtifactReconstruction.ts
+++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts
@@ -46,7 +46,6 @@ import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScri
 import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js";
 import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js";
 import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js";
-import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
 import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
 import type { ProgressReporter } from "../ProgressReporter.js";
 import {
@@ -113,7 +112,7 @@ export const reconstructJavaScriptArtifact = async (
       {
         resourceScope: scope,
         signal,
-        integrity: { mode: input.integrity_policy },
+        integrity: input.integrity_policy,
         asarHeaderAdmission,
       },
       undefined,
@@ -281,7 +280,7 @@ export const reconstructJavaScriptArtifact = async (
         "seal_javascript_application_graph",
         "Sealing the validated application graph",
       );
-      const graph = await completeJavaScriptAnalysisSteps(
+      const { graph, electronSummary } = await completeJavaScriptAnalysisSteps(
         applicationGraphSteps,
       );
       await reportCompletedPhase(
@@ -366,7 +365,7 @@ export const reconstructJavaScriptArtifact = async (
           : { source_selection: files.source_selection }),
         graph,
         semantic_graph: semanticGraph,
-        electron_summary: summarizeElectronBoundaries(analysis, directoryPaths),
+        electron_summary: electronSummary,
         statistics: {
           relevant_files:
             files.files.length + files.unavailable_relevant_files.length,
diff --git a/src/application/managed/ManagedMemberComparisonService.ts b/src/application/managed/ManagedMemberComparisonService.ts
index 910034c350..2d1184932a 100644
--- a/src/application/managed/ManagedMemberComparisonService.ts
+++ b/src/application/managed/ManagedMemberComparisonService.ts
@@ -13,6 +13,7 @@ import {
   AnalysisOutputError,
 } from "../../domain/analysisErrorCore.js";
 import { AnalysisError } from "../../domain/analysisErrorBase.js";
+import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
 import { createEvidence, type Evidence } from "../../domain/evidence.js";
 import {
   classifyBinaryTargetFilesystemFailure,
@@ -108,12 +109,28 @@ export const compareManagedMemberPaths = async (
   let partialEvidence: Evidence | undefined;
   try {
     const [leftTarget, rightTarget] = await Promise.all([
-      resolveTarget(input.leftPath),
-      resolveTarget(input.rightPath),
+      resolveTarget(
+        input.leftPath,
+        resources === undefined ? {} : { resources },
+      ),
+      resolveTarget(
+        input.rightPath,
+        resources === undefined ? {} : { resources },
+      ),
     ]);
     // Report an unopenable path like every other path-based command does,
     // with the failed path and constraint, rather than a bare input error.
-    if (!leftTarget.ok) return err(leftTarget.error);
+    if (!leftTarget.ok) {
+      if (!rightTarget.ok && rightTarget.error.cleanupIncomplete)
+        return err(
+          analysisErrorWithCleanupFailure(
+            leftTarget.error,
+            rightTarget.error,
+            operation,
+          ).bindCleanupDependencies([leftTarget.error, rightTarget.error]),
+        );
+      return err(leftTarget.error);
+    }
     if (!rightTarget.ok) return err(rightTarget.error);
     const inspectTarget = async (
       target: BinaryTarget,
diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts
index 835b443660..b8bbd734a7 100644
--- a/src/application/native/NativeCallRoutes.ts
+++ b/src/application/native/NativeCallRoutes.ts
@@ -1,3 +1,4 @@
+import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
 import { permitsCapabilityFallback } from "../CapabilityFallback.js";
 import { z } from "zod";
 import type {
@@ -153,6 +154,14 @@ export const readNativeCallRoutes = async (
           new AnalysisOutputError(
             NATIVE_CALL_ROUTE_SOURCES.references,
             "Invalid typed call references",
+            { cause: parsed.error },
+          ).retainPartialObservation(
+            createAnalysisExecutionEvidence(
+              NATIVE_CALL_ROUTE_SOURCES.references,
+              { procedure, direction: "outgoing" },
+              execution.value,
+              "observed",
+            ),
           ),
         ),
       );
@@ -188,7 +197,17 @@ export const readNativeCallRoutes = async (
     ),
   );
   const parsed = parseRelatedAddresses(callees.value.result, "callees");
-  if (!parsed.ok) return finish(err(parsed.error));
+  if (!parsed.ok) {
+    parsed.error.retainPartialObservation(
+      createAnalysisExecutionEvidence(
+        NATIVE_CALL_ROUTE_SOURCES.callees,
+        { procedure },
+        callees.value,
+        "observed",
+      ),
+    );
+    return finish(err(parsed.error));
+  }
   // Keep every observed call site. A target-only fallback cannot replace one,
   // and repeated fallback targets add no new observation.
   const targets = new Set(routes.map(({ address }) => address));
diff --git a/src/application/native/NativeDispatchMetadataInspection.ts b/src/application/native/NativeDispatchMetadataInspection.ts
index 82a20a5fda..42a4cb45d0 100644
--- a/src/application/native/NativeDispatchMetadataInspection.ts
+++ b/src/application/native/NativeDispatchMetadataInspection.ts
@@ -1,3 +1,4 @@
+import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
 import { permitsCapabilityFallback } from "../CapabilityFallback.js";
 import { nativeInventoryRowsSchemas } from "../../domain/native/nativeInventory.js";
 import {
@@ -54,12 +55,27 @@ export const inspectNativeDispatch = async (
     input,
     signal === undefined ? {} : { signal },
   );
-  if (binaryMetadata.ok)
-    return ok(
-      jsonValueSchema.parse(
-        nativeDispatchMetadataResultSchema.parse(binaryMetadata.value.result),
-      ),
+  if (binaryMetadata.ok) {
+    const parsed = nativeDispatchMetadataResultSchema.safeParse(
+      binaryMetadata.value.result,
     );
+    if (!parsed.success)
+      return err(
+        new AnalysisOutputError(
+          NATIVE_DISPATCH_METADATA_SOURCES.binary,
+          "Invalid native dispatch metadata",
+          { cause: parsed.error },
+        ).retainPartialObservation(
+          createAnalysisExecutionEvidence(
+            NATIVE_DISPATCH_METADATA_SOURCES.binary,
+            input,
+            binaryMetadata.value,
+            "observed",
+          ),
+        ),
+      );
+    return ok(jsonValueSchema.parse(parsed.data));
+  }
   if (!permitsCapabilityFallback(binaryMetadata.error))
     return err(binaryMetadata.error);
   const execution = await analysis.execute(
@@ -74,6 +90,14 @@ export const inspectNativeDispatch = async (
       new AnalysisOutputError(
         NATIVE_DISPATCH_METADATA_SOURCES.symbols,
         "provider returned an invalid inventory",
+        { cause: names.error },
+      ).retainPartialObservation(
+        createAnalysisExecutionEvidence(
+          NATIVE_DISPATCH_METADATA_SOURCES.symbols,
+          {},
+          execution.value,
+          "observed",
+        ),
       ),
     );
   const result = inspectNativeDispatchMetadata(
diff --git a/src/application/native/NativeReaderEvidence.test.ts b/src/application/native/NativeReaderEvidence.test.ts
new file mode 100644
index 0000000000..54e01f67c3
--- /dev/null
+++ b/src/application/native/NativeReaderEvidence.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import {
+  createAnalysisExecution,
+  type AnalysisOperationPort,
+} from "../AnalysisProvider.js";
+import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
+import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
+import { err, ok } from "../../domain/result.js";
+import { readNativeCallRoutes } from "./NativeCallRoutes.js";
+import { traceNativeValues } from "./NativeValueTrace.js";
+import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
+
+import { createAnalysisProfile } from "../../domain/analysisProfile.js";
+import { createEvidence } from "../../domain/evidence.js";
+
+const provider = { id: "fixture", name: "Fixture", version: "1" };
+const subject = {
+  path: "/fixture",
+  sha256: "a".repeat(64),
+  format: "elf" as const,
+  architecture: "x86_64" as const,
+};
+const source = createEvidence(subject, provider, {
+  operation: "list_names",
+  parameters: {},
+  result: [{ address: "0x1000", value: "entry" }],
+});
+const observation = createAnalysisExecution(
+  { malformed: "provider facts" },
+  provider,
+  {
+    subject,
+    analysisProfile: createAnalysisProfile(provider, { language: "x86:LE:64" }),
+    sourceEvidence: [source],
+    rawResult: { output: "original output" },
+    limitations: ["partial provider coverage"],
+    locations: [],
+  },
+);
+
+describe("native reader validation Evidence", () => {
+  it.each(["references", "callees", "dossier", "binary", "symbols"] as const)(
+    "retains the original %s observation on a typed validation failure",
+    async (reader) => {
+      let operation = "";
+      let input: unknown;
+      const analysis: AnalysisOperationPort = {
+        execute: async (name, parameters) => {
+          if (
+            reader === "symbols" &&
+            name === "inspect_native_dispatch_metadata"
+          )
+            return err(
+              new AnalysisCapabilityUnavailableError(
+                "fixture",
+                name,
+                "unsupported",
+              ),
+            );
+          operation = name;
+          input = parameters;
+          return ok(
+            reader === "callees" && name === "procedure_references"
+              ? createAnalysisExecution(
+                  {
+                    reference_kinds_available: false,
+                    references: [],
+                    unresolved_calls: [],
+                  },
+                  provider,
+                )
+              : observation,
+          );
+        },
+      };
+      const result =
+        reader === "references" || reader === "callees"
+          ? (await readNativeCallRoutes(analysis, "0x1000")).outcome
+          : reader === "dossier"
+            ? await traceNativeValues(analysis, { procedure: "0x1000" })
+            : await inspectNativeDispatch(analysis, 10);
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+      expect(result.error._tag).toBe("AnalysisOutputError");
+      expect(result.error.partialObservation).toEqual(
+        createAnalysisExecutionEvidence(
+          operation,
+          input,
+          observation,
+          "observed",
+        ),
+      );
+    },
+  );
+});
diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts
index 29dc6b3f67..5c978d7203 100644
--- a/src/application/native/NativeValueTrace.test.ts
+++ b/src/application/native/NativeValueTrace.test.ts
@@ -1,3 +1,4 @@
+import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
 import { describe, expect, it } from "vitest";
 import { traceNativeValues } from "./NativeValueTrace.js";
 import {
@@ -989,3 +990,54 @@ describe("native value byte-bound call bindings", () => {
     );
   });
 });
+
+describe("native value validation failure provenance", () => {
+  it.each(["call-targets", "target-digest"] as const)(
+    "retains the exact provider observation for invalid %s",
+    async (failure) => {
+      let expected:
+        | ReturnType
+        | undefined;
+      const result = await traceNativeValues(
+        {
+          execute: async (operation, parameters, options) => {
+            const execution = await analysis.execute(
+              operation,
+              parameters,
+              options,
+            );
+            if (!execution.ok) return execution;
+            if (
+              (failure === "call-targets" &&
+                operation === "resolve_native_call_targets") ||
+              (failure === "target-digest" && parameters.procedure === "0x2000")
+            ) {
+              const observation = {
+                ...execution.value,
+                ...(failure === "call-targets"
+                  ? { result: { malformed: "call targets" } }
+                  : { subject: { ...subject, sha256: "b".repeat(64) } }),
+                rawResult: { original: "provider output" },
+                limitations: ["Provider coverage is partial"],
+              };
+              expected = createAnalysisExecutionEvidence(
+                operation,
+                parameters,
+                observation,
+                "observed",
+              );
+              return ok(observation);
+            }
+            return execution;
+          },
+        },
+        { procedure: "0x1000" },
+      );
+      expect(result.ok).toBe(false);
+      if (result.ok) return;
+      expect(result.error._tag).toBe("AnalysisOutputError");
+      expect(expected).toBeDefined();
+      expect(result.error.partialObservation).toEqual(expected);
+    },
+  );
+});
diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts
index 2f330b55d5..6647a98929 100644
--- a/src/application/native/NativeValueTrace.ts
+++ b/src/application/native/NativeValueTrace.ts
@@ -4,7 +4,7 @@ import {
   nativeValueTraceInputSchema,
   nativeValueTraceSchema,
 } from "../../domain/native/nativeValueTrace.js";
-import { createEvidence } from "../../domain/evidence.js";
+import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
 import {
   AnalysisCancelledError,
   AnalysisOutputError,
@@ -118,13 +118,20 @@ export const traceNativeValues = async (
       });
       continue;
     }
+    const evidence = createAnalysisExecutionEvidence(
+      "analyze_function",
+      { procedure: next.procedure },
+      execution.value,
+      "observed",
+    );
     const parsed = functionDossierSchema.safeParse(execution.value.result);
     if (!parsed.success || execution.value.subject == null)
       return err(
         new AnalysisOutputError(
           "trace_native_values",
           "Function dossier lacks validated facts or artifact identity",
-        ),
+          parsed.success ? undefined : { cause: parsed.error },
+        ).retainPartialObservation(evidence),
       );
     const dossier = parsed.data;
     const address = dossier.procedure.address;
@@ -137,23 +144,8 @@ export const traceNativeValues = async (
         new AnalysisOutputError(
           "trace_native_values",
           "Cross-function target digest mismatch",
-        ),
+        ).retainPartialObservation(evidence),
       );
-    const evidence = createEvidence(
-      execution.value.subject,
-      execution.value.provider,
-      {
-        operation: "analyze_function",
-        parameters: { procedure: next.procedure },
-        result: execution.value.result,
-        rawResult: execution.value.rawResult,
-        limitations: execution.value.limitations,
-        locations: execution.value.locations,
-        ...(execution.value.analysisProfile === undefined
-          ? {}
-          : { analysisProfile: execution.value.analysisProfile }),
-      },
-    );
     const evidenceId = evidence.evidence_id;
     procedures.push({
       address,
@@ -289,6 +281,14 @@ export const traceNativeValues = async (
           new AnalysisOutputError(
             "resolve_native_call_targets",
             "Invalid static call resolution",
+            { cause: targets.error },
+          ).retainPartialObservation(
+            createAnalysisExecutionEvidence(
+              "resolve_native_call_targets",
+              { address: operation.address },
+              resolved.value,
+              "observed",
+            ),
           ),
         );
       sourceLimitations.push(
diff --git a/src/application/wasm/WasmArtifactService.test.ts b/src/application/wasm/WasmArtifactService.test.ts
index 94a878f64b..8b5180c0fe 100644
--- a/src/application/wasm/WasmArtifactService.test.ts
+++ b/src/application/wasm/WasmArtifactService.test.ts
@@ -1,3 +1,7 @@
+import { rebindEvidenceToTargetPath } from "../../domain/evidence.js";
+import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
+import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
+import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
 import { AnalysisSettings } from "../AnalysisSettings.js";
 import { expect, it } from "vitest";
 import { WasmArtifactService } from "./WasmArtifactService.js";
@@ -61,7 +65,7 @@ it("rejects a provider report that silently changes the selected command budget"
     }),
   ).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
 });
-it.each(["path", "digest", "wat", "candidates", "glue"])(
+it.each(["path", "subject-path", "digest", "wat", "candidates", "glue"])(
   "rejects mismatched upstream observation: %s",
   async (problem) => {
     const value = wasmArtifactFixture();
@@ -76,7 +80,10 @@ it.each(["path", "digest", "wat", "candidates", "glue"])(
           ok({
             ...execution,
             subject: {
-              path: "/artifacts/module.wasm",
+              path:
+                problem === "subject-path"
+                  ? "/different"
+                  : "/artifacts/module.wasm",
               format: "file",
               sha256:
                 problem === "digest" ? "b".repeat(64) : value.artifact.sha256,
@@ -255,3 +262,51 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
   expect(accepted).toEqual([123000, 42000, 30000]);
   expect(authored).toEqual([undefined, 42000, undefined]);
 });
+
+it("replays imported rebound WASM Evidence in views and comparisons", async () => {
+  const report = wasmArtifactFixture();
+  const service = new WasmArtifactService({
+    close: () => Promise.resolve(),
+    inspect: async () => ok(wasmArtifactExecution(report)),
+  });
+  const completed = await service.inspect({ path: report.artifact.path });
+  if (!completed.ok) throw completed.error;
+  const rebound = rebindEvidenceToTargetPath(completed.value, {
+    path: "/alias/module.wasm",
+    sha256: report.artifact.sha256,
+    format: "file",
+  });
+  const exporting = new EvidenceLedger();
+  expect(exporting.record(rebound).ok).toBe(true);
+  const imported = new EvidenceLedger();
+  expect(imported.import(exporting.export()).ok).toBe(true);
+  const lookup = (id: string) => imported.forAnalysis(id);
+  const replay = await inspectAnalysisView(
+    {
+      source: { kind: "retained-evidence", evidence_id: rebound.evidence_id },
+      view: { kind: "summary" },
+    },
+    lookup,
+  );
+  if (!replay.ok) throw replay.error;
+  expect(replay.value.subject?.local_path).toBe("/alias/module.wasm");
+  expect(replay.value.normalized_result).toMatchObject({
+    artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
+  });
+  expect(
+    (
+      await compareWasmInterfaces(
+        {
+          left: { kind: "retained-evidence", evidence_id: rebound.evidence_id },
+          right: {
+            kind: "retained-evidence",
+            evidence_id: rebound.evidence_id,
+          },
+          scope: "both",
+        },
+        {},
+        lookup,
+      )
+    ).ok,
+  ).toBe(true);
+});
diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts
index 2545311805..b6f1619b8f 100644
--- a/src/application/wasm/WasmArtifactService.ts
+++ b/src/application/wasm/WasmArtifactService.ts
@@ -55,6 +55,7 @@ export class WasmArtifactService {
     if (
       !report.success ||
       report.data.artifact.path !== input.value.path ||
+      execution.subject.path !== input.value.path ||
       report.data.tool_profile.timeout_ms !== timeout ||
       !wasmArtifactMatchesSubject(report.data.artifact, execution.subject) ||
       JSON.stringify(report.data.glue.map((item) => item.artifact.path)) !==
diff --git a/src/application/wasm/WasmInterfaceComparisonService.test.ts b/src/application/wasm/WasmInterfaceComparisonService.test.ts
index 25d7a2fc81..9948c6643a 100644
--- a/src/application/wasm/WasmInterfaceComparisonService.test.ts
+++ b/src/application/wasm/WasmInterfaceComparisonService.test.ts
@@ -131,7 +131,7 @@ it("withholds absence claims for historical or partial inventories and unsupport
   });
 });
 
-it("authenticates semantic Evidence and binds display subject and selection identity", async () => {
+it("authenticates semantic Evidence and rejects altered artifact digests", async () => {
   const left = JSON.parse(JSON.stringify(evidence(facts("f"))));
   left.normalized_result.interface.entries[0].name = "tampered";
   expect(
@@ -142,7 +142,7 @@ it("authenticates semantic Evidence and binds display subject and selection iden
     }),
   ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
   const rebound = JSON.parse(JSON.stringify(evidence(facts("f"))));
-  rebound.subject.local_path = "/other.wasm";
+  rebound.subject.digest.sha256 = "b".repeat(64);
   expect(
     await compareWasmInterfaces({
       left: rebound,
diff --git a/src/application/wasm/WasmInterfaceComparisonService.ts b/src/application/wasm/WasmInterfaceComparisonService.ts
index d4a6e16226..ec6478e551 100644
--- a/src/application/wasm/WasmInterfaceComparisonService.ts
+++ b/src/application/wasm/WasmInterfaceComparisonService.ts
@@ -50,7 +50,6 @@ const parseInspection = (
       evidence.subject === null
         ? null
         : {
-            path: evidence.subject.local_path,
             sha256: evidence.subject.digest.sha256,
             format: evidence.subject.format,
           },
diff --git a/src/artifacts/ArtifactEmbeddedFormatProbes.ts b/src/artifacts/ArtifactEmbeddedFormatProbes.ts
index 173de825df..1acb291535 100644
--- a/src/artifacts/ArtifactEmbeddedFormatProbes.ts
+++ b/src/artifacts/ArtifactEmbeddedFormatProbes.ts
@@ -14,16 +14,17 @@ import {
   type NodeSeaLocation,
 } from "./nodeSea/NodeSeaLocation.js";
 import {
-  findPyInstallerCookie,
+  observePyInstallerCookie,
   type PyInstallerCookie,
+  type PyInstallerCookieObservation,
 } from "./pyinstaller/CArchiveLayout.js";
 
-/** Completed observations only; null means examined absence, omission means unexamined. */
+/** Completed observations only; omission means unexamined, and null is examined absence. */
 export interface ArtifactEmbeddedFormatProbeFacts {
   readonly sourceState: RegularFileState;
   readonly godotPck?: GodotPckLocation | null;
   readonly nodeSea?: NodeSeaLocation | null;
-  readonly pyInstaller?: PyInstallerCookie | null;
+  readonly pyInstaller?: PyInstallerCookieObservation;
 }
 
 /** Share format observations only across admissions of the same unchanged regular file. */
@@ -78,16 +79,19 @@ export class ArtifactEmbeddedFormatProbes {
   async pyInstaller(): Promise {
     abortIfNeeded(this.signal);
     if (this.#facts.pyInstaller !== undefined)
-      return this.#facts.pyInstaller ?? undefined;
-    const found = await findPyInstallerCookie(
+      return this.#facts.pyInstaller.state === "located"
+        ? this.#facts.pyInstaller.cookie
+        : undefined;
+    const found = await observePyInstallerCookie(
       this.source.handle,
       this.source.initial.size,
       this.signal,
     );
     abortIfNeeded(this.signal);
-    const cookie = found?.ok === true ? found.value : undefined;
-    this.#facts = { ...this.#facts, pyInstaller: cookie ?? null };
-    return cookie;
+    if (!found.ok)
+      throw new ArtifactReaderFailure(found.error.reason, found.error.message);
+    this.#facts = { ...this.#facts, pyInstaller: found.value };
+    return found.value.state === "located" ? found.value.cookie : undefined;
   }
 
   completedFacts(): ArtifactEmbeddedFormatProbeFacts {
diff --git a/src/artifacts/ArtifactFileSource.ts b/src/artifacts/ArtifactFileSource.ts
index cf055a4284..46bd8f7eb4 100644
--- a/src/artifacts/ArtifactFileSource.ts
+++ b/src/artifacts/ArtifactFileSource.ts
@@ -8,12 +8,45 @@ import {
   sameRegularFileState,
   type StableRegularFileDescriptor,
 } from "../filesystem/RegularFile.js";
-import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
+import {
+  artifactSelectedPathErrno,
+  assertArtifactReadActive,
+} from "./ArtifactFileOperation.js";
 import { artifactFileAdmissionFailure } from "./ArtifactFileAdmission.js";
 import { hashReadable } from "./ArtifactHash.js";
 import { ArtifactReaderFailure } from "./ArtifactReader.js";
 import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
 
+/** Verify an admitted descriptor against the regular file still named by its path. */
+export const assertArtifactFileSelectedSource = async (
+  path: string,
+  source: StableRegularFileDescriptor,
+  diagnosticLabel: string,
+): Promise => {
+  const [observed, selected] = await Promise.all([
+    source.handle.stat(),
+    lstat(path).catch((cause: unknown) => {
+      // Admission already established this selected name. Its disappearance
+      // or a replaced ancestor is a source change, not an unrelated I/O fault.
+      if (artifactSelectedPathErrno(cause) !== undefined)
+        throw new ArtifactReaderFailure(
+          "integrity",
+          `${diagnosticLabel} source identity changed: ${path}`,
+          { cause },
+        );
+      throw cause;
+    }),
+  ]);
+  if (
+    !sameRegularFileState(source.initial, observed) ||
+    !sameRegularFileState(source.initial, selected)
+  )
+    throw new ArtifactReaderFailure(
+      "integrity",
+      `${diagnosticLabel} source identity changed: ${path}`,
+    );
+};
+
 /** One persistent descriptor and hash binding, borrowing caller-admitted sources. */
 export class ArtifactFileSource {
   readonly #scope = new ArtifactResourceScope();
@@ -140,32 +173,11 @@ export class ArtifactFileSource {
     assertArtifactReadActive(signal);
     this.#opening ??= this.#admit(signal);
     const source = await this.#opening;
-    const [observed, selected] = await Promise.all([
-      source.handle.stat(),
-      lstat(this.path).catch((cause: unknown) => {
-        // Admission already established this selected name. Its disappearance
-        // or a replaced ancestor is a source change, not an unrelated I/O fault.
-        if (
-          cause instanceof Error &&
-          "code" in cause &&
-          (cause.code === "ENOENT" || cause.code === "ENOTDIR")
-        )
-          throw new ArtifactReaderFailure(
-            "integrity",
-            `${this.diagnosticLabel} source identity changed: ${this.path}`,
-            { cause },
-          );
-        throw cause;
-      }),
-    ]);
-    if (
-      !sameRegularFileState(source.initial, observed) ||
-      !sameRegularFileState(source.initial, selected)
-    )
-      throw new ArtifactReaderFailure(
-        "integrity",
-        `${this.diagnosticLabel} source identity changed: ${this.path}`,
-      );
+    await assertArtifactFileSelectedSource(
+      this.path,
+      source,
+      this.diagnosticLabel,
+    );
     assertArtifactReadActive(signal);
     return source;
   }
diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts
index ca5ac153fd..98647bfea2 100644
--- a/src/artifacts/ArtifactProvider.ts
+++ b/src/artifacts/ArtifactProvider.ts
@@ -20,6 +20,7 @@ import {
 } from "./apple/KeyedArchiveReader.js";
 import { traceDylibResolution } from "./apple/DylibResolutionReader.js";
 import { dirname } from "node:path";
+import { withArtifactTargetIdentity } from "./withArtifactTargetIdentity.js";
 import { inventoryArtifact } from "./inventory/ArtifactInventory.js";
 import { extractArtifact } from "./extraction/ArtifactExtraction.js";
 import { analyzeInterfaceBuilderBundle } from "./apple/InterfaceBuilderAnalysis.js";
@@ -242,13 +243,21 @@ class ArtifactClient implements AnalysisClient {
       }
       if (operation === "decode_interface_builder") {
         const limits = interfaceBuilderLimitsSchema.parse(parameters);
-        const result = await analyzeInterfaceBuilderBundle({
-          bundlePath: this.target.sourcePath ?? this.target.path,
-          targetSha256: this.target.sha256,
-          limits,
-          resources: this.#resourceScope,
-          ...(options?.signal === undefined ? {} : { signal: options.signal }),
-        });
+        const result = await withArtifactTargetIdentity(
+          this.target,
+          this.#resourceScope,
+          () =>
+            analyzeInterfaceBuilderBundle({
+              bundlePath: this.target.sourcePath ?? this.target.path,
+              targetSha256: this.target.sha256,
+              limits,
+              resources: this.#resourceScope,
+              ...(options?.signal === undefined
+                ? {}
+                : { signal: options.signal }),
+            }),
+          options?.signal,
+        );
         return ok(
           createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
             limitations: result.limitations,
@@ -275,16 +284,22 @@ class ArtifactClient implements AnalysisClient {
                 ? {}
                 : { signal: options.signal }),
             })
-          : await inspectBundleKeyedArchive({
-              bundlePath: this.target.sourcePath ?? this.target.path,
-              targetSha256: this.target.sha256,
-              parameters,
-              platform: this.platform,
-              resources: this.#resourceScope,
-              ...(options?.signal === undefined
-                ? {}
-                : { signal: options.signal }),
-            });
+          : await withArtifactTargetIdentity(
+              this.target,
+              this.#resourceScope,
+              () =>
+                inspectBundleKeyedArchive({
+                  bundlePath: this.target.sourcePath ?? this.target.path,
+                  targetSha256: this.target.sha256,
+                  parameters,
+                  platform: this.platform,
+                  resources: this.#resourceScope,
+                  ...(options?.signal === undefined
+                    ? {}
+                    : { signal: options.signal }),
+                }),
+              options?.signal,
+            );
         return ok(
           createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
             limitations: result.limitations,
@@ -401,14 +416,20 @@ class ArtifactClient implements AnalysisClient {
     options?: ExecutionOptions,
   ) {
     const bundlePath = this.target.sourcePath ?? this.target.path;
-    const result = await analyzeAppleAssetCatalogs({
-      environment: this.environment,
-      bundlePath,
-      resources: this.#resourceScope,
-      targetSha256: this.target.sha256,
-      page: parameters,
-      ...(options?.signal === undefined ? {} : { signal: options.signal }),
-    });
+    const result = await withArtifactTargetIdentity(
+      this.target,
+      this.#resourceScope,
+      () =>
+        analyzeAppleAssetCatalogs({
+          environment: this.environment,
+          bundlePath,
+          resources: this.#resourceScope,
+          targetSha256: this.target.sha256,
+          page: parameters,
+          ...(options?.signal === undefined ? {} : { signal: options.signal }),
+        }),
+      options?.signal,
+    );
     return ok(
       createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
         limitations: result.limitations,
@@ -522,7 +543,7 @@ class ArtifactClient implements AnalysisClient {
         this.settings,
       ),
       ...(options?.signal === undefined ? {} : { signal: options.signal }),
-      integrity: { mode: parsed.integrity_policy },
+      integrity: parsed.integrity_policy,
     });
   }
 }
diff --git a/src/artifacts/ArtifactSelectedSource.ts b/src/artifacts/ArtifactSelectedSource.ts
index fa79e6a5c6..734c2f5105 100644
--- a/src/artifacts/ArtifactSelectedSource.ts
+++ b/src/artifacts/ArtifactSelectedSource.ts
@@ -1,5 +1,17 @@
-import { realpath } from "node:fs/promises";
+import { realpath, stat } from "node:fs/promises";
 import { ArtifactReaderFailure } from "./ArtifactReader.js";
+import { artifactSelectedPathErrno } from "./ArtifactFileOperation.js";
+
+/** Canonicalize only a path that native filesystem lookup actually accepts. */
+export const resolveArtifactSourcePath = async (
+  path: string,
+): Promise => {
+  // Darwin realpath can collapse file/.. although opening that spelling fails
+  // with ENOTDIR. Keep both file and directory selections valid here; the
+  // inventory/reader owns format and regular-file admission.
+  await stat(path);
+  return realpath(path);
+};
 
 /** Verify selection spelling independently of canonical descriptor ownership. */
 export const assertArtifactSelectedSource = async (
@@ -19,14 +31,9 @@ export const assertArtifactSelectedSource = async (
     );
   let current: string;
   try {
-    current = await realpath(selectedPath);
+    current = await resolveArtifactSourcePath(selectedPath);
   } catch (cause: unknown) {
-    if (
-      cause instanceof Error &&
-      "code" in cause &&
-      ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
-    )
-      throw changed(cause);
+    if (artifactSelectedPathErrno(cause) !== undefined) throw changed(cause);
     throw cause;
   }
   if (current !== canonicalPath) throw changed();
diff --git a/src/artifacts/ArtifactSourceResolution.test.ts b/src/artifacts/ArtifactSourceResolution.test.ts
new file mode 100644
index 0000000000..745705b4aa
--- /dev/null
+++ b/src/artifacts/ArtifactSourceResolution.test.ts
@@ -0,0 +1,107 @@
+import { lstat, mkdir, rename, symlink, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it } from "vitest";
+
+import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
+import { openRegularFile } from "../filesystem/RegularFile.js";
+import { assertArtifactFileSelectedSource } from "./ArtifactFileSource.js";
+import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js";
+import { readStableArtifact } from "./readStableArtifact.js";
+import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
+
+it.skipIf(process.platform === "win32")(
+  "admits a caller-selected directory symlink without changing its target identity",
+  async () => {
+    const root = await createTestTempDirectory("rea-directory-selection-");
+    const directory = join(root, "directory");
+    const selected = join(root, "selected");
+    await mkdir(directory);
+    await symlink(directory, selected);
+    const reader = new DirectoryArtifactReader(selected);
+    try {
+      const metadata = await lstat(directory, { bigint: true });
+      await expect(reader.rootObservation()).resolves.toMatchObject({
+        identity: { dev: metadata.dev, ino: metadata.ino },
+      });
+    } finally {
+      await reader.close();
+    }
+  },
+);
+
+it.skipIf(process.platform === "win32")(
+  "rejects a directory selection that traverses through a regular file",
+  async () => {
+    const root = await createTestTempDirectory("rea-directory-resolution-");
+    await writeFile(join(root, "file"), "carrier");
+    const reader = new DirectoryArtifactReader(`${root}/file/..`);
+    try {
+      await expect(reader.rootObservation()).rejects.toMatchObject({
+        reason: "io",
+        cause: { code: "ENOTDIR" },
+      });
+    } finally {
+      await reader.close();
+    }
+  },
+);
+
+it.skipIf(process.platform === "win32")(
+  "reports a replaced ancestor symlink loop as a changed admitted source",
+  async () => {
+    const root = await createTestTempDirectory("rea-source-resolution-");
+    const parent = join(root, "selected");
+    await mkdir(parent);
+    const path = join(parent, "carrier.bin");
+    await writeFile(path, "carrier");
+    const source = await openRegularFile(path, { symlinks: "reject" });
+    try {
+      await rename(parent, join(root, "original"));
+      await symlink("selected", parent);
+      await expect(
+        assertArtifactFileSelectedSource(path, source, "Carrier"),
+      ).rejects.toMatchObject({
+        reason: "integrity",
+        cause: { code: "ELOOP" },
+      });
+    } finally {
+      await source.handle.close();
+    }
+  },
+);
+
+it.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
+  "retains a name-too-long resolution failure as an invalidated selection",
+  async () => {
+    const path = join("/", "x".repeat(16384));
+    await expect(
+      assertArtifactSelectedSource("/admitted", path),
+    ).rejects.toMatchObject({
+      reason: "integrity",
+      cause: { code: "ENAMETOOLONG" },
+    });
+  },
+);
+
+it.each(["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG", "EACCES", "EIO"])(
+  "preserves %s meaning when a stable snapshot cannot open its admitted name",
+  async (code) => {
+    const root = await createTestTempDirectory("rea-stable-resolution-");
+    const path = join(root, "carrier.bin");
+    await writeFile(path, "carrier");
+    const failure = Object.assign(new Error("injected open failure"), { code });
+    const result = readStableArtifact(path, 1024, undefined, {
+      lstat: (selected) => lstat(selected, { bigint: true }),
+      open: async () => {
+        throw failure;
+      },
+    });
+    if (code === "EACCES" || code === "EIO")
+      await expect(result).rejects.toBe(failure);
+    else
+      await expect(result).rejects.toMatchObject({
+        reason: "integrity",
+        cause: failure,
+      });
+  },
+);
diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts
index 7f7d923b48..f8bba0ff6a 100644
--- a/src/artifacts/AsarArtifactReader.ts
+++ b/src/artifacts/AsarArtifactReader.ts
@@ -25,7 +25,11 @@ import {
   type ArtifactReader,
 } from "./ArtifactReader.js";
 import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
-import { admitAsarHeader, readAsarHeader } from "./AsarHeader.js";
+import {
+  admitAsarHeader,
+  readAsarHeader,
+  type AsarHeaderExtents,
+} from "./AsarHeader.js";
 import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
 import type { FileHandleChunkStream } from "../filesystem/readFileHandleChunks.js";
 import { readArtifactFileChunks } from "./readArtifactFileChunks.js";
@@ -45,9 +49,8 @@ import { readAsarListing, type AsarListedEntry } from "./AsarListing.js";
  */
 export class AsarArtifactReader implements ArtifactReader {
   readonly format = "asar" as const;
-  #archiveSize: number | undefined;
+  #extents: AsarHeaderExtents | undefined;
   #selectedCanonicalSource: string | undefined;
-  #headerSize: number | undefined;
   readonly #entries = new Map();
   readonly #ownedHandles = new Map();
   #snapshot: { readonly path: string; readonly sha256: string } | undefined;
@@ -68,18 +71,13 @@ export class AsarArtifactReader implements ArtifactReader {
         this.#resetArchiveState();
         this.#entries.clear();
         const admitted = await this.#admitHeader(snapshotPath, signal);
-        this.#archiveSize = admitted.archiveBytes;
-        this.#headerSize = admitted.headerBytes;
+        this.#extents = {
+          archiveBytes: admitted.archiveBytes,
+          headerBytes: admitted.headerBytes,
+          jsonBytes: admitted.jsonBytes,
+        };
         assertAsarReadActive(signal);
-        const ordered = await readAsarListing(admitted.header, signal);
-        const archiveMetadata = await lstat(snapshotPath);
-        if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
-          throw new ArtifactReaderFailure(
-            "format",
-            "ASAR container is not a regular file",
-          );
-        this.#archiveSize = archiveMetadata.size;
-        return ordered;
+        return readAsarListing(admitted.header, signal);
       }, signal);
     } catch (cause: unknown) {
       throw asarFailure(this.path, "inventory", cause);
@@ -196,8 +194,8 @@ export class AsarArtifactReader implements ArtifactReader {
     signal?: AbortSignal,
   ): Promise {
     const { facts, entry: producedEntry } = state;
-    const archiveSize = this.#archiveSize;
-    const headerSize = this.#headerSize;
+    const archiveSize = this.#extents?.archiveBytes;
+    const headerSize = this.#extents?.headerBytes;
     const offset = facts.packedOffset;
     const start =
       offset === undefined || headerSize === undefined
@@ -602,8 +600,7 @@ export class AsarArtifactReader implements ArtifactReader {
   }
 
   #resetArchiveState(): void {
-    this.#archiveSize = undefined;
-    this.#headerSize = undefined;
+    this.#extents = undefined;
   }
 }
 
diff --git a/src/artifacts/AsarListing.ts b/src/artifacts/AsarListing.ts
index a678c5e3e7..e8bf49c0bb 100644
--- a/src/artifacts/AsarListing.ts
+++ b/src/artifacts/AsarListing.ts
@@ -1,6 +1,6 @@
 import { setImmediate } from "node:timers/promises";
-import { z } from "zod";
 
+import { asarNodeSchema } from "./AsarNode.js";
 import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js";
 import { assertAsarReadActive } from "./AsarFailure.js";
 import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -9,22 +9,7 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
 const MAX_LISTING_BYTES = 64 * 1024 * 1024;
 const BLOCK_INTEGRITY_LIMITATION =
   "ASAR block integrity declarations were not validated. Whole-file digest comparison does not establish block integrity or Electron runtime acceptance.";
-const nodeSchema = z.looseObject({
-  files: z
-    .custom>(
-      (value) =>
-        value !== null && typeof value === "object" && !Array.isArray(value),
-    )
-    .optional(),
-  link: z.string().optional(),
-  size: z.number().nonnegative().safe().optional(),
-  offset: z.string().optional(),
-  unpacked: z.boolean().optional(),
-  executable: z.boolean().optional(),
-  integrity: z
-    .looseObject({ algorithm: z.string(), hash: z.string() })
-    .optional(),
-});
+
 type AsarListedFacts = Pick<
   ArtifactEntry,
   "declaredIntegrity" | "executable" | "unpacked"
@@ -56,7 +41,7 @@ export const readAsarListing = async (
   header: unknown,
   signal?: AbortSignal,
 ): Promise => {
-  const root = nodeSchema.parse(header);
+  const root = asarNodeSchema.parse(header);
   if (root.files === undefined)
     throw new ArtifactReaderFailure("format", "ASAR root has no files table");
   const entries: AsarListedEntry[] = [];
@@ -104,7 +89,7 @@ export const readAsarListing = async (
     // occurrence even when the engine can share a string prefix.
     admitRetainedBytes(units * 2 + 256);
     const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`;
-    const metadata = nodeSchema.parse(raw);
+    const metadata = asarNodeSchema.parse(raw);
     if (
       metadata.files === undefined &&
       metadata.link === undefined &&
diff --git a/src/artifacts/AsarNode.ts b/src/artifacts/AsarNode.ts
new file mode 100644
index 0000000000..b47a22a12b
--- /dev/null
+++ b/src/artifacts/AsarNode.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+/** Shared ASAR node declarations; consumers select only the fields they interpret. */
+export const asarNodeSchema = z.looseObject({
+  files: z
+    .custom>(
+      (value) =>
+        value !== null && typeof value === "object" && !Array.isArray(value),
+    )
+    .optional(),
+  link: z.string().optional(),
+  size: z.number().nonnegative().safe().optional(),
+  offset: z.string().optional(),
+  unpacked: z.boolean().optional(),
+  executable: z.boolean().optional(),
+  integrity: z
+    .looseObject({ algorithm: z.string(), hash: z.string() })
+    .optional(),
+});
diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts
index 65a36a83d9..ac7af3c347 100644
--- a/src/artifacts/DirectoryArtifactReader.ts
+++ b/src/artifacts/DirectoryArtifactReader.ts
@@ -34,6 +34,7 @@ import {
 } from "./ArtifactFileOperation.js";
 import { AnalysisCleanupLease } from "./AnalysisCleanupLease.js";
 import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js";
+import { resolveArtifactSourcePath } from "./ArtifactSelectedSource.js";
 
 export type DirectoryArtifactEntry = ArtifactEntry & {
   readonly kind: "file" | "directory" | "symlink";
@@ -185,7 +186,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
 
   async #resolveRoot(): Promise {
     return (this.#rootPromise ??= (async () => {
-      const root = await realpath(this.#rootPath);
+      const root = await resolveArtifactSourcePath(this.#rootPath);
       const metadata = await lstat(root, { bigint: true });
       if (metadata.isSymbolicLink())
         throw new ArtifactReaderFailure(
diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts
index cb9f47c583..a904b49e0f 100644
--- a/src/artifacts/NativeDmgArtifactReader.ts
+++ b/src/artifacts/NativeDmgArtifactReader.ts
@@ -202,6 +202,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
     const prefix = basename(this.path);
     const entries = directory
       .entries(signal, {
+        onCoverage: (coverage) => options?.onCoverage?.(coverage),
         onOmission: (omission) =>
           options?.onOmission?.({
             ...omission,
diff --git a/src/artifacts/NativeDmgProtocol.test.ts b/src/artifacts/NativeDmgProtocol.test.ts
index b1e5e3772a..e57132a987 100644
--- a/src/artifacts/NativeDmgProtocol.test.ts
+++ b/src/artifacts/NativeDmgProtocol.test.ts
@@ -1,13 +1,95 @@
 import { ExecFileOutputCancelledError } from "../process/ExecFileOutput.js";
 import { ArtifactReaderFailure } from "./ArtifactReader.js";
 import { build } from "plist";
-import { expect, it } from "vitest";
+import { expect, it, vi } from "vitest";
 import {
   NativeDmgArtifactReader,
   type NativeDmgHost,
 } from "./NativeDmgArtifactReader.js";
 import { translateArtifactFailure } from "./ArtifactOperationFailure.js";
 import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { ArtifactReaderTraversal } from "./ArtifactReader.js";
+import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
+
+it.skipIf(process.platform === "win32").each([false, true])(
+  "preserves mounted-directory coverage alongside readable DMG entries (unknown coverage: %s)",
+  async (unknownCoverage) => {
+    let mountPoint: string | undefined;
+    const coverage: ArtifactReaderTraversal[] = [];
+    const producerCoverage: ArtifactReaderTraversal = {
+      status: "unknown",
+      examinedEntries: 3,
+      limitations: [
+        "Directory contains an entry name that is not valid UTF-8.",
+      ],
+    };
+    // APFS rejects invalid UTF-8 basenames at creation. Inject the directory
+    // owner's completed observation while retaining its real entry traversal.
+    const entries = DirectoryArtifactReader.prototype.entries;
+    const traversal = vi
+      .spyOn(DirectoryArtifactReader.prototype, "entries")
+      .mockImplementation(async function* (
+        this: DirectoryArtifactReader,
+        signal,
+        options,
+      ) {
+        yield* entries.call(this, signal, options);
+        if (unknownCoverage && typeof options !== "function")
+          options?.onCoverage?.(producerCoverage);
+      });
+    const host: NativeDmgHost = {
+      run: async (args) => {
+        if (args[0] === "verify" || args[0] === "detach")
+          return { stdout: "", exitCode: 0 };
+        if (args[0] === "attach") {
+          const root = args[args.indexOf("-mountroot") + 1];
+          if (root === undefined) throw new Error("Missing mount root");
+          mountPoint = join(root, "Volume");
+          await mkdir(mountPoint);
+          await writeFile(join(mountPoint, "readable.txt"), "visible");
+        }
+        if (args[0] === "info" || args[0] === "attach") {
+          const entities =
+            mountPoint === undefined
+              ? []
+              : [{ "dev-entry": "/dev/disk800", "mount-point": mountPoint }];
+          return {
+            stdout: build(
+              args[0] === "info"
+                ? {
+                    images:
+                      entities.length === 0
+                        ? []
+                        : [{ "system-entities": entities }],
+                  }
+                : { "system-entities": entities },
+            ),
+            exitCode: 0,
+          };
+        }
+        throw new Error(`Unexpected command ${String(args[0])}`);
+      },
+    };
+    const reader = new NativeDmgArtifactReader("/fixture/image.dmg", {}, host);
+    const paths: string[] = [];
+    try {
+      for await (const entry of reader.entries(undefined, {
+        onCoverage: (value) => coverage.push(value),
+      }))
+        paths.push(entry.path);
+      expect(paths).toContain("image.dmg/Volume/readable.txt");
+      expect(coverage).toEqual(unknownCoverage ? [producerCoverage] : []);
+    } finally {
+      try {
+        await reader.close();
+      } finally {
+        traversal.mockRestore();
+      }
+    }
+  },
+);
 
 it("retains malformed successful hdiutil inventory output as a typed producer parse failure before attach", async () => {
   const calls: string[] = [];
diff --git a/src/artifacts/PyInstallerArtifactReader.ts b/src/artifacts/PyInstallerArtifactReader.ts
index 821bac62c1..698bed446b 100644
--- a/src/artifacts/PyInstallerArtifactReader.ts
+++ b/src/artifacts/PyInstallerArtifactReader.ts
@@ -34,7 +34,7 @@ import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "./ArtifactClassificationPr
 import { PyInstallerPyzStore } from "./pyinstaller/PyInstallerPyzStore.js";
 import { pyInstallerMemberStream } from "./pyinstaller/PyInstallerMemberStream.js";
 import {
-  findPyInstallerCookie,
+  observePyInstallerCookie,
   readCArchiveToc,
   type CArchiveEntry,
   type PyInstallerCookie,
@@ -406,14 +406,17 @@ export class PyInstallerArtifactReader implements ArtifactReader {
     size: number,
     signal?: AbortSignal,
   ): Promise {
-    const found = await findPyInstallerCookie(handle, size, signal);
-    if (found === undefined)
+    const found = await observePyInstallerCookie(handle, size, signal);
+    if (!found.ok) throw failure(found.error);
+    const observed = found.value;
+    if (observed.state === "absent")
       throw new ArtifactReaderFailure(
         "format",
         "PyInstaller CArchive cookie was not found",
       );
-    if (!found.ok) throw failure(found.error);
-    return found.value;
+    if (observed.state === "rejected-candidates")
+      throw failure(observed.failure);
+    return observed.cookie;
   }
 
   async #toc(
diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts
index fcf5600c34..875217f97b 100644
--- a/src/artifacts/ZipArtifactReader.test.ts
+++ b/src/artifacts/ZipArtifactReader.test.ts
@@ -2,7 +2,7 @@ import { writeOrderedZip } from "../../tests/fixtures/artifactEntryOrder.js";
 import { zipSymlinkMetadataFixture } from "../../tests/fixtures/zipSymlinkMetadata.js";
 import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js";
 import { createDeferred } from "../../tests/fixtures/binarySession.js";
-import { readFile, writeFile } from "node:fs/promises";
+import { appendFile, readFile, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { once } from "node:events";
 
@@ -607,3 +607,75 @@ it("joins a late ZIP producer and closes the source despite an enumeration clean
     fileClose.mockRestore();
   }
 });
+
+it.each([false, true])(
+  "rejects ZIP carrier mutation after enumeration (borrowed=%s)",
+  async (borrowed) => {
+    const fixture = await admittedZip();
+    const reader = new ZipArtifactReader(
+      fixture.path,
+      "zip",
+      undefined,
+      borrowed ? fixture : undefined,
+    );
+    try {
+      const entries: ArtifactEntry[] = [];
+      for await (const entry of reader.entries()) entries.push(entry);
+      expect(entries).toHaveLength(1);
+      // Trailing bytes leave the real member and its cached CRC unchanged, but
+      // change the carrier from which the inventory was produced.
+      await appendFile(fixture.path, "changed carrier");
+      await expect(
+        hashReadable(await reader.open(entries[0]!)),
+      ).rejects.toMatchObject({ reason: "integrity" });
+    } finally {
+      await reader.close();
+      await fixture.handle.close();
+    }
+  },
+);
+
+it("rejects ZIP carrier mutation while a directory traversal is paused", async () => {
+  const fixture = await admittedZip();
+  const reader = new ZipArtifactReader(fixture.path, "zip");
+  const entries = reader.entries()[Symbol.asyncIterator]();
+  try {
+    expect((await entries.next()).done).toBe(false);
+    await appendFile(fixture.path, "changed carrier");
+    await expect(entries.next()).rejects.toMatchObject({ reason: "integrity" });
+  } finally {
+    await entries.return?.();
+    await reader.close();
+    await fixture.handle.close();
+  }
+});
+
+it("rejects ZIP carrier mutation during member streaming", async () => {
+  const directory = await createTestTempDirectory("rea-zip-stream-mutation-");
+  const path = join(directory, "stream.zip");
+  const writer = new ZipWriter(new Uint8ArrayWriter());
+  await writer.add(
+    "member.bin",
+    new Uint8ArrayReader(new Uint8Array(512 * 1024)),
+    { level: 0 },
+  );
+  await writeFile(path, await writer.close());
+  const reader = new ZipArtifactReader(path, "zip");
+  try {
+    const entries: ArtifactEntry[] = [];
+    for await (const entry of reader.entries()) entries.push(entry);
+    const stream = await reader.open(entries[0]!);
+    const consume = async () => {
+      let mutated = false;
+      for await (const _chunk of stream) {
+        if (!mutated) {
+          mutated = true;
+          await appendFile(path, "changed carrier");
+        }
+      }
+    };
+    await expect(consume()).rejects.toMatchObject({ reason: "integrity" });
+  } finally {
+    await reader.close();
+  }
+});
diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts
index 9a7fc2fcd3..eff4e072b9 100644
--- a/src/artifacts/ZipArtifactReader.ts
+++ b/src/artifacts/ZipArtifactReader.ts
@@ -40,14 +40,8 @@ import {
   artifactExpansionBudgetBytes,
   MINIMUM_ARTIFACT_EXPANDED_BYTES,
 } from "../domain/artifactExpansionBudget.js";
-import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
-import {
-  NonRegularFileReadError,
-  RegularFileAdmissionFailure,
-  openRegularFile,
-  sameRegularFileState,
-  type StableRegularFileDescriptor,
-} from "../filesystem/RegularFile.js";
+import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
+import { ArtifactFileSource } from "./ArtifactFileSource.js";
 
 const ZIP_UTF8_DECODER = new TextDecoder("utf-8", {
   fatal: true,
@@ -56,17 +50,16 @@ const ZIP_UTF8_DECODER = new TextDecoder("utf-8", {
 
 class NodeFileReader extends Reader {
   #handle: FileHandle | undefined;
-  #owner: OwnedFileHandle | undefined;
+  readonly #source: ArtifactFileSource;
   #initPromise: Promise | undefined;
-  readonly #ownsHandle: boolean;
 
   constructor(
     private readonly path: string,
     private readonly maximumReadBytes?: number,
-    private readonly admitted?: StableRegularFileDescriptor,
+    admitted?: StableRegularFileDescriptor,
   ) {
     super(path);
-    this.#ownsHandle = admitted === undefined;
+    this.#source = new ArtifactFileSource(path, "ZIP", admitted);
   }
 
   override init(): Promise {
@@ -75,55 +68,20 @@ class NodeFileReader extends Reader {
   }
 
   async #initialize(): Promise {
-    let source: StableRegularFileDescriptor;
-    try {
-      source =
-        this.admitted ??
-        (await openRegularFile(this.path, { symlinks: "reject" }));
-      this.#handle = source.handle;
-      if (this.#ownsHandle) this.#owner = new OwnedFileHandle(source.handle);
-    } catch (cause: unknown) {
-      if (cause instanceof RegularFileAdmissionFailure) {
-        this.#owner = cause.owner;
-        const primary =
-          cause.cause instanceof NonRegularFileReadError
-            ? new ArtifactReaderFailure(
-                "format",
-                `ZIP source is not a regular file: ${this.path}`,
-                { cause: cause.cause },
-              )
-            : cause.cause;
-        throw ArtifactReaderFailure.withCleanup(
-          primary,
-          ArtifactReaderFailure.cleanupObservation(
-            cause.cleanupCause,
-            this.path,
-          ),
-        );
-      }
-      if (cause instanceof NonRegularFileReadError)
-        throw new ArtifactReaderFailure(
-          "format",
-          `ZIP source is not a regular file: ${this.path}`,
-          { cause },
-        );
-      throw cause;
-    }
-    if (this.admitted !== undefined) {
-      const metadata = await source.handle.stat();
-      if (!sameRegularFileState(source.initial, metadata))
-        throw new ArtifactReaderFailure(
-          "integrity",
-          `ZIP source identity changed before reading: ${this.path}`,
-        );
-    }
+    const source = await this.#source.read();
+    this.#handle = source.handle;
     this.size = source.initial.size;
   }
 
+  async verify(): Promise {
+    await this.#source.read();
+  }
+
   override async readUint8Array(
     index: number,
     length: number,
   ): Promise {
+    await this.verify();
     const handle = this.#handle;
     if (handle === undefined) throw new Error("ZIP reader is not initialized");
     const readBytes = Math.min(length, this.size - index);
@@ -158,15 +116,13 @@ class NodeFileReader extends Reader {
         );
       offset += bytesRead;
     }
+    await this.verify();
     return bytes;
   }
 
   async closeHandle(): Promise {
-    if (!this.#ownsHandle) return;
     await this.#initPromise?.catch(() => undefined);
-    if (this.#owner === undefined) return;
-    await this.#owner.close();
-    this.#owner = undefined;
+    await this.#source.close();
     this.#handle = undefined;
   }
 }
@@ -216,6 +172,7 @@ export class ZipArtifactReader implements ArtifactReader {
     this.#enumerations.add(enumeration);
     try {
       for await (const entry of enumeration) {
+        await this.#source.verify();
         this.#assertOpen(signal);
         // ZIP member identity is its directory occurrence, not its filename.
         // Logical path uniqueness belongs to the artifact inventory boundary.
@@ -253,6 +210,7 @@ export class ZipArtifactReader implements ArtifactReader {
           });
         yield metadata;
       }
+      await this.#source.verify();
     } finally {
       this.#enumerations.delete(enumeration);
     }
@@ -280,7 +238,11 @@ export class ZipArtifactReader implements ArtifactReader {
           "Encrypted ZIP entry is unsupported",
         ),
       );
-    const { output, completion } = extractStream(source, signal);
+    const { output, completion } = extractStream(
+      source,
+      () => this.#source.verify(),
+      signal,
+    );
     this.#streams.set(output, completion);
     // Producer completion can precede consumption of a small buffered member.
     // Retain the output until both it and its producer have finished.
@@ -435,6 +397,7 @@ const decodeStrictZipUtf8 = (bytes: Uint8Array, message: string): string => {
 
 const extractStream = (
   entry: FileEntry,
+  verifySource: () => Promise,
   signal?: AbortSignal,
 ): { readonly output: Readable; readonly completion: Promise } => {
   const output = new PassThrough();
@@ -465,20 +428,24 @@ const extractStream = (
       if (!output.write(Buffer.from(chunk)))
         await once(output, "drain", { signal: controller.signal });
     },
-    close: () => {
+    close: async () => {
+      await verifySource();
+      assertArtifactReadActive(signal);
       output.end();
     },
     abort: (reason) => {
       output.destroy(toError(typedZipFailure(reason)));
     },
   });
-  const completion = entry
-    .getData(writable, {
-      signal: controller.signal,
-      checkSignature: true,
-      checkOverlappingEntry: true,
-      onprogress: () => assertArtifactReadActive(signal),
-    })
+  const completion = verifySource()
+    .then(() =>
+      entry.getData(writable, {
+        signal: controller.signal,
+        checkSignature: true,
+        checkOverlappingEntry: true,
+        onprogress: () => assertArtifactReadActive(signal),
+      }),
+    )
     .then(
       () => undefined,
       (cause: unknown) => {
diff --git a/src/artifacts/apple/BinaryPlistMaterializer.test.ts b/src/artifacts/apple/BinaryPlistMaterializer.test.ts
index 05c8277647..65c60fe0e3 100644
--- a/src/artifacts/apple/BinaryPlistMaterializer.test.ts
+++ b/src/artifacts/apple/BinaryPlistMaterializer.test.ts
@@ -6,6 +6,7 @@ import { scanBinaryPlistScalars } from "./PlistScalars.js";
 import { plistUidValue } from "../../domain/apple/plistUid.js";
 import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
 import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
+import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
 
 const source = (objects: readonly Buffer[]): Buffer => {
   const table =
@@ -84,6 +85,37 @@ it("keeps a source UID distinct from an ordinary UID-shaped dictionary", () => {
   expect(plistUidValue(value[1])).toBeUndefined();
 });
 
+it("preserves binary dictionary identity through scalar and number projections", () => {
+  const bytes = source([
+    Buffer.from([0xa2, 1, 2]),
+    Buffer.from([0x80, 7]),
+    Buffer.from([0xd1, 3, 4]),
+    text("CF$UID"),
+    Buffer.from([0x10, 7]),
+  ]);
+  const metadata = scanBinaryPlistScalars(bytes);
+  const numbers = createPlistNumberProjection(metadata.literals);
+  const projected = numbers.project(projectPlistValue(decode(bytes)).value);
+  if (!Array.isArray(projected)) throw new Error("Expected projected array");
+  expect(projected).toEqual([{ UID: 7 }, { CF$UID: 7 }]);
+  expect(plistUidValue(projected[0])).toBe(7);
+  expect(plistUidValue(projected[1])).toBeUndefined();
+  // Reprojection must keep the source dictionary distinct from XML's convention.
+  const repeated = numbers.project(projected);
+  if (!Array.isArray(repeated)) throw new Error("Expected projected array");
+  expect(plistUidValue(repeated[1])).toBeUndefined();
+});
+
+it("retains XML's Foundation reference convention through number projection", () => {
+  const decoded = parseXmlPropertyList(
+    "CF$UID7",
+  );
+  const numbers = createPlistNumberProjection(decoded.numberLiterals);
+  const projected = numbers.project(projectPlistValue(decoded.value).value);
+  expect(projected).toEqual({ CF$UID: 7 });
+  expect(plistUidValue(projected)).toBe(7);
+});
+
 it("materializes normalized three-byte tables from a nonzero root without losing source numbers", () => {
   const tableOffset = 36;
   const bytes = Buffer.alloc(tableOffset + 9 + 32);
diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts
index 6683099533..3fa89fd1a1 100644
--- a/src/artifacts/apple/MachoLoadCommandReader.test.ts
+++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts
@@ -740,6 +740,33 @@ it("selects matching arm64e subtype slices when tracing a mixed FAT image", asyn
   ]);
 });
 
+it.each([false, true])(
+  "rejects overlapping FAT declarations before parsing payloads (hidden row: %s)",
+  async (hidden) => {
+    const bytes = Buffer.from(
+      fatImage([
+        { cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
+        { cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
+      ]),
+    );
+    bytes.writeUInt32BE(bytes.readUInt32BE(16), 36);
+    bytes.writeUInt32BE(bytes.readUInt32BE(20), 40);
+    if (hidden) bytes.writeUInt32BE(1, 4);
+    const readAt = vi.fn(readerOf(bytes));
+    expect(await readMachoImage(readAt, bytes.length)).toEqual({
+      status: "malformed",
+      reason: "Mach-O slices 0 and 1 overlap.",
+    });
+    // Header identity probes are allowed; load-command payloads are not.
+    const firstOffset = bytes.readUInt32BE(16);
+    expect(
+      readAt.mock.calls.some(
+        ([offset, length]) => offset === firstOffset && length > 4,
+      ),
+    ).toBe(false);
+  },
+);
+
 it("keeps Apple's hidden ARM64 FAT row outside the raw declared slice list", async () => {
   const armv7 = { type: 12, subtype: 9 };
   const bytes = Buffer.from(
diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts
index 561959d931..172b64488b 100644
--- a/src/artifacts/apple/MachoLoadCommandReader.ts
+++ b/src/artifacts/apple/MachoLoadCommandReader.ts
@@ -28,6 +28,7 @@ import {
 } from "../../domain/apple/machoPlatform.js";
 import {
   classifyFatContainerIdentity,
+  findOverlappingFatSlices,
   hasMachoMagic,
   readFatContainerHeader,
   readFatHeaderPrefix,
@@ -164,6 +165,7 @@ export const readMachoImage = async (
       }
       if (identity.state === "needs-slice-header")
         return { status: "not-mach-o" };
+      assertDistinctFatSlices(header.slices);
       const hiddenSlice = await readAppleHiddenArm64Slice(readAt, size, header);
       return {
         status: "parsed",
@@ -187,6 +189,18 @@ export const readMachoImage = async (
   }
 };
 
+/** Reject aliasing at the declaration owner before parsing any slice payload. */
+const assertDistinctFatSlices = (
+  declarations: readonly FatSliceDeclaration[],
+): void => {
+  const overlap = findOverlappingFatSlices(declarations);
+  if (overlap !== null)
+    throw new MachoFormatIssue(
+      "malformed",
+      `Mach-O slices ${overlap[0].index} and ${overlap[1].index} overlap.`,
+    );
+};
+
 /**
  * Apple lipo stores one hidden ARM64 row after the declared 32-bit FAT table.
  * It remains separate from the raw nfat_arch declarations and is only admitted
@@ -221,6 +235,7 @@ const readAppleHiddenArm64Slice = async (
   if (declaration.cpuType !== CPU_TYPE_ARM64) return null;
   const rangeIssue = validateFatSlice(declaration, fileSize, end);
   if (rangeIssue !== null) throw new MachoFormatIssue("malformed", rangeIssue);
+  assertDistinctFatSlices([...header.slices, declaration]);
   const slice = await readSlice(readAt, declaration.offset, declaration.size, {
     fatCpuType: declaration.cpuType,
     fatCpuSubtype: declaration.cpuSubtype,
diff --git a/src/artifacts/apple/machoContainer.test.ts b/src/artifacts/apple/machoContainer.test.ts
index 40403e397c..8683a3e778 100644
--- a/src/artifacts/apple/machoContainer.test.ts
+++ b/src/artifacts/apple/machoContainer.test.ts
@@ -12,6 +12,7 @@ import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js";
 import { classifyArtifactBytes } from "../inventory/ArtifactGraphConstruction.js";
 import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js";
 import { parseExecutableHeader } from "../../domain/binaryTarget.js";
+import { isJvmClassFile } from "../../domain/apple/jvmClassFile.js";
 import {
   classifyFatContainerIdentity,
   classifyFatContainerEvidence,
@@ -313,6 +314,39 @@ describe("FAT Mach-O identity", () => {
 });
 
 describe("FAT slice header identity and bounds", () => {
+  const classWithInterface = (reference: number): Buffer => {
+    // Retain enough bytes for the coincident FAT table claim to be readable;
+    // otherwise rejecting JVM identity would still yield not-fat on truncation.
+    const source = javaClassFile(2048, true);
+    const interfaceCountOffset = source.length - 8;
+    const bytes = Buffer.concat([
+      source.subarray(0, interfaceCountOffset + 2),
+      Buffer.alloc(2),
+      source.subarray(interfaceCountOffset + 2),
+    ]);
+    bytes.writeUInt16BE(1, interfaceCountOffset);
+    bytes.writeUInt16BE(reference, interfaceCountOffset + 2);
+    return bytes;
+  };
+
+  it("preserves JVM identity for class-tagged interface references", () => {
+    const bytes = classWithInterface(3);
+    expect(isJvmClassFile(bytes)).toBe(true);
+    expect(readFatContainerHeader(bytes, bytes.length)).toEqual({
+      state: "not-fat",
+    });
+  });
+
+  it.each([0, 2, 8, 65535])(
+    "does not suppress FAT examination for invalid JVM interface index %i",
+    (reference) => {
+      // Zero, a UTF-8 entry, and out-of-pool indices are not class references.
+      const bytes = classWithInterface(reference);
+      expect(isJvmClassFile(bytes)).toBe(false);
+      expect(readFatContainerHeader(bytes, bytes.length).state).toBe("parsed");
+    },
+  );
+
   it("keeps Java CAFEBABE class files outside FAT and Mach-O identity", async () => {
     const bytes = javaClassFile();
     expect(readFatContainerHeader(bytes, bytes.byteLength)).toEqual({
diff --git a/src/artifacts/extraction/ArtifactExtraction.test.ts b/src/artifacts/extraction/ArtifactExtraction.test.ts
index c4250d3b1a..e13b877bde 100644
--- a/src/artifacts/extraction/ArtifactExtraction.test.ts
+++ b/src/artifacts/extraction/ArtifactExtraction.test.ts
@@ -375,7 +375,7 @@ it.each([false, true] as const)(
 
 vi.mock("../pyinstaller/CArchiveLayout.js", async (importOriginal) => ({
   ...(await importOriginal()),
-  findPyInstallerCookie: () =>
+  observePyInstallerCookie: () =>
     Promise.reject(new ArtifactReaderFailure("format", "detection failed")),
 }));
 
diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts
index eea3bfbc2a..b0efcd5a0d 100644
--- a/src/artifacts/extraction/ArtifactExtraction.ts
+++ b/src/artifacts/extraction/ArtifactExtraction.ts
@@ -1,5 +1,9 @@
 import { assertAdmittedRootDigest } from "../inventory/classify.js";
-import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
+import { assertArtifactFileSelectedSource } from "../ArtifactFileSource.js";
+import {
+  assertArtifactSelectedSource,
+  resolveArtifactSourcePath,
+} from "../ArtifactSelectedSource.js";
 import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js";
 import { absoluteCallerPath } from "../../filesystem/callerPath.js";
 import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
@@ -52,7 +56,7 @@ import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.j
 import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
 import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
 import { scanArtifactInventoryForExtractionInScope } from "../inventory/ArtifactInventory.js";
-import type { TransferredZipInventorySource } from "../inventory/scanCanonical.js";
+import type { TransferredArtifactInventorySource } from "../inventory/scanCanonical.js";
 import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
 
 /** Local extraction input with the output root chosen by the adapter. */
@@ -82,11 +86,11 @@ const extractArtifactInScope = async (
 ): Promise => {
   // Cancellation wins over every later refusal, including unsupported formats.
   abortIfNeeded(signal);
-  const sourcePath = await realpath(input.inputPath);
+  const sourcePath = await resolveArtifactSourcePath(input.inputPath);
   if (input.expectedRootSha256 !== undefined)
     await assertArtifactSelectedSource(sourcePath, input.selectedPath);
   const admission = await requireExtractableFormat(sourcePath, input, signal);
-  const { snapshot, zipSource } =
+  const { snapshot, transferredSource } =
     await scanArtifactInventoryForExtractionInScope(
       input.inputPath,
       {
@@ -98,32 +102,32 @@ const extractArtifactInScope = async (
         asarHeaderAdmission:
           input.asarHeaderAdmission ??
           resolveAsarHeaderAdmission(input.environment),
-        integrity: { mode: input.integrityPolicy },
+        integrity: input.integrityPolicy,
       },
       admission.probeFacts,
     );
-  const activeZipSource =
-    zipSource === undefined
+  const activeSource =
+    transferredSource === undefined
       ? undefined
-      : { ...zipSource, releaseAttempted: false };
+      : { ...transferredSource, releaseAttempted: false };
   try {
     return await materializeArtifactInventoryInScope(
       input,
       sourcePath,
       snapshot,
       signal,
-      activeZipSource,
+      activeSource,
       admission.probeFacts,
     );
   } catch (cause: unknown) {
-    if (activeZipSource !== undefined && cause instanceof ArtifactReaderFailure)
+    if (activeSource !== undefined && cause instanceof ArtifactReaderFailure)
       cause.retainPartialObservation({
         kind: "artifact-inventory",
         inventory: snapshot,
       });
-    const attempt = await releaseTransferredZipSource(
+    const attempt = await releaseTransferredArtifactSource(
       input.resourceScope,
-      activeZipSource,
+      activeSource,
     );
     if (attempt?.kind === "failed")
       throw input.resourceScope.cleanupFailure(
@@ -135,32 +139,33 @@ const extractArtifactInScope = async (
   }
 };
 
-interface ActiveZipInventorySource extends TransferredZipInventorySource {
+interface ActiveArtifactInventorySource extends TransferredArtifactInventorySource {
   releaseAttempted: boolean;
 }
 
-const releaseTransferredZipSource = async (
+const releaseTransferredArtifactSource = async (
   resourceScope: ArtifactResourceScope,
-  zipSource: ActiveZipInventorySource | undefined,
+  transferredSource: ActiveArtifactInventorySource | undefined,
 ): Promise => {
-  if (zipSource === undefined || zipSource.releaseAttempted) return undefined;
-  zipSource.releaseAttempted = true;
-  return resourceScope.release(zipSource.owner);
+  if (transferredSource === undefined || transferredSource.releaseAttempted)
+    return undefined;
+  transferredSource.releaseAttempted = true;
+  return resourceScope.release(transferredSource.owner);
 };
 
-/** Materialize a scanned inventory, borrowing its active ZIP source when supplied. */
+/** Materialize a scanned inventory, borrowing its admitted source when supplied. */
 export const materializeArtifactInventoryInScope = async (
   input: ArtifactExtractionInput,
   sourcePath: string,
   snapshot: ArtifactInventorySnapshot,
   signal?: AbortSignal,
-  zipSource?: ActiveZipInventorySource,
+  transferredSource?: ActiveArtifactInventorySource,
   probeFacts?: ArtifactEmbeddedFormatProbeFacts,
 ): Promise => {
   abortIfNeeded(signal);
   // A directory source must never acquire its own output as a new member on
   // the verification traversal. Resolve the selected parent before any write.
-  if (zipSource === undefined) {
+  if (transferredSource === undefined) {
     const canonicalSource = await realpath(sourcePath);
     if ((await lstat(canonicalSource)).isDirectory()) {
       const { canonicalOutput } = await SafeOutputTree.resolveDestination(
@@ -271,7 +276,7 @@ export const materializeArtifactInventoryInScope = async (
     inventory: snapshot,
     selected,
     signal,
-    zipSource,
+    transferredSource,
     probeFacts,
   });
 };
@@ -295,7 +300,7 @@ const materializeSelection = async ({
   inventory,
   selected,
   signal,
-  zipSource,
+  transferredSource,
   probeFacts,
 }: {
   readonly input: ArtifactExtractionInput;
@@ -303,7 +308,7 @@ const materializeSelection = async ({
   readonly inventory: ArtifactInventorySnapshot;
   readonly selected: readonly SelectedOccurrence[];
   readonly signal: AbortSignal | undefined;
-  readonly zipSource: ActiveZipInventorySource | undefined;
+  readonly transferredSource: ActiveArtifactInventorySource | undefined;
   readonly probeFacts: ArtifactEmbeddedFormatProbeFacts | undefined;
 }): Promise => {
   const byPath = new Map();
@@ -316,12 +321,18 @@ const materializeSelection = async ({
       );
     byPath.set(path, item);
   }
+  if (transferredSource !== undefined && "kind" in transferredSource.admission)
+    await assertArtifactFileSelectedSource(
+      sourcePath,
+      transferredSource.admission.source,
+      "Artifact extraction",
+    );
   const reader = await createReader(
     sourcePath,
     input,
     inventory.manifest.root_format,
     signal,
-    zipSource,
+    transferredSource,
     probeFacts,
   );
   const readerOwner: ArtifactResourceOwner = {
@@ -362,9 +373,9 @@ const materializeSelection = async ({
         [closeAttempt],
         { kind: "artifact-inventory", inventory },
       );
-    const sourceClose = await releaseTransferredZipSource(
+    const sourceClose = await releaseTransferredArtifactSource(
       input.resourceScope,
-      zipSource,
+      transferredSource,
     );
     if (sourceClose?.kind === "failed")
       throw input.resourceScope.cleanupFailure(
@@ -372,7 +383,7 @@ const materializeSelection = async ({
         [sourceClose],
         { kind: "artifact-inventory", inventory },
       );
-    if (zipSource !== undefined) abortIfNeeded(signal);
+    if (transferredSource !== undefined) abortIfNeeded(signal);
     extracted.sort((left, right) =>
       compareUnicodeCodePoints(left.relative_path, right.relative_path),
     );
@@ -644,13 +655,12 @@ const createReader = async (
   >,
   containerFormat: ArtifactGraphManifest["root_format"],
   signal?: AbortSignal,
-  zipSource?: ActiveZipInventorySource,
+  transferredSource?: ActiveArtifactInventorySource,
   probeFacts?: ArtifactEmbeddedFormatProbeFacts,
 ): Promise => {
-  const transferredZip =
-    zipSource !== undefined && isZipPackageFormat(containerFormat);
   let format: ArtifactReaderFormat;
-  if (transferredZip) format = containerFormat;
+  if (transferredSource !== undefined && isZipPackageFormat(containerFormat))
+    format = containerFormat;
   else if (
     containerFormat === "godot-pck" ||
     containerFormat === "pyinstaller" ||
@@ -671,7 +681,7 @@ const createReader = async (
     environment: input.environment,
     unreadableDirectories: "fail",
     selectedPath: absoluteCallerPath(input.selectedPath ?? input.inputPath),
-    admission: transferredZip ? zipSource.source : undefined,
+    admission: transferredSource?.admission,
     asarHeaderAdmission:
       format === "asar"
         ? (input.asarHeaderAdmission ??
diff --git a/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts b/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts
new file mode 100644
index 0000000000..229499d76a
--- /dev/null
+++ b/src/artifacts/extraction/EmbeddedArtifactExtraction.test.ts
@@ -0,0 +1,348 @@
+import {
+  appendFile,
+  mkdir,
+  rename,
+  readFile,
+  stat,
+  symlink,
+  unlink,
+  writeFile,
+} from "node:fs/promises";
+import { createHash } from "node:crypto";
+import { join } from "node:path";
+import { expect, it, vi } from "vitest";
+import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
+import { buildGodotPck } from "../../../tests/fixtures/godotPck.js";
+import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
+import { GodotPckArtifactReader } from "../GodotPckArtifactReader.js";
+import { NodeSeaArtifactReader } from "../NodeSeaArtifactReader.js";
+import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
+import { cArchiveExecutable } from "../pyinstaller/CArchive.fixture.js";
+import { nodeSeaElf } from "../nodeSea/NodeSea.fixture.js";
+import * as cArchive from "../pyinstaller/CArchiveLayout.js";
+import * as nodeSea from "../nodeSea/NodeSeaBlob.js";
+import * as godot from "../GodotPckLocation.js";
+import { extractArtifact } from "./ArtifactExtraction.js";
+
+const cases = [
+  {
+    format: "pyinstaller" as const,
+    inputFormat: "pe" as const,
+    reader: PyInstallerArtifactReader as {
+      prototype: { close(): Promise };
+    },
+    bytes: () =>
+      cArchiveExecutable({
+        carrier: "pe",
+        layout: "2.1",
+        pythonVersion: 314,
+        members: [
+          {
+            name: String.raw`pkg\payload.txt`,
+            typeCode: "x",
+            payload: Buffer.from("fixture"),
+            compress: false,
+          },
+        ],
+      }),
+    member: "pkg/payload.txt",
+    observe: () => vi.spyOn(cArchive, "observePyInstallerCookie"),
+  },
+  {
+    format: "node-sea" as const,
+    inputFormat: "elf" as const,
+    reader: NodeSeaArtifactReader as { prototype: { close(): Promise } },
+    bytes: () => nodeSeaElf({ codePath: "payload.js", main: "fixture" }),
+    member: "payload.js",
+    observe: () => vi.spyOn(nodeSea, "decodeNodeSeaBlob"),
+  },
+  {
+    format: "godot-pck" as const,
+    inputFormat: "godot-pck" as const,
+    reader: GodotPckArtifactReader as { prototype: { close(): Promise } },
+    bytes: () => buildGodotPck(3, [{ path: "payload.txt", data: "fixture" }]),
+    member: "payload.txt",
+    observe: () => vi.spyOn(godot, "locateGodotPck"),
+  },
+];
+
+it.each(cases)(
+  "extracts $format using the inventory admission once",
+  async ({ format, inputFormat, reader, bytes, member, observe }) => {
+    const directory = await createTestTempDirectory("rea-embedded-extraction-");
+    const path = join(directory, "source");
+    const output = join(directory, "out");
+    const carrier = bytes();
+    await writeFile(path, carrier);
+    const scope = new ArtifactResourceScope();
+    const observation = observe();
+    const close = vi.spyOn(reader.prototype, "close");
+    const release = vi.spyOn(scope, "release");
+    try {
+      const result = await extractArtifact({
+        inputPath: path,
+        inputFormat,
+        outputRoot: output,
+        environment: {},
+        integrityPolicy: "fail",
+        resourceScope: scope,
+        expectedRootSha256: createHash("sha256").update(carrier).digest("hex"),
+        selectedPath: path,
+      });
+      expect(result.manifest.root_format).toBe(format);
+      expect(await readFile(join(output, member), "utf8")).toBe("fixture");
+      expect(observation).toHaveBeenCalledTimes(1);
+      expect(close).toHaveBeenCalledTimes(2);
+      const rootRelease = release.mock.calls.findIndex(
+        ([owner]) =>
+          owner.kind === "file-handle" &&
+          owner.resource === `root artifact descriptor for ${path}`,
+      );
+      expect(rootRelease).toBeGreaterThanOrEqual(0);
+      expect(
+        release.mock.calls
+          .slice(0, rootRelease)
+          .filter(([owner]) => owner.kind === "reader"),
+      ).toHaveLength(2);
+      expect(scope.hasPending).toBe(false);
+    } finally {
+      await scope.close();
+      observation.mockRestore();
+      close.mockRestore();
+      release.mockRestore();
+    }
+  },
+);
+
+it.each(cases)(
+  "rejects changed $format bytes before materializing admitted facts",
+  async ({ inputFormat, reader, bytes }) => {
+    const directory = await createTestTempDirectory(
+      "rea-embedded-extraction-stale-",
+    );
+    const path = join(directory, "source");
+    const output = join(directory, "out");
+    await writeFile(path, bytes());
+    const scope = new ArtifactResourceScope();
+    const original = reader.prototype.close;
+    let closes = 0;
+    const close = vi
+      .spyOn(reader.prototype, "close")
+      .mockImplementation(async function (this: { close(): Promise }) {
+        await original.call(this);
+        if (++closes === 1) await appendFile(path, "changed");
+      });
+    try {
+      await expect(
+        extractArtifact({
+          inputPath: path,
+          inputFormat,
+          outputRoot: output,
+          environment: {},
+          integrityPolicy: "fail",
+          resourceScope: scope,
+        }),
+      ).rejects.toMatchObject({ reason: "integrity" });
+      await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
+      expect(scope.hasPending).toBe(false);
+    } finally {
+      await scope.close();
+      close.mockRestore();
+    }
+  },
+);
+
+it.each(
+  cases.flatMap((fixture) =>
+    ["cancel", "retry", "mixed"].map((mode) => ({ ...fixture, mode })),
+  ),
+)(
+  "retains $format finalization semantics during $mode",
+  async ({ inputFormat, reader, bytes, mode }) => {
+    const directory = await createTestTempDirectory(
+      "rea-embedded-extraction-cleanup-",
+    );
+    const path = join(directory, "source");
+    const output = join(directory, "out");
+    await writeFile(path, bytes());
+    const scope = new ArtifactResourceScope();
+    const controller = new AbortController();
+    const failure = new Error("root close denied");
+    const originalReaderClose = reader.prototype.close;
+    let readerAttempts = 0;
+    const readerClose = vi
+      .spyOn(reader.prototype, "close")
+      .mockImplementation(async function (this: { close(): Promise }) {
+        if (++readerAttempts === 2 && mode === "mixed")
+          throw new Error("reader close denied");
+        await originalReaderClose.call(this);
+      });
+    const originalRelease = scope.release.bind(scope);
+    let rootAttempts = 0;
+    let rootClose: { mockRestore(): void } | undefined;
+    const release = vi
+      .spyOn(scope, "release")
+      .mockImplementation(async (owner) => {
+        if (
+          owner.kind === "file-handle" &&
+          owner.resource === `root artifact descriptor for ${path}`
+        ) {
+          const originalClose = owner.handle.close.bind(owner.handle);
+          rootClose = vi
+            .spyOn(owner.handle, "close")
+            .mockImplementation(async () => {
+              rootAttempts++;
+              expect(readerAttempts).toBeGreaterThanOrEqual(2);
+              if (rootAttempts === 1) {
+                if (mode === "cancel") controller.abort(failure);
+                else throw failure;
+              }
+              await originalClose();
+            });
+        }
+        return originalRelease(owner);
+      });
+    try {
+      const execution = extractArtifact(
+        {
+          inputPath: path,
+          inputFormat,
+          outputRoot: output,
+          environment: {},
+          integrityPolicy: "fail",
+          resourceScope: scope,
+        },
+        controller.signal,
+      );
+      if (mode === "cancel")
+        await expect(execution).rejects.toMatchObject({
+          reason: "cancelled",
+          cause: failure,
+        });
+      else
+        await expect(execution).rejects.toMatchObject({
+          cleanup: {
+            resources: expect.arrayContaining([
+              `root artifact descriptor for ${path}`,
+            ]),
+          },
+        });
+      await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
+      if (mode === "mixed")
+        expect(scope.cleanupResources).toEqual([
+          `artifact reader for ${path}`,
+          `root artifact descriptor for ${path}`,
+        ]);
+      else if (mode === "retry")
+        expect(scope.cleanupResources).toEqual([
+          `root artifact descriptor for ${path}`,
+        ]);
+      await scope.close();
+      expect(rootAttempts).toBe(mode === "cancel" ? 1 : 2);
+      expect(readerAttempts).toBe(mode === "mixed" ? 3 : 2);
+      expect(scope.hasPending).toBe(false);
+    } finally {
+      await scope.close().catch(() => undefined);
+      rootClose?.mockRestore();
+      release.mockRestore();
+      readerClose.mockRestore();
+    }
+  },
+);
+
+it.each(cases)(
+  "rejects a retargeted selected path before publishing $format extraction",
+  async ({ inputFormat, reader, bytes }) => {
+    const directory = await createTestTempDirectory(
+      "rea-embedded-extraction-selection-",
+    );
+    const path = join(directory, "source");
+    const selectedPath = join(directory, "selected");
+    const replacement = join(directory, "replacement");
+    const output = join(directory, "out");
+    const carrier = bytes();
+    await writeFile(path, carrier);
+    await writeFile(replacement, carrier);
+    await symlink(path, selectedPath);
+    const scope = new ArtifactResourceScope();
+    const originalClose = reader.prototype.close;
+    let closes = 0;
+    const close = vi
+      .spyOn(reader.prototype, "close")
+      .mockImplementation(async function (this: { close(): Promise }) {
+        await originalClose.call(this);
+        if (++closes === 2) {
+          await unlink(selectedPath);
+          await symlink(replacement, selectedPath);
+        }
+      });
+    try {
+      await expect(
+        extractArtifact({
+          inputPath: path,
+          selectedPath,
+          expectedRootSha256: createHash("sha256")
+            .update(carrier)
+            .digest("hex"),
+          inputFormat,
+          outputRoot: output,
+          environment: {},
+          integrityPolicy: "fail",
+          resourceScope: scope,
+        }),
+      ).rejects.toMatchObject({ reason: "integrity" });
+      await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
+      expect(closes).toBe(2);
+      expect(scope.hasPending).toBe(false);
+    } finally {
+      await scope.close();
+      close.mockRestore();
+    }
+  },
+);
+
+it.each(cases)(
+  "rejects a replacement at the admitted $format pathname",
+  async ({ inputFormat, reader, bytes }) => {
+    const directory = await createTestTempDirectory(
+      "rea-embedded-extraction-replacement-",
+    );
+    const selectedDirectory = join(directory, "selected");
+    await mkdir(selectedDirectory);
+    const path = join(selectedDirectory, "source");
+    const output = join(directory, "out");
+    await writeFile(path, bytes());
+    const scope = new ArtifactResourceScope();
+    const originalClose = reader.prototype.close;
+    let closes = 0;
+    const close = vi
+      .spyOn(reader.prototype, "close")
+      .mockImplementation(async function (this: { close(): Promise }) {
+        await originalClose.call(this);
+        if (++closes === 1) {
+          // Moving the parent leaves the admitted regular file's metadata intact.
+          await rename(selectedDirectory, join(directory, "admitted"));
+          await mkdir(selectedDirectory);
+          await writeFile(path, Buffer.from("replacement"));
+        }
+      });
+    try {
+      await expect(
+        extractArtifact({
+          inputPath: path,
+          inputFormat,
+          outputRoot: output,
+          environment: {},
+          integrityPolicy: "fail",
+          resourceScope: scope,
+        }),
+      ).rejects.toMatchObject({ reason: "integrity" });
+      await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" });
+      expect(closes).toBe(1);
+      expect(scope.hasPending).toBe(false);
+    } finally {
+      await scope.close();
+      close.mockRestore();
+    }
+  },
+);
diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts
index a8959683b7..d3913bfb85 100644
--- a/src/artifacts/inventory/ArtifactInventory.ts
+++ b/src/artifacts/inventory/ArtifactInventory.ts
@@ -1,5 +1,5 @@
 import { absoluteCallerPath } from "../../filesystem/callerPath.js";
-import { realpath } from "node:fs/promises";
+import { resolveArtifactSourcePath } from "../ArtifactSelectedSource.js";
 
 import {
   artifactInventoryResultSchema,
@@ -9,7 +9,7 @@ import { abortIfNeeded } from "../ArtifactHash.js";
 import {
   scanCanonicalArtifactInventoryInScope,
   scanCanonicalArtifactInventoryForExtractionInScope,
-  type TransferredZipInventorySource,
+  type TransferredArtifactInventorySource,
 } from "./scanCanonical.js";
 import type { ArtifactInventoryOptions } from "./types.js";
 import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
@@ -31,7 +31,7 @@ export const scanArtifactInventoryInScope = async (
   options: ArtifactInventoryOptions,
 ): Promise => {
   abortIfNeeded(options.signal);
-  const path = await realpath(inputPath);
+  const path = await resolveArtifactSourcePath(inputPath);
   return scanCanonicalArtifactInventoryInScope(
     path,
     options,
@@ -46,10 +46,10 @@ export const scanArtifactInventoryForExtractionInScope = async (
   probeFacts?: ArtifactEmbeddedFormatProbeFacts,
 ): Promise<{
   readonly snapshot: ArtifactInventorySnapshot;
-  readonly zipSource: TransferredZipInventorySource | undefined;
+  readonly transferredSource: TransferredArtifactInventorySource | undefined;
 }> => {
   abortIfNeeded(options.signal);
-  const path = await realpath(inputPath);
+  const path = await resolveArtifactSourcePath(inputPath);
   return scanCanonicalArtifactInventoryForExtractionInScope(
     path,
     options,
diff --git a/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts b/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts
new file mode 100644
index 0000000000..d3f4b6b5af
--- /dev/null
+++ b/src/artifacts/inventory/ArtifactInventoryFinalization.test.ts
@@ -0,0 +1,187 @@
+import { createHash } from "node:crypto";
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
+import { afterEach, expect, it, vi } from "vitest";
+import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
+import { ArtifactProvider } from "../ArtifactProvider.js";
+import { ArtifactReaderFailure } from "../ArtifactReader.js";
+import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
+import { ZipArtifactReader } from "../ZipArtifactReader.js";
+import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
+import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
+import {
+  scanCanonicalArtifactInventoryForExtractionInScope,
+  scanCanonicalArtifactInventoryInScope,
+} from "./scanCanonical.js";
+
+afterEach(() => vi.restoreAllMocks());
+
+const zipFixture = async () => {
+  const directory = await createTestTempDirectory(
+    "rea-inventory-finalization-",
+  );
+  const path = join(directory, "input.zip");
+  const writer = new ZipWriter(new Uint8ArrayWriter());
+  await writer.add("member.txt", new TextReader("retained member evidence"));
+  const bytes = await writer.close();
+  await writeFile(path, bytes);
+  return { path, sha256: createHash("sha256").update(bytes).digest("hex") };
+};
+
+it.each([
+  { transfer: false, readerFails: false, rootFails: false, cancelAt: "reader" },
+  { transfer: false, readerFails: false, rootFails: false, cancelAt: "root" },
+  { transfer: false, readerFails: true, rootFails: true, cancelAt: "reader" },
+  { transfer: false, readerFails: true, rootFails: true, cancelAt: "root" },
+  { transfer: true, readerFails: false, rootFails: false, cancelAt: "reader" },
+  { transfer: true, readerFails: false, rootFails: true, cancelAt: "reader" },
+  { transfer: true, readerFails: true, rootFails: false, cancelAt: "reader" },
+])(
+  "retains completed inventory and exact cleanup owners: %j",
+  async (scenario) => {
+    const { path, sha256 } = await zipFixture();
+    const scope = new ArtifactResourceScope();
+    const controller = new AbortController();
+    const reason = { request: "cancel during inventory finalization" };
+    let retry = false;
+    const readerClose = ZipArtifactReader.prototype.close;
+    const reader = vi
+      .spyOn(ZipArtifactReader.prototype, "close")
+      .mockImplementation(async function (this: ZipArtifactReader) {
+        if (scenario.cancelAt === "reader") controller.abort(reason);
+        if (scenario.readerFails && !retry)
+          throw new Error("reader close denied");
+        await readerClose.call(this);
+      });
+    const rootClose = OwnedFileHandle.prototype.close;
+    const root = vi
+      .spyOn(OwnedFileHandle.prototype, "close")
+      .mockImplementation(async function (this: OwnedFileHandle) {
+        if (scenario.cancelAt === "root") controller.abort(reason);
+        if (scenario.rootFails && !retry) throw new Error("root close denied");
+        await rootClose.call(this);
+      });
+    const options = { resourceScope: scope, signal: controller.signal };
+    const failure: unknown = await scope
+      .run(async () => {
+        if (scenario.transfer)
+          await scanCanonicalArtifactInventoryForExtractionInScope(
+            path,
+            options,
+          );
+        else await scanCanonicalArtifactInventoryInScope(path, options);
+      })
+      .catch((cause: unknown) => cause);
+    try {
+      expect(failure).toBeInstanceOf(ArtifactReaderFailure);
+      if (!(failure instanceof ArtifactReaderFailure))
+        throw new Error("Expected inventory failure");
+      const earlierCleanupFailure =
+        scenario.readerFails && scenario.cancelAt === "root";
+      expect(failure.reason).toBe(earlierCleanupFailure ? "io" : "cancelled");
+      expect(failure.cancellation?.reason).toBe(
+        earlierCleanupFailure ? undefined : reason,
+      );
+      expect(failure.partialObservation).toMatchObject({
+        kind: "artifact-inventory",
+        inventory: {
+          manifest: { root_sha256: sha256, occurrence_count: 2 },
+          coverage: { status: "complete" },
+        },
+      });
+      expect(reader).toHaveBeenCalledTimes(1);
+      expect(root).toHaveBeenCalledTimes(1);
+      expect(failure.cleanupLeases).toHaveLength(
+        Number(scenario.readerFails) + Number(scenario.rootFails),
+      );
+      expect(scope.hasPending).toBe(scenario.readerFails || scenario.rootFails);
+      retry = true;
+      await scope.retryCleanup();
+      expect(scope.hasPending).toBe(false);
+      expect(failure.cleanupLeases?.every((lease) => lease.released)).toBe(
+        true,
+      );
+      expect(reader).toHaveBeenCalledTimes(scenario.readerFails ? 2 : 1);
+      expect(root).toHaveBeenCalledTimes(scenario.rootFails ? 2 : 1);
+    } finally {
+      retry = true;
+      await scope.close();
+    }
+  },
+);
+
+it("keeps an earlier inventory failure primary when cleanup is cancelled", async () => {
+  const { path } = await zipFixture();
+  const scope = new ArtifactResourceScope();
+  const controller = new AbortController();
+  const primary = new ArtifactReaderFailure(
+    "format",
+    "member enumeration failed",
+  );
+  const enumeration = vi
+    .spyOn(ZipArtifactReader.prototype, "entries")
+    .mockImplementation(async function* () {
+      throw primary;
+    });
+  const close = ZipArtifactReader.prototype.close;
+  vi.spyOn(ZipArtifactReader.prototype, "close").mockImplementation(
+    async function (this: ZipArtifactReader) {
+      controller.abort({ request: "later cleanup cancellation" });
+      await close.call(this);
+    },
+  );
+  try {
+    await expect(
+      scope.run(() =>
+        scanCanonicalArtifactInventoryInScope(path, {
+          resourceScope: scope,
+          signal: controller.signal,
+        }),
+      ),
+    ).rejects.toBe(primary);
+    expect(enumeration).toHaveBeenCalledTimes(1);
+    expect(scope.hasPending).toBe(false);
+  } finally {
+    await scope.close();
+  }
+});
+
+it.each(["inventory_artifact", "inspect_artifact"] as const)(
+  "reports finalization cancellation and retained inventory through provider %s",
+  async (operation) => {
+    const { path, sha256 } = await zipFixture();
+    const controller = new AbortController();
+    const reason = { request: "provider inventory cancellation" };
+    const close = ZipArtifactReader.prototype.close;
+    vi.spyOn(ZipArtifactReader.prototype, "close").mockImplementation(
+      async function (this: ZipArtifactReader) {
+        controller.abort(reason);
+        await close.call(this);
+      },
+    );
+    const client = new ArtifactProvider({}).createClient({
+      kind: "archive",
+      format: "zip",
+      path,
+      sha256,
+    });
+    try {
+      const result = await client.execute(
+        operation,
+        {},
+        { signal: controller.signal },
+      );
+      expect(result.ok).toBe(false);
+      if (result.ok) throw new Error("Expected provider cancellation");
+      expect(projectAnalysisError(result.error).code).toBe("cancelled");
+      expect(result.error.cause).toBe(reason);
+      expect(result.error.partialObservation).toMatchObject({
+        kind: "artifact-inventory",
+        inventory: { manifest: { root_sha256: sha256, occurrence_count: 2 } },
+      });
+    } finally {
+      expect((await client.close()).ok).toBe(true);
+    }
+  },
+);
diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts
index 0bbda27539..e78979130c 100644
--- a/src/artifacts/inventory/scanCanonical.ts
+++ b/src/artifacts/inventory/scanCanonical.ts
@@ -47,14 +47,11 @@ import {
   scanReader,
   type PendingIntegrityContradiction,
 } from "./scanReader.js";
-import {
-  STRICT_INTEGRITY_POLICY,
-  type ArtifactInventoryOptions,
-} from "./types.js";
+import type { ArtifactInventoryOptions } from "./types.js";
 
-export interface TransferredZipInventorySource {
-  readonly source: RootInventorySource;
+export interface TransferredArtifactInventorySource {
   readonly owner: ArtifactResourceOwner;
+  readonly admission: NonNullable[3]>;
 }
 
 export const scanCanonicalArtifactInventoryInScope = async (
@@ -74,7 +71,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
     )
   ).snapshot;
 
-/** Keep the admitted ZIP descriptor alive for extraction of this inventory. */
+/** Keep the admitted source and parsed format facts alive for extraction of this inventory. */
 export const scanCanonicalArtifactInventoryForExtractionInScope = async (
   path: string,
   options: ArtifactInventoryOptions,
@@ -82,13 +79,13 @@ export const scanCanonicalArtifactInventoryForExtractionInScope = async (
   selectedPath: string = path,
 ): Promise<{
   readonly snapshot: ArtifactInventorySnapshot;
-  readonly zipSource: TransferredZipInventorySource | undefined;
+  readonly transferredSource: TransferredArtifactInventorySource | undefined;
 }> =>
   scanCanonical(
     path,
     options,
     createReader,
-    "transfer-zip",
+    "transfer-source",
     probeFacts,
     selectedPath,
   );
@@ -97,14 +94,13 @@ const scanCanonical = async (
   path: string,
   options: ArtifactInventoryOptions,
   readerFactory: typeof createReader,
-  rootDisposition: "release" | "transfer-zip",
+  rootDisposition: "release" | "transfer-source",
   probeFacts?: ArtifactEmbeddedFormatProbeFacts,
   selectedPath: string = path,
 ): Promise<{
   readonly snapshot: ArtifactInventorySnapshot;
-  readonly zipSource: TransferredZipInventorySource | undefined;
+  readonly transferredSource: TransferredArtifactInventorySource | undefined;
 }> => {
-  const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
   const metadata = await lstat(path);
   const {
     format: rootFormat,
@@ -149,7 +145,7 @@ const scanCanonical = async (
       reader,
       ownedReaders,
       options.signal,
-      integrity,
+      options.integrity,
       options.asarHeaderAdmission,
     );
     outcome = {
@@ -247,18 +243,23 @@ const scanCanonical = async (
     options,
     rootSource,
     ownedReaders,
+    readerAdmission,
     outcome,
     rootDisposition:
-      rootDisposition === "transfer-zip" &&
+      rootDisposition === "transfer-source" &&
       outcome.kind === "completed" &&
       rootSource !== undefined &&
-      isZipPackageFormat(rootFormat)
+      (isZipPackageFormat(rootFormat) ||
+        (readerAdmission !== undefined && "kind" in readerAdmission))
         ? "transfer"
         : "release",
   });
   if (cleanup.failure !== undefined) throw cleanup.failure;
-  if (outcome.kind === "failed") throw outcome.cause;
-  return { snapshot: outcome.snapshot, zipSource: cleanup.transferred };
+  if (cleanup.outcome.kind === "failed") throw cleanup.outcome.cause;
+  return {
+    snapshot: cleanup.outcome.snapshot,
+    transferredSource: cleanup.transferred,
+  };
 };
 
 type InventoryScanOutcome =
@@ -273,6 +274,7 @@ const cleanupInventoryOwners = async ({
   options,
   rootSource,
   ownedReaders,
+  readerAdmission,
   outcome,
   rootDisposition,
 }: {
@@ -280,13 +282,41 @@ const cleanupInventoryOwners = async ({
   readonly options: ArtifactInventoryOptions;
   readonly rootSource: RootInventorySource | undefined;
   readonly ownedReaders: readonly ArtifactReader[];
+  readonly readerAdmission: Parameters[3];
   readonly outcome: InventoryScanOutcome;
   readonly rootDisposition: "release" | "transfer";
 }): Promise<{
   readonly failure: ArtifactReaderFailure | undefined;
-  readonly transferred: TransferredZipInventorySource | undefined;
+  readonly transferred: TransferredArtifactInventorySource | undefined;
+  readonly outcome: InventoryScanOutcome;
 }> => {
+  let finalOutcome = outcome;
   let cleanupFailure: ArtifactReaderFailure | undefined;
+  // Finalization still belongs to this operation. Preserve completed facts when
+  // cancellation arrives while an owner is being released, but keep an earlier
+  // operation or cleanup failure primary.
+  const observeCancellation = (): void => {
+    if (
+      finalOutcome.kind === "completed" &&
+      cleanupFailure === undefined &&
+      options.signal?.aborted === true
+    )
+      finalOutcome = {
+        kind: "failed",
+        cause: new ArtifactReaderFailure(
+          "cancelled",
+          "Artifact inventory cancelled during finalization",
+          {
+            cause: options.signal.reason,
+            partialObservation: {
+              kind: "artifact-inventory",
+              inventory: finalOutcome.snapshot,
+            },
+          },
+        ),
+      };
+  };
+  observeCancellation();
   const readerOwners: ArtifactResourceOwner[] = [...ownedReaders]
     .reverse()
     .map((reader) => ({
@@ -304,10 +334,11 @@ const cleanupInventoryOwners = async ({
         };
   for (const owner of readerOwners) {
     const attempt = await options.resourceScope.release(owner);
+    observeCancellation();
     if (attempt.kind === "released") continue;
     cleanupFailure = options.resourceScope.cleanupFailure(
       cleanupFailure === undefined
-        ? outcome
+        ? finalOutcome
         : { kind: "failed", cause: cleanupFailure },
       [attempt],
       outcome.kind === "completed"
@@ -317,20 +348,24 @@ const cleanupInventoryOwners = async ({
   }
   if (
     rootDisposition === "transfer" &&
+    finalOutcome.kind === "completed" &&
     cleanupFailure === undefined &&
     rootSource !== undefined &&
-    rootOwner !== undefined
+    rootOwner !== undefined &&
+    readerAdmission !== undefined
   )
     return {
       failure: undefined,
-      transferred: { source: rootSource, owner: rootOwner },
+      transferred: { owner: rootOwner, admission: readerAdmission },
+      outcome: finalOutcome,
     };
   if (rootOwner !== undefined) {
     const attempt = await options.resourceScope.release(rootOwner);
+    observeCancellation();
     if (attempt.kind === "failed")
       cleanupFailure = options.resourceScope.cleanupFailure(
         cleanupFailure === undefined
-          ? outcome
+          ? finalOutcome
           : { kind: "failed", cause: cleanupFailure },
         [attempt],
         outcome.kind === "completed"
@@ -338,7 +373,11 @@ const cleanupInventoryOwners = async ({
           : undefined,
       );
   }
-  return { failure: cleanupFailure, transferred: undefined };
+  return {
+    failure: cleanupFailure,
+    transferred: undefined,
+    outcome: finalOutcome,
+  };
 };
 
 interface SnapshotBuildInput {
diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts
index a28db8381f..7117430469 100644
--- a/src/artifacts/inventory/scanReader.ts
+++ b/src/artifacts/inventory/scanReader.ts
@@ -31,10 +31,7 @@ import {
   resolveOccurrenceParents,
   type MutableOccurrence,
 } from "./ArtifactGraphConstruction.js";
-import {
-  STRICT_INTEGRITY_POLICY,
-  type ArtifactIntegrityPolicy,
-} from "./types.js";
+import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
 import { abortIfNeeded, hashReadable } from "../ArtifactHash.js";
 
 export interface PendingIntegrityContradiction {
@@ -56,7 +53,7 @@ const emptyScan = (): {
 interface ScanContext {
   readonly reader: ArtifactReader;
   readonly signal: AbortSignal | undefined;
-  readonly integrity: ArtifactIntegrityPolicy;
+  readonly integrity: ArtifactIntegrityPolicyName;
   readonly asarHeaderAdmission: AsarHeaderAdmission | undefined;
   readonly nodes: Map;
   readonly occurrences: MutableOccurrence[];
@@ -72,7 +69,7 @@ export const scanReader = async (
   reader: ArtifactReader | undefined,
   ownedReaders: ArtifactReader[],
   signal?: AbortSignal,
-  integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
+  integrity: ArtifactIntegrityPolicyName = "fail",
   asarHeaderAdmission?: AsarHeaderAdmission,
 ): Promise<{
   readonly nodes: Map;
@@ -327,7 +324,7 @@ const digestArtifactEntry = async (
     );
   const mismatched = claim !== null && claim.digest !== observed;
   if (mismatched && claim !== null && observed !== undefined) {
-    if (context.integrity.mode === "fail")
+    if (context.integrity === "fail")
       throw new ArtifactReaderFailure(
         "integrity",
         `Artifact integrity metadata disagrees with content: ${logicalPath}`,
diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts
index 659cf43d74..b9fa6a2e11 100644
--- a/src/artifacts/inventory/types.ts
+++ b/src/artifacts/inventory/types.ts
@@ -1,15 +1,7 @@
+import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
 import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
 import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
 
-/** Resolved integrity behavior admitted to the artifact scanner. */
-export type ArtifactIntegrityPolicy =
-  | { readonly mode: "fail" }
-  | { readonly mode: "record-and-continue" };
-
-export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
-  mode: "fail",
-};
-
 /** Options shared by artifact inventory scans. */
 export interface ArtifactInventoryOptions {
   readonly resourceScope: ArtifactResourceScope;
@@ -18,7 +10,7 @@ export interface ArtifactInventoryOptions {
   /** Independently selected root spelling used only for ASAR companion lookup. */
   readonly selectedPath?: string | undefined;
   readonly signal?: AbortSignal | undefined;
-  readonly integrity?: ArtifactIntegrityPolicy | undefined;
+  readonly integrity?: ArtifactIntegrityPolicyName | undefined;
   readonly environment?: Readonly | undefined;
   /** ASAR header/index admission resolved from the owner's environment; default when absent. */
   readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined;
diff --git a/src/artifacts/nodeSea/NodeSeaLocation.ts b/src/artifacts/nodeSea/NodeSeaLocation.ts
index 0d3a1c81b4..45095003f3 100644
--- a/src/artifacts/nodeSea/NodeSeaLocation.ts
+++ b/src/artifacts/nodeSea/NodeSeaLocation.ts
@@ -272,22 +272,24 @@ const locateThinMachO = async (
     }
     if (cmd === LC_SEGMENT || cmd === LC_SEGMENT_64) {
       const segment64 = cmd === LC_SEGMENT_64;
-      const segmentHeader = segment64 ? 72 : 56;
       const sectionSize = segment64 ? 80 : 68;
-      const name = cString(commands, offset + 8, 16);
+      const parsedSegment = readMachoSegmentHeader(
+        commands.subarray(offset, offset + cmdsize),
+        segment64,
+        little,
+      );
+      // Incomplete segments cannot establish a SEA carrier. Interpret names
+      // and section framing only from the shared fixed-record observation.
+      const segment = parsedSegment.ok ? parsedSegment.value : undefined;
+      const name =
+        segment === undefined ? undefined : cString(segment.nameBytes, 0, 16);
       if (
-        (name === "NODE_SEA" || name === "__NODE_SEA") &&
-        cmdsize >= segmentHeader
+        segment !== undefined &&
+        (name === "NODE_SEA" || name === "__NODE_SEA")
       ) {
-        const parsedSegment = readMachoSegmentHeader(
-          commands.subarray(offset, offset + cmdsize),
-          segment64,
-          little,
-        );
-        if (!parsedSegment.ok)
-          throw formatFailure(NODE_SEA_MACHO_SEGMENT_TRUNCATED);
-        const nsects = parsedSegment.value.sectionCount;
-        const table = offset + parsedSegment.value.headerSize;
+        const segmentHeader = segment.headerSize;
+        const nsects = segment.sectionCount;
+        const table = offset + segmentHeader;
         if (
           nsects > Math.floor((cmdsize - segmentHeader) / sectionSize) ||
           table + nsects * sectionSize > offset + cmdsize
diff --git a/src/artifacts/pyinstaller/CArchiveLayout.ts b/src/artifacts/pyinstaller/CArchiveLayout.ts
index 539e27b7cd..eb77c4b08a 100644
--- a/src/artifacts/pyinstaller/CArchiveLayout.ts
+++ b/src/artifacts/pyinstaller/CArchiveLayout.ts
@@ -72,7 +72,7 @@ export interface CArchiveEntry {
   readonly offset: number;
   readonly compressedSize: number;
   readonly uncompressedSize: number;
-  readonly compressionFlag: number;
+  readonly compressionFlag: 0 | 1;
 }
 
 /** Role-defined declarations never own local payload bytes. */
@@ -83,17 +83,26 @@ export const isCArchiveDeclaration = (entry: CArchiveEntry): boolean =>
 export const isCArchivePythonModule = (entry: CArchiveEntry): boolean =>
   entry.role === "module" || entry.role === "package";
 
-/** Locate a structurally valid cookie. Invalid magic copies are skipped. */
-export const findPyInstallerCookie = async (
+/** Completed search facts; a rejected magic copy does not establish an archive. */
+export type PyInstallerCookieObservation =
+  | { readonly state: "absent" }
+  | {
+      readonly state: "rejected-candidates";
+      readonly failure: PyInstallerFailure;
+    }
+  | { readonly state: "located"; readonly cookie: PyInstallerCookie };
+
+/** Search the bounded tail completely; acquisition failures are not negative observations. */
+export const observePyInstallerCookie = async (
   handle: FileHandle,
   fileSize: number,
   signal?: AbortSignal,
-): Promise | undefined> => {
+): Promise> => {
   if (
     !Number.isSafeInteger(fileSize) ||
     fileSize < PYINSTALLER_COOKIE_V20_BYTES
   )
-    return undefined;
+    return ok({ state: "absent" });
   const tailSize = Math.min(fileSize, PYINSTALLER_COOKIE_SEARCH_BYTES);
   const read = await readPyInstallerBytes(handle, {
     position: fileSize - tailSize,
@@ -112,11 +121,15 @@ export const findPyInstallerCookie = async (
     if (relative < 0) break;
     const cookieOffset = fileSize - tailSize + relative;
     const parsed = parseCookieAt(observed, relative, cookieOffset, fileSize);
-    if (parsed.ok) return parsed;
+    if (parsed.ok) return ok({ state: "located", cookie: parsed.value });
     failure = parsed.error;
     from = relative;
   }
-  return failure === undefined ? undefined : err(failure);
+  return ok(
+    failure === undefined
+      ? { state: "absent" }
+      : { state: "rejected-candidates", failure },
+  );
 };
 
 /** Read and parse the TOC that a validated cookie names. */
diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts
index d5353ab328..2944dab724 100644
--- a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts
+++ b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts
@@ -136,7 +136,7 @@ it("inventories members using the cookie discovered by root classification", asy
   );
   const path = join(root, "app");
   await writeFile(path, fixture());
-  const locate = vi.spyOn(layout, "findPyInstallerCookie");
+  const locate = vi.spyOn(layout, "observePyInstallerCookie");
   const resourceScope = new ArtifactResourceScope();
   try {
     const result = await inventoryArtifact(path, {
@@ -194,7 +194,7 @@ it("retains completed cookie facts across unchanged admissions and honors cached
   const root = await createTestTempDirectory("rea-pyinstaller-cookie-cache-");
   const path = join(root, "app");
   await writeFile(path, fixture());
-  const locate = vi.spyOn(layout, "findPyInstallerCookie");
+  const locate = vi.spyOn(layout, "observePyInstallerCookie");
   try {
     await withOwnedRegularFile(path, {}, async (source) => {
       const controller = new AbortController();
@@ -222,3 +222,108 @@ it("retains completed cookie facts across unchanged admissions and honors cached
     locate.mockRestore();
   }
 });
+
+it("retains rejected magic candidates without classifying their native carrier as PyInstaller", async () => {
+  const root = await createTestTempDirectory("rea-pyinstaller-candidate-");
+  const path = join(root, "app");
+  await writeFile(
+    path,
+    Buffer.concat([
+      fixture().subarray(0, 64),
+      layout.PYINSTALLER_COOKIE_MAGIC,
+      Buffer.alloc(88),
+    ]),
+  );
+  await withOwnedRegularFile(path, {}, async (source) => {
+    const probes = new ArtifactEmbeddedFormatProbes(path, source);
+    await expect(probes.pyInstaller()).resolves.toBeUndefined();
+    const observed = probes.completedFacts().pyInstaller;
+    expect(observed).toMatchObject({
+      state: "rejected-candidates",
+      failure: { reason: "format" },
+    });
+    const resumed = new ArtifactEmbeddedFormatProbes(
+      path,
+      source,
+      undefined,
+      probes.completedFacts(),
+    );
+    await expect(resumed.pyInstaller()).resolves.toBeUndefined();
+    expect(resumed.completedFacts().pyInstaller).toBe(observed);
+  });
+  const resourceScope = new ArtifactResourceScope();
+  try {
+    const result = await inventoryArtifact(path, {
+      environment: {},
+      resourceScope,
+    });
+    expect(result.manifest.root_format).toBe("elf");
+  } finally {
+    await resourceScope.close();
+  }
+});
+
+it("continues past an invalid trailing magic copy to inventory the valid cookie", async () => {
+  const root = await createTestTempDirectory(
+    "rea-pyinstaller-candidate-valid-",
+  );
+  const path = join(root, "app");
+  await writeFile(
+    path,
+    Buffer.concat([
+      fixture(),
+      layout.PYINSTALLER_COOKIE_MAGIC,
+      Buffer.alloc(88),
+    ]),
+  );
+  const resourceScope = new ArtifactResourceScope();
+  try {
+    const result = await inventoryArtifact(path, {
+      environment: {},
+      resourceScope,
+    });
+    expect(result.manifest.root_format).toBe("pyinstaller");
+    expect(result.occurrences).toContainEqual(
+      expect.objectContaining({
+        logical_path: "payload",
+        hash_status: "verified",
+      }),
+    );
+  } finally {
+    await resourceScope.close();
+  }
+});
+
+it("does not cache an incomplete tail read as an absent cookie and can retry", async () => {
+  const root = await createTestTempDirectory("rea-pyinstaller-tail-short-");
+  const path = join(root, "app");
+  await writeFile(path, fixture());
+  await withOwnedRegularFile(path, {}, async (source) => {
+    const probes = new ArtifactEmbeddedFormatProbes(path, source);
+    const read = source.handle.read.bind(source.handle);
+    let calls = 0;
+    const short = vi
+      .spyOn(source.handle, "read")
+      .mockImplementation((...args: unknown[]) => {
+        calls += 1;
+        if (calls > 1)
+          return Promise.resolve({ bytesRead: 0, buffer: args[0] });
+        args[2] = 3;
+        return Reflect.apply(read, source.handle, args);
+      });
+    try {
+      await expect(probes.pyInstaller()).rejects.toMatchObject({
+        reason: "format",
+        message: "CArchive tail is truncated",
+      });
+      expect(calls).toBe(2);
+      expect(Object.hasOwn(probes.completedFacts(), "pyInstaller")).toBe(false);
+    } finally {
+      short.mockRestore();
+    }
+    await expect(probes.pyInstaller()).resolves.toMatchObject({
+      layout: "2.1",
+    });
+    expect(probes.completedFacts().pyInstaller?.state).toBe("located");
+  });
+});
diff --git a/src/artifacts/pyinstaller/PyInstallerMetadata.ts b/src/artifacts/pyinstaller/PyInstallerMetadata.ts
index e612120ea6..4ef6de9554 100644
--- a/src/artifacts/pyinstaller/PyInstallerMetadata.ts
+++ b/src/artifacts/pyinstaller/PyInstallerMetadata.ts
@@ -112,7 +112,7 @@ export const cArchiveEntryMetadata = (
       source_name: entry.name,
       type_code: entry.typeCode,
       relative_offset: entry.offset,
-      compression_flag: entry.compressionFlag === 1 ? 1 : 0,
+      compression_flag: entry.compressionFlag,
     },
     entry.role === "script" || isCArchivePythonModule(entry),
     header,
diff --git a/src/artifacts/readStableArtifact.ts b/src/artifacts/readStableArtifact.ts
index c67c41eaa1..ad3371d3f7 100644
--- a/src/artifacts/readStableArtifact.ts
+++ b/src/artifacts/readStableArtifact.ts
@@ -6,6 +6,7 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js";
 import type { ArtifactResourceScope } from "./ArtifactResourceScope.js";
 import {
   artifactFileCompletion,
+  artifactSelectedPathErrno,
   withArtifactFileHandle,
   assertArtifactReadActive,
 } from "./ArtifactFileOperation.js";
@@ -52,11 +53,7 @@ const afterValidation = async (
   try {
     return await read();
   } catch (cause) {
-    if (
-      cause instanceof Error &&
-      "code" in cause &&
-      ["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
-    )
+    if (artifactSelectedPathErrno(cause) !== undefined)
       throw new ArtifactReaderFailure(
         "integrity",
         `Artifact changed ${stage}: ${path}`,
diff --git a/src/artifacts/withArtifactTargetIdentity.ts b/src/artifacts/withArtifactTargetIdentity.ts
new file mode 100644
index 0000000000..b6ad882d69
--- /dev/null
+++ b/src/artifacts/withArtifactTargetIdentity.ts
@@ -0,0 +1,25 @@
+import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
+import { ArtifactReaderFailure } from "./ArtifactReader.js";
+import type { ArtifactResourceScope } from "./ArtifactResourceScope.js";
+import { hashStableRootArtifact } from "./inventory/hashStableRootArtifact.js";
+
+/** Bind live resource reads to the target identity established by the session. */
+export const withArtifactTargetIdentity = async (
+  target: BinaryTarget,
+  resources: ArtifactResourceScope,
+  read: () => Promise,
+  signal?: AbortSignal,
+): Promise => {
+  const verify = async () => {
+    const digest = await hashStableRootArtifact(target.path, resources, signal);
+    if (digest.sha256 !== target.sha256)
+      throw new ArtifactReaderFailure(
+        "integrity",
+        `Active target digest changed: expected ${target.sha256}, observed ${digest.sha256}`,
+      );
+  };
+  await verify();
+  const result = await read();
+  await verify();
+  return result;
+};
diff --git a/src/browser/BrowserScenarioResponseValues.ts b/src/browser/BrowserScenarioResponseValues.ts
new file mode 100644
index 0000000000..fd53f73a7f
--- /dev/null
+++ b/src/browser/BrowserScenarioResponseValues.ts
@@ -0,0 +1,251 @@
+import { visit, type JSONPath } from "jsonc-parser";
+import { isExactJsonNumber } from "../domain/exactJsonNumber.js";
+import { safeParseJson } from "../domain/safeJson.js";
+import { err, ok, type Result } from "../domain/result.js";
+import type { BrowserScenarioSaveEntry } from "../domain/browserScenarioValues.js";
+
+/** Bound decoded JSON before parsing; UTF-8 needs at most three bytes per UTF-16 code unit. */
+export const MAX_SAVED_RESPONSE_CHARACTERS = 4_000_000;
+export const MAX_SAVED_RESPONSE_BYTES = 3 * MAX_SAVED_RESPONSE_CHARACTERS;
+
+interface ResponseValueIssue {
+  readonly _tag: "ResponseValueIssue";
+  readonly message: string;
+}
+const issue = (message: string): ResponseValueIssue => ({
+  _tag: "ResponseValueIssue",
+  message,
+});
+
+export interface ResponseValue {
+  readonly entry: BrowserScenarioSaveEntry;
+  readonly value: string | number;
+}
+
+const pointerTokens = (pointer: string): readonly string[] =>
+  pointer === ""
+    ? []
+    : pointer
+        .slice(1)
+        .split("/")
+        .map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
+
+/** Keep unrelated fields usable while marking numbers that cannot be selected exactly. */
+export const parseScenarioValueJson = (text: string) =>
+  safeParseJson(text, (_key, value, context) =>
+    typeof value === "number" && !isExactJsonNumber(value, context?.source)
+      ? Number.NaN
+      : value,
+  );
+
+/** Exact own-property lookup: an absent pointer remains distinct from a non-scalar value. */
+export const responsePointerValue = (
+  value: unknown,
+  pointer: string,
+): unknown => {
+  let current = value;
+  for (const key of pointerTokens(pointer)) {
+    if (Array.isArray(current)) {
+      if (!/^(?:0|[1-9][0-9]*)(?![\s\S])/u.test(key)) return undefined;
+      current = current[Number(key)];
+    } else if (
+      typeof current === "object" &&
+      current !== null &&
+      Object.hasOwn(current, key)
+    )
+      current = (current as Record)[key];
+    else return undefined;
+  }
+  return current;
+};
+
+/** Both bindings and source-body redaction use the same decoded values and exact-number policy. */
+export const readResponseValues = (
+  body: string | Uint8Array | undefined,
+  entries: readonly BrowserScenarioSaveEntry[],
+  missing: "fail" | "ignore",
+): Result<
+  { readonly values: readonly ResponseValue[]; readonly text: string },
+  ResponseValueIssue
+> => {
+  if (
+    body === undefined ||
+    (typeof body !== "string" && body.byteLength > MAX_SAVED_RESPONSE_BYTES)
+  )
+    return err(issue("the response is too large to save values from."));
+  let text: string;
+  try {
+    text =
+      typeof body === "string"
+        ? body
+        : new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
+            body,
+          );
+  } catch {
+    return err(
+      issue(
+        "the response is not valid UTF-8 JSON, so no value can be saved from it.",
+      ),
+    );
+  }
+  if (text.length > MAX_SAVED_RESPONSE_CHARACTERS)
+    return err(issue("the response is too large to save values from."));
+  const parsed = parseScenarioValueJson(text);
+  if (!parsed.ok)
+    return err(
+      issue("the response is not JSON, so no value can be saved from it."),
+    );
+  const selected: ResponseValue[] = [];
+  for (const entry of entries) {
+    const value = responsePointerValue(parsed.value, entry.json_pointer);
+    if (value === undefined && missing === "ignore") continue;
+    if (typeof value !== "string" && typeof value !== "number")
+      return err(
+        issue(
+          `the response has no string or number at ${entry.json_pointer} to save as ${entry.name}.`,
+        ),
+      );
+    if (typeof value === "number" && !Number.isFinite(value))
+      return err(
+        issue(
+          `the number at ${entry.json_pointer} cannot be saved exactly (not finite or JSON conversion would lose precision).`,
+        ),
+      );
+    selected.push({ entry, value });
+  }
+  return ok({ values: selected, text });
+};
+
+interface ResponseBodySource {
+  headers(): Readonly>;
+  request(): { sizes?(): Promise<{ readonly responseBodySize: number }> };
+  body(): Promise;
+}
+
+/** The caller owns deadline/cancellation; unknown decoded bounds never request a whole body. */
+export const readBoundedResponseBody = async (
+  response: ResponseBodySource,
+  maxBytes: number,
+): Promise> => {
+  const request = response.request();
+  const size =
+    request.sizes === undefined
+      ? undefined
+      : (await request.sizes()).responseBodySize;
+  if (size !== undefined && size > maxBytes)
+    return err(issue("the response is too large to save values from."));
+  const encoding = response.headers()["content-encoding"]?.trim().toLowerCase();
+  if (
+    size === undefined ||
+    !Number.isSafeInteger(size) ||
+    size <= 0 ||
+    (encoding !== undefined && encoding !== "" && encoding !== "identity")
+  )
+    return err(
+      issue(
+        "the decoded response size cannot be bounded before reading it; this response body was not read.",
+      ),
+    );
+  const body = await response.body();
+  return body.length > maxBytes
+    ? err(issue("the response is too large to save values from."))
+    : ok(body);
+};
+
+/** Replace selected JSON tokens and known decoded string secrets; preserve other source spellings and whitespace. */
+export const redactResponseSource = (
+  text: string,
+  values: readonly ResponseValue[],
+  redact: (value: string) => string,
+  maxBytes: number,
+  maxAllocationBytes: number,
+): Result => {
+  const markers = new Map(
+    values.map(({ entry, value }) => [
+      JSON.stringify(pointerTokens(entry.json_pointer)),
+      redact(String(value)),
+    ]),
+  );
+  const parts: string[] = [];
+  let cursor = 0;
+  let bytes = Buffer.byteLength(text);
+  const replace = (offset: number, length: number, marker: string): void => {
+    const replacement = JSON.stringify(marker);
+    bytes +=
+      Buffer.byteLength(replacement) -
+      Buffer.byteLength(text.slice(offset, offset + length));
+    if (bytes > maxBytes || bytes * 4 > maxAllocationBytes)
+      throw new Error(
+        "Structural source redaction exceeds the selected body allocation budget.",
+      );
+    parts.push(text.slice(cursor, offset), replacement);
+    cursor = offset + length;
+  };
+  const markerAt = (path: JSONPath): string | undefined =>
+    markers.get(JSON.stringify(path.map(String)));
+  const container = (
+    _offset: number,
+    _length: number,
+    _line: number,
+    _column: number,
+    path: () => JSONPath,
+  ): void => {
+    if (markerAt(path()) !== undefined)
+      throw new Error(
+        "A marked source field has conflicting scalar/container representations; its source content was withheld.",
+      );
+  };
+  try {
+    visit(
+      text,
+      {
+        onObjectBegin: container,
+        onArrayBegin: container,
+        onLiteralValue: (
+          value: unknown,
+          offset,
+          length,
+          _line,
+          _column,
+          path,
+        ) => {
+          let marker = markerAt(path());
+          if (marker === undefined) {
+            const raw = text.slice(offset, offset + length);
+            const rendered =
+              typeof value === "number" && !isExactJsonNumber(value, raw)
+                ? undefined
+                : String(value);
+            const decoded =
+              rendered === undefined ? undefined : redact(rendered);
+            if (decoded !== undefined && decoded !== rendered) marker = decoded;
+            if (marker === undefined && typeof value !== "string") {
+              const redacted = redact(raw);
+              if (redacted !== raw) marker = redacted;
+            }
+          }
+          if (marker !== undefined) replace(offset, length, marker);
+        },
+        onObjectProperty: (property, offset, length) => {
+          const redacted = redact(property);
+          if (redacted !== property) replace(offset, length, redacted);
+        },
+        onError: () => {
+          throw new Error("The marked source is not valid JSON.");
+        },
+      },
+      { disallowComments: true, allowTrailingComma: false },
+    );
+    if (parts.length === 0) return ok(undefined);
+    parts.push(text.slice(cursor));
+    return ok(Buffer.from(parts.join("")));
+  } catch (cause: unknown) {
+    return err(
+      issue(
+        cause instanceof Error
+          ? cause.message
+          : "Structural source redaction failed.",
+      ),
+    );
+  }
+};
diff --git a/src/browser/BrowserScenarioSecrets.ts b/src/browser/BrowserScenarioSecrets.ts
index 3e0f69f85d..64d3245226 100644
--- a/src/browser/BrowserScenarioSecrets.ts
+++ b/src/browser/BrowserScenarioSecrets.ts
@@ -7,8 +7,14 @@ import { createHash } from "node:crypto";
 import type { BrowserScenario } from "../domain/browserScenario.js";
 import type {
   BrowserScenarioUrl,
+  BrowserScenarioRequestValue,
   BrowserScenarioValue,
+  BrowserScenarioSaveEntry,
 } from "../domain/browserScenarioValues.js";
+import {
+  readResponseValues,
+  redactResponseSource,
+} from "./BrowserScenarioResponseValues.js";
 import {
   sanitizeBrowserUrl,
   type SanitizedBrowserUrl,
@@ -20,11 +26,29 @@ const REDACTION_PREFIX = "[REDACTED:";
 /** Page values shorter than this are not redacted: redacting them would rewrite ids, ports and hosts throughout the capture. */
 export const MIN_REDACTED_PAGE_VALUE_LENGTH = 8;
 
-const encodedSecretValues = (secret: string): ReadonlySet =>
-  new Set([
-    encodeURIComponent(secret),
+/** The most saved-value text a scenario keeps at once, with the redaction spellings of values saved as secret: as much as one saved response can carry. */
+export const MAX_RETAINED_SAVED_CHARACTERS = 4_000_000;
+
+export type ScenarioRedactionScope = "current" | "response";
+interface ResponseRedactionFacts {
+  readonly text: readonly SecretReplacement[];
+  readonly url: readonly SecretReplacement[];
+  readonly needles: readonly SecretNeedle[];
+  readonly costPerCharacter: number;
+}
+const normalizedSecretValues = (value: string): readonly string[] => [
+  ...new Set([value.replace(/^[\t ]+|[\t ]+$/gu, ""), value]),
+];
+
+const encodedSecretValues = (secret: string): ReadonlySet => {
+  const component = encodeURIComponent(secret);
+  return new Set([
+    component,
+    // Special-URL queries escape apostrophes while keeping the component's tilde.
+    component.replace(/'/gu, "%27"),
     new URLSearchParams([["value", secret]]).toString().slice("value=".length),
   ]);
+};
 
 type SecretSpellings = "url" | "text";
 interface SecretPattern {
@@ -76,6 +100,38 @@ const secretPatterns = (
   });
 };
 
+/** The code units a secret's redaction spellings keep, each distinct spelling once. */
+const spellingUnits = (secret: string): number =>
+  [
+    ...new Set(
+      [
+        ...secretPatterns(secret, "text"),
+        ...secretPatterns(secret, "url"),
+      ].flatMap(({ text, searchText }) => [text, searchText]),
+    ),
+  ].reduce((units, spelling) => units + spelling.length, 0);
+
+const URI_KEPT = /[\w.!~*'()-]/u;
+const FORM_KEPT = /[\w .*-]/u;
+
+/** A lower bound that refuses over-budget spellings before allocating encoded copies. */
+const leastSpellingUnits = (secret: string): number => {
+  let uri = 0;
+  let query = 0;
+  let form = 0;
+  for (const character of secret) {
+    const point = character.codePointAt(0) ?? 0;
+    const escaped =
+      3 * (point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4);
+    const uriKept = URI_KEPT.test(character);
+    uri += uriKept ? 1 : escaped;
+    query += uriKept && character !== "'" ? 1 : escaped;
+    form += FORM_KEPT.test(character) ? 1 : escaped;
+  }
+  const encoded = Math.max(uri, query, form);
+  return secret.length + (encoded > secret.length ? encoded : 0);
+};
+
 const declaredPatterns = (
   values: ReadonlyMap,
   spellings: SecretSpellings,
@@ -185,6 +241,7 @@ const replaceSecretLiterals = (
   value: string,
   replacements: readonly SecretReplacement[],
   patterns: readonly SecretPattern[],
+  markerPatterns: readonly SecretPattern[] = patterns,
 ): string => {
   const normalizedValue = replacements.some(
     ({ percentInsensitive }) => percentInsensitive,
@@ -210,7 +267,7 @@ const replaceSecretLiterals = (
         next = { ...replacement, index };
     }
     if (next === undefined) break;
-    const marker = redactionMarker(next.id, patterns);
+    const marker = redactionMarker(next.id, markerPatterns);
     parts.push(value.slice(offset, next.index), marker);
     offset = next.index + next.text.length;
   }
@@ -238,8 +295,27 @@ export class BrowserScenarioSecrets {
   private textReplacements: readonly SecretReplacement[] = [];
   private textNeedles: readonly SecretNeedle[] = [];
   private redactionCostPerCharacter = 8;
+  private readonly declaredIds: ReadonlySet;
+  private readonly declaredValues: ReadonlySet;
+  private readonly responseValues = new Set();
+  private responseFacts: ResponseRedactionFacts | undefined;
 
-  private constructor(private readonly values: Map) {
+  /** Pixel privacy is established before the scenario can render a future marked response. */
+  withholdsScreenshots(): boolean {
+    return this.plansSensitiveValues || this.textPatterns.length > 0;
+  }
+
+  /** Explicit response facts, independent of source readiness and subsequent-only page credentials. */
+  hasLearnedResponseSecrets(): boolean {
+    return this.responseValues.size > 0;
+  }
+
+  private constructor(
+    private readonly values: Map,
+    private readonly plansSensitiveValues: boolean,
+  ) {
+    this.declaredIds = new Set(values.keys());
+    this.declaredValues = new Set(values.values());
     this.compile();
   }
 
@@ -250,19 +326,54 @@ export class BrowserScenarioSecrets {
     this.urlReplacements = secretReplacements(values, "url");
     this.textReplacements = secretReplacements(values, "text");
     this.textNeedles = secretNeedles(this.textReplacements, this.textPatterns);
-    this.redactionCostPerCharacter = 8;
+    this.redactionCostPerCharacter = this.costOf(
+      this.textReplacements,
+      this.urlReplacements,
+    );
+    this.responseFacts = undefined;
+  }
+
+  private costOf(
+    text: readonly SecretReplacement[],
+    url: readonly SecretReplacement[],
+  ): number {
+    let cost = 8;
     for (const [replacements, patterns] of [
-      [this.textReplacements, this.textPatterns],
-      [this.urlReplacements, this.urlPatterns],
+      [text, this.textPatterns],
+      [url, this.urlPatterns],
     ] as const)
       for (const replacement of replacements)
-        this.redactionCostPerCharacter = Math.max(
-          this.redactionCostPerCharacter,
+        cost = Math.max(
+          cost,
           8 +
             (Buffer.byteLength(redactionMarker(replacement.id, patterns)) * 8 +
               128) /
               replacement.text.length,
         );
+    return cost;
+  }
+
+  /** Borrow compiled pattern/byte owners; invalidate on admission or scope promotion, not per event. */
+  private responseRedactions(): ResponseRedactionFacts {
+    if (this.responseFacts !== undefined) return this.responseFacts;
+    const eligible = ({ id }: { readonly id: string }): boolean =>
+      this.declaredIds.has(id) ||
+      this.responseValues.has(this.values.get(id) ?? "");
+    const text = this.textReplacements.filter(eligible),
+      url = this.urlReplacements.filter(eligible);
+    return (this.responseFacts = {
+      text,
+      url,
+      needles: this.textNeedles.filter(eligible),
+      costPerCharacter: this.costOf(text, url),
+    });
+  }
+
+  private markResponseValue(value: string): void {
+    for (const candidate of normalizedSecretValues(value))
+      if (candidate.length > 0 && !this.declaredValues.has(candidate))
+        this.responseValues.add(candidate);
+    this.responseFacts = undefined;
   }
 
   /**
@@ -273,15 +384,50 @@ export class BrowserScenarioSecrets {
    * {@link MIN_REDACTED_PAGE_VALUE_LENGTH} is not redacted.
    */
   admitPageValue(label: string, value: string): void {
-    // fetch sends a header value without surrounding spaces and tabs, so that is the spelling records hold.
-    const sent = value.replace(/^[\t ]+|[\t ]+$/gu, "");
-    if (sent !== value) this.admitPageValue(label, sent);
-    if (value.length < MIN_REDACTED_PAGE_VALUE_LENGTH) return;
-    for (const held of this.values.values()) if (held === value) return;
-    let id = `page:${label}`;
-    for (let suffix = 2; this.values.has(id); suffix += 1)
-      id = `page:${label}~${String(suffix)}`;
-    this.values.set(id, value);
+    this.retainValues(
+      label,
+      this.valuesToAdmit(value, MIN_REDACTED_PAGE_VALUE_LENGTH),
+    );
+  }
+
+  /** Build only bounded Basic text; all distinct encoded spellings share the saved-value budget. */
+  basicCredential(label: string, username: string, password: string): string {
+    const bytes = Buffer.byteLength(username) + 1 + Buffer.byteLength(password);
+    const encodedCharacters = 4 * Math.ceil(bytes / 3);
+    if (encodedCharacters > MAX_RETAINED_SAVED_CHARACTERS)
+      throw new Error(
+        `Basic credentials cannot be built: their encoded form exceeds ${String(MAX_RETAINED_SAVED_CHARACTERS)} characters.`,
+      );
+    const input = Buffer.allocUnsafe(bytes);
+    let offset = input.write(username);
+    offset += input.write(":", offset);
+    input.write(password, offset);
+    const encoded = input.toString("base64");
+    this.retainSavedRedactions(
+      label,
+      this.valuesToAdmit(encoded, 1),
+      this.savedUnits,
+      "Basic credentials",
+    );
+    return encoded;
+  }
+
+  /** Fetch trims surrounding header whitespace; both source and sent spellings remain owned here. */
+  private valuesToAdmit(value: string, shortest: number): readonly string[] {
+    const held = new Set(this.values.values());
+    return normalizedSecretValues(value).filter(
+      (candidate) => candidate.length >= shortest && !held.has(candidate),
+    );
+  }
+
+  private retainValues(label: string, values: readonly string[]): void {
+    if (values.length === 0) return;
+    for (const value of values) {
+      let id = `page:${label}`;
+      for (let suffix = 2; this.values.has(id); suffix += 1)
+        id = `page:${label}~${String(suffix)}`;
+      this.values.set(id, value);
+    }
     this.compile();
   }
 
@@ -317,11 +463,190 @@ export class BrowserScenarioSecrets {
         );
       values.set(declaration.secret_id, value);
     }
-    return ok(new BrowserScenarioSecrets(values));
+    const plansSensitiveValues = scenario.actions.some((action) =>
+      action.action === "request"
+        ? action.save.some((entry) => entry.secret) ||
+          action.headers.some(
+            ({ value }) =>
+              value.source === "cookie" ||
+              value.source === "local_storage" ||
+              value.source === "basic_credentials",
+          )
+        : (action.action === "click" || action.action === "press") &&
+          (action.save_response?.save.some((entry) => entry.secret) ?? false),
+    );
+    return ok(new BrowserScenarioSecrets(values, plansSensitiveValues));
   }
 
-  value(source: BrowserScenarioValue): string {
+  /** Values steps saved from JSON responses, by name, as strings or numbers. */
+  private readonly saved = new Map();
+
+  /** Code units the current saved values keep, and those the redaction spellings of secret saves keep for the rest of the scenario. */
+  private savedUnits = 0;
+  private secretSpellingUnits = 0;
+
+  /** Policies are attached before dispatch to the browser's exact request identity. */
+  private readonly responseSecrets = new WeakMap<
+    object,
+    {
+      readonly stepId: string;
+      readonly entries: readonly BrowserScenarioSaveEntry[];
+    }
+  >();
+
+  private readonly readyResponses = new WeakSet();
+
+  responseSecretsReady(request: object): boolean {
+    return this.readyResponses.has(request);
+  }
+
+  markResponseSecretsReady(request: object): void {
+    this.readyResponses.add(request);
+  }
+
+  responseProtector(
+    stepId: string,
+    entries: readonly BrowserScenarioSaveEntry[],
+  ): ((request: object) => void) | undefined {
+    const sensitive = entries.filter((entry) => entry.secret);
+    if (sensitive.length === 0) return undefined;
+    const policy = { stepId, entries: sensitive };
+    return (request) => {
+      this.responseSecrets.set(request, policy);
+    };
+  }
+
+  hasResponseProtection(request: object): boolean {
+    return this.responseSecrets.has(request);
+  }
+
+  /** Redact the selected source fields before network Evidence can retain the body. */
+  admitResponseSecrets(
+    request: object,
+    body: Buffer,
+    retention?: {
+      readonly maxBytes: number;
+      readonly maxAllocationBytes: number;
+    },
+  ): Buffer {
+    const source = this.responseSecrets.get(request);
+    if (source === undefined) return body;
+    const selected = readResponseValues(body, source.entries, "ignore");
+    if (!selected.ok)
+      throw new Error(`Step ${source.stepId}: ${selected.error.message}`);
+    for (const { entry, value } of selected.value.values) {
+      const additions = this.valuesToAdmit(String(value), 1);
+      this.retainSavedRedactions(
+        `${source.stepId}.${entry.name}`,
+        additions,
+        this.savedUnits,
+        entry.name,
+      );
+      this.markResponseValue(String(value));
+    }
+    if (selected.value.values.length === source.entries.length)
+      this.markResponseSecretsReady(request);
+    if (retention === undefined) return body;
+    const redacted = redactResponseSource(
+      selected.value.text,
+      selected.value.values,
+      (value) => this.redact(value),
+      retention.maxBytes,
+      retention.maxAllocationBytes,
+    );
+    if (!redacted.ok) throw new Error(redacted.error.message);
+    return redacted.value ?? body;
+  }
+
+  /** Decode JSON only when redaction is active; other bytes retain their existing binary policy. */
+  redactJsonBody(
+    body: Buffer,
+    retention: {
+      readonly maxBytes: number;
+      readonly maxAllocationBytes: number;
+    },
+    scope: ScenarioRedactionScope = "current",
+  ): Buffer {
+    if (
+      body.length === 0 ||
+      (scope === "response"
+        ? this.responseRedactions().text
+        : this.textPatterns
+      ).length === 0
+    )
+      return body;
+    const text = new TextDecoder("utf-8", {
+      fatal: true,
+      ignoreBOM: true,
+    }).decode(body);
+    const redacted = redactResponseSource(
+      text,
+      [],
+      (value) => this.redact(value, scope),
+      retention.maxBytes,
+      retention.maxAllocationBytes,
+    );
+    if (!redacted.ok) throw new Error(redacted.error.message);
+    return redacted.value ?? body;
+  }
+
+  private retainSavedRedactions(
+    label: string,
+    additions: readonly string[],
+    savedUnits: number,
+    name: string,
+  ): void {
+    const available =
+      MAX_RETAINED_SAVED_CHARACTERS - savedUnits - this.secretSpellingUnits;
+    const minimum = additions.reduce(
+      (units, value) => units + leastSpellingUnits(value),
+      0,
+    );
+    const spellings =
+      minimum > available
+        ? minimum
+        : additions.reduce((units, value) => units + spellingUnits(value), 0);
+    if (available < 0 || spellings > available)
+      throw new Error(
+        `${name} cannot be saved: the scenario would keep more than ${String(MAX_RETAINED_SAVED_CHARACTERS)} characters of saved values.`,
+      );
+    this.secretSpellingUnits += spellings;
+    this.retainValues(label, additions);
+  }
+
+  /** Keep a value a step saved from a JSON response; a secret one is redacted from everything recorded after it. Refused before it is kept when the scenario would hold more than {@link MAX_RETAINED_SAVED_CHARACTERS}. */
+  save(
+    label: string,
+    name: string,
+    value: string | number,
+    secret: boolean,
+  ): void {
+    const text = String(value);
+    const replaced = this.saved.get(name);
+    const savedUnits =
+      this.savedUnits -
+      (replaced === undefined ? 0 : String(replaced).length) +
+      text.length;
+    const available =
+      MAX_RETAINED_SAVED_CHARACTERS - savedUnits - this.secretSpellingUnits;
+    const additions =
+      secret && available >= 0 ? this.valuesToAdmit(text, 1) : [];
+    this.retainSavedRedactions(label, additions, savedUnits, name);
+    if (secret) this.markResponseValue(text);
+    this.saved.set(name, value);
+    this.savedUnits = savedUnits;
+  }
+
+  /** A saved value with the JSON type it was saved with. */
+  savedValue(name: string): string | number {
+    const value = this.saved.get(name);
+    if (value === undefined) throw new Error(`No earlier step saved ${name}`);
+    return value;
+  }
+
+  value(source: BrowserScenarioValue | BrowserScenarioRequestValue): string {
     if (source.source === "literal") return source.value;
+    if (source.source === "saved") return String(this.savedValue(source.name));
     const value = this.values.get(source.secret_id);
     if (value === undefined)
       throw new Error("Validated browser secret was not resolved");
@@ -339,8 +664,16 @@ export class BrowserScenarioSecrets {
    * Callers measuring untrusted observations must reserve this cost before
    * invoking redaction. A one-byte secret can otherwise expand every byte.
    */
-  redactionAllocationCost(length: number): number {
-    return Math.ceil(length * this.redactionCostPerCharacter);
+  redactionAllocationCost(
+    length: number,
+    scope: ScenarioRedactionScope = "current",
+  ): number {
+    return Math.ceil(
+      length *
+        (scope === "response"
+          ? this.responseRedactions().costPerCharacter
+          : this.redactionCostPerCharacter),
+    );
   }
 
   redactFailure(error: AnalysisError): AnalysisError {
@@ -348,13 +681,18 @@ export class BrowserScenarioSecrets {
     return redactSensitiveFailure(error, (value) => this.redact(value));
   }
 
-  redact(value: string): string {
-    const patterns = this.textPatterns;
+  redact(value: string, scope: ScenarioRedactionScope = "current"): string {
+    const replacements =
+      scope === "response"
+        ? this.responseRedactions().text
+        : this.textReplacements;
+    const patterns = scope === "response" ? replacements : this.textPatterns;
     if (patterns.length === 0) return value;
     const output = replaceSecretLiterals(
       value,
-      this.textReplacements,
+      replacements,
       patterns,
+      this.textPatterns,
     );
     return containsSecretPattern(
       output,
@@ -366,10 +704,15 @@ export class BrowserScenarioSecrets {
   }
 
   /** Replace declared UTF-8, URI/form, and JSON-escaped secret spellings. */
-  redactBytes(value: Buffer): Buffer {
-    const patterns = this.textPatterns;
-    if (patterns.length === 0) return value;
-    const candidates = this.textNeedles;
+  redactBytes(
+    value: Buffer,
+    scope: ScenarioRedactionScope = "current",
+  ): Buffer {
+    const candidates =
+      scope === "response"
+        ? this.responseRedactions().needles
+        : this.textNeedles;
+    if (candidates.length === 0) return value;
     const hasPercentPatterns = candidates.some(
       ({ percentInsensitive }) => percentInsensitive,
     );
@@ -424,10 +767,22 @@ export class BrowserScenarioSecrets {
   }
 
   /** Remove only declared secret values from one URL and report that action. */
-  sanitizeUrl(value: string): SanitizedBrowserUrl {
-    const patterns = this.urlPatterns;
+  sanitizeUrl(
+    value: string,
+    scope: ScenarioRedactionScope = "current",
+  ): SanitizedBrowserUrl {
+    const replacements =
+      scope === "response"
+        ? this.responseRedactions().url
+        : this.urlReplacements;
+    const patterns = scope === "response" ? replacements : this.urlPatterns;
     if (patterns.length === 0) return sanitizeBrowserUrl(value);
-    const output = replaceSecretLiterals(value, this.urlReplacements, patterns);
+    const output = replaceSecretLiterals(
+      value,
+      replacements,
+      patterns,
+      this.urlPatterns,
+    );
     if (
       containsSecretPattern(output, normalizePercentEscapes(output), patterns)
     )
diff --git a/src/browser/CdpAuthorizedTarget.ts b/src/browser/CdpAuthorizedTarget.ts
index 95a2449a3a..063ad26cab 100644
--- a/src/browser/CdpAuthorizedTarget.ts
+++ b/src/browser/CdpAuthorizedTarget.ts
@@ -1,3 +1,4 @@
+import { selectCdpDiscoveryTarget } from "./CdpDiscoveryTargets.js";
 import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
 import { BrowserObservationError } from "../domain/browserObservationError.js";
 import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
@@ -15,8 +16,11 @@ export const authorizeCdpTarget = (
   readonly target: CdpEndpointTarget;
   readonly allowedOrigins: ReadonlySet;
 } => {
-  const target = discovery.targets.find(
-    (candidate) => candidate.id === input.target_id,
+  const target = selectCdpDiscoveryTarget(
+    discovery.targets,
+    input.target_id,
+    operation,
+    discovery.ambiguousTargetIds,
   );
   if (target === undefined)
     throw new BrowserObservationError(operation, "target_not_found", {
diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts
index f21f68c34c..00316328a4 100644
--- a/src/browser/CdpBrowserProvider.ts
+++ b/src/browser/CdpBrowserProvider.ts
@@ -166,7 +166,7 @@ export class CdpBrowserProvider implements BrowserObservationPort {
                 ]),
             ...(selected.allowed.some((target) => target.attached === null)
               ? [
-                  "CDP discovery omitted attachment state for one or more targets; attached is reported as unknown.",
+                  "CDP discovery could not establish debugger attachment state for one or more targets.",
                 ]
               : []),
           ],
diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts
index e945db0899..bfc3d9cb85 100644
--- a/src/browser/CdpCaptureDocuments.test.ts
+++ b/src/browser/CdpCaptureDocuments.test.ts
@@ -382,7 +382,7 @@ describe("CDP frame capture", () => {
   });
 });
 
-describe("CDP accessibility capture", () => {
+describe("CDP accessibility inventory and identity", () => {
   it("keeps an explicit empty accessibility inventory complete", () => {
     const completeness = new CdpCaptureCompleteness();
     const capture = captureAccessibility([{ nodes: [] }], {
@@ -467,6 +467,51 @@ describe("CDP accessibility capture", () => {
     });
   });
 
+  it("shares admitted identities between projection and child coverage", () => {
+    const completeness = new CdpCaptureCompleteness();
+    const capture = captureAccessibility(
+      [
+        {
+          nodes: [
+            {
+              nodeId: "parent",
+              ignored: false,
+              parentId: "",
+              childIds: ["", 3, "child", "missing"],
+            },
+            { nodeId: "child", ignored: false, parentId: "parent" },
+          ],
+        },
+      ],
+      { includeText: true, completeness },
+    );
+
+    expect(
+      capture.nodes.map(({ node_id, parent_id }) => ({ node_id, parent_id })),
+    ).toEqual([
+      { node_id: "parent", parent_id: null },
+      { node_id: "child", parent_id: "parent" },
+    ]);
+    expect(capture.hasUnobservedChildren).toBe(true);
+    expect(completeness.snapshot().excluded).toEqual([
+      { section: "accessibility", reason: "invalid_protocol_value", count: 2 },
+    ]);
+    const observed = captureAccessibility(
+      [
+        {
+          nodes: [
+            { nodeId: "parent", ignored: false, childIds: ["", null, "child"] },
+            { nodeId: "child", ignored: false },
+          ],
+        },
+      ],
+      { includeText: true },
+    );
+    expect(observed.hasUnobservedChildren).toBe(false);
+  });
+});
+
+describe("CDP accessibility text capture", () => {
   it("retains full approved accessibility text", () => {
     const capture = captureAccessibility(
       [
diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts
index ac7b1e6864..3eca8812f5 100644
--- a/src/browser/CdpCaptureDocuments.ts
+++ b/src/browser/CdpCaptureDocuments.ts
@@ -519,14 +519,24 @@ export const captureAccessibility = (
       );
       return [];
     }
-    const nodes: UnknownRecord[] = [];
+    const nodes: {
+      readonly nodeId: string;
+      readonly parentId: string | null;
+      readonly childIds: readonly string[];
+      readonly ignored: boolean;
+      readonly role: unknown;
+      readonly name: unknown;
+      readonly description: unknown;
+      readonly properties: unknown;
+    }[] = [];
     let malformedCount = 0;
     for (const entry of inventory) {
       const node = recordValue(entry);
+      const nodeId = cdpStringValue(node?.nodeId);
       if (
         node === undefined ||
-        cdpStringValue(node.nodeId) === undefined ||
-        cdpStringValue(node.nodeId)?.length === 0 ||
+        nodeId === undefined ||
+        nodeId.length === 0 ||
         typeof node.ignored !== "boolean"
       ) {
         malformedCount += 1;
@@ -538,17 +548,31 @@ export const captureAccessibility = (
         (parentId === undefined || parentId.length === 0)
       )
         malformedCount += 1;
+      const childIds = Array.isArray(node.childIds)
+        ? node.childIds.filter(
+            (childId): childId is string =>
+              typeof childId === "string" && childId.length > 0,
+          )
+        : [];
       if (
         node.childIds !== undefined &&
         (!Array.isArray(node.childIds) ||
-          node.childIds.some(
-            (childId) =>
-              cdpStringValue(childId) === undefined ||
-              cdpStringValue(childId)?.length === 0,
-          ))
+          childIds.length !== node.childIds.length)
       )
         malformedCount += 1;
-      nodes.push(node);
+      // Identity admission owns these facts. Projection and coverage consume
+      // the same parsed IDs, including usable siblings of malformed fields.
+      nodes.push({
+        nodeId,
+        parentId:
+          parentId === undefined || parentId.length === 0 ? null : parentId,
+        childIds,
+        ignored: node.ignored,
+        role: node.role,
+        name: node.name,
+        description: node.description,
+        properties: node.properties,
+      });
     }
     if (malformedCount > 0)
       options.completeness?.exclude(
@@ -558,12 +582,7 @@ export const captureAccessibility = (
       );
     return nodes;
   });
-  const nodeIds = new Set(
-    all.flatMap((node) => {
-      const nodeId = cdpStringValue(node.nodeId);
-      return nodeId === undefined ? [] : [nodeId];
-    }),
-  );
+  const nodeIds = new Set(all.map((node) => node.nodeId));
   let excludedFields = 0;
   const nodes = all.map((node) => {
     const captureText = (value: unknown): string | null => {
@@ -576,25 +595,20 @@ export const captureAccessibility = (
       return raw;
     };
     return {
-      node_id: cdpStringValue(node.nodeId) ?? "",
-      parent_id: cdpStringValue(node.parentId) ?? null,
+      node_id: node.nodeId,
+      parent_id: node.parentId,
       role: axText(node.role),
       name: captureText(node.name),
       description: captureText(node.description),
-      ignored: node.ignored === true,
+      ignored: node.ignored,
       states: accessibilityStates(node.properties),
     };
   });
   return {
     total: all.length,
     nodes,
-    hasUnobservedChildren: all.some(
-      (node) =>
-        Array.isArray(node.childIds) &&
-        node.childIds.some((childId) => {
-          const id = cdpStringValue(childId);
-          return id !== undefined && !nodeIds.has(id);
-        }),
+    hasUnobservedChildren: all.some((node) =>
+      node.childIds.some((childId) => !nodeIds.has(childId)),
     ),
     textCapture: {
       status: options.unavailable
diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts
index f185001fa1..d1543bb451 100644
--- a/src/browser/CdpCaptureEventHelpers.ts
+++ b/src/browser/CdpCaptureEventHelpers.ts
@@ -1,4 +1,5 @@
 import { err, ok, type Result } from "../domain/result.js";
+import { mediaTypeEssence } from "../domain/analysisView/recordedMediaType.js";
 import {
   admitRecords,
   isHttpUrl,
@@ -76,7 +77,10 @@ export const isJsonContentType = (
 };
 
 export const isJsonMediaType = (value: string | null | undefined): boolean => {
-  const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
+  const mediaType =
+    value === null || value === undefined ? null : mediaTypeEssence(value);
+  // CDP shape collection accepts structured JSON suffixes on any media type;
+  // replay's application-only JSON policy is a separate consumer decision.
   return (
     mediaType === "application/json" || mediaType?.endsWith("+json") === true
   );
diff --git a/src/browser/CdpCaptureEvents.shapeBudget.test.ts b/src/browser/CdpCaptureEvents.shapeBudget.test.ts
index cb6ebb5db9..76d2c0b142 100644
--- a/src/browser/CdpCaptureEvents.shapeBudget.test.ts
+++ b/src/browser/CdpCaptureEvents.shapeBudget.test.ts
@@ -79,6 +79,53 @@ const frame = (events: CdpCaptureEvents, payloadData: string) =>
     params: { requestId: "socket", response: { opcode: 1, payloadData } },
   });
 
+it.each([
+  ["\tAPPLICATION/JSON ; charset=utf-8\t", true],
+  ["application/problem+json", true],
+  ["model/example+json", true],
+  ["+json", false],
+  ["application /problem+json", false],
+  ["application/problem\n+json", false],
+  ["application/\u212a+json", false],
+  ["\napplication/json", false],
+  ["text/plain", false],
+])(
+  "admits shapes only from valid media types while preserving %j",
+  (mediaType, selected) => {
+    const events = createEvents();
+    events.ingest({
+      method: "Network.requestWillBeSent",
+      params: {
+        requestId: "typed",
+        timestamp: 1,
+        initiator: { type: "other" },
+        request: {
+          url: `${origin}/typed`,
+          method: "POST",
+          headers: { "Content-Type": mediaType },
+          postData: '{"count":1}',
+        },
+      },
+    });
+    expect(events.network.get("typed")?.body_shapes.request !== null).toBe(
+      selected,
+    );
+    events.ingest({
+      method: "Network.responseReceived",
+      params: {
+        requestId: "typed",
+        response: {
+          url: `${origin}/typed`,
+          status: 200,
+          mimeType: mediaType,
+        },
+      },
+    });
+    expect(events.responseBodyRequests().length).toBe(selected ? 1 : 0);
+    expect(events.network.get("typed")?.mime_type).toBe(mediaType);
+  },
+);
+
 it("distinguishes valid over-budget websocket JSON from non-JSON text", () => {
   const events = createEvents();
   openSocket(events);
diff --git a/src/browser/CdpCaptureStorage.test.ts b/src/browser/CdpCaptureStorage.test.ts
index 72665f1b43..870bed8c1b 100644
--- a/src/browser/CdpCaptureStorage.test.ts
+++ b/src/browser/CdpCaptureStorage.test.ts
@@ -1,6 +1,13 @@
 import { expect, it } from "vitest";
 
 import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
+import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
+import {
+  browserCaptureComparisonInputSchema,
+  compareBrowserCaptures,
+} from "../domain/browserCaptureComparison.js";
+import { webCaptureDiffSchema } from "../domain/webCaptureDiffSchemas.js";
+import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js";
 import { captureStorage } from "./CdpCaptureStorage.js";
 import { CdpCommandRejection } from "./CdpCommandRejection.js";
 import type { CdpConnection } from "./CdpConnection.js";
@@ -140,3 +147,218 @@ it("keeps an omitted IndexedDB remote value unknown", async () => {
   expect(record).toMatchObject({ value_sha256: null, complete: false });
   expect(value.fingerprints_complete).toBe(false);
 });
+
+it.each(["indexed_db", "cache"] as const)(
+  "advances %s pagination past malformed rows without repeating evidence",
+  async (scope) => {
+    const offsets: unknown[] = [];
+    const record = {
+      key: { type: "number", value: 1 },
+      primaryKey: { type: "number", value: 1 },
+      value: { type: "string", value: "first" },
+    };
+    const cacheEntry = {
+      requestURL: "https://example.test/first",
+      requestMethod: "GET",
+      requestHeaders: [],
+    };
+    const replies: Record = {
+      "Storage.getUsageAndQuota": { usage: 0, quota: 1000 },
+      "DOMStorage.getDOMStorageItems": { entries: [] },
+      "IndexedDB.requestDatabaseNames": {
+        databaseNames: scope === "indexed_db" ? ["db"] : [],
+      },
+      "CacheStorage.requestCacheNames": {
+        caches:
+          scope === "cache" ? [{ cacheName: "cache", cacheId: "id" }] : [],
+      },
+      "Network.getCookies": { cookies: [] },
+      "IndexedDB.requestDatabase": {
+        databaseWithObjectStores: {
+          name: "db",
+          version: 1,
+          objectStores: [
+            {
+              name: "records",
+              keyPath: { type: "string", string: "id" },
+              autoIncrement: false,
+              indexes: [],
+            },
+          ],
+        },
+      },
+      "CacheStorage.requestCachedResponse": { response: { body: "" } },
+    };
+    const connection = {
+      async send(
+        method: string,
+        params: Record,
+      ): Promise {
+        if (
+          method === "IndexedDB.requestData" ||
+          method === "CacheStorage.requestEntries"
+        ) {
+          offsets.push(params.skipCount);
+          // The first page includes a malformed row; offset 1 repeats valid evidence.
+          const rows =
+            params.skipCount === 0
+              ? [null, scope === "indexed_db" ? record : cacheEntry]
+              : params.skipCount === 1
+                ? [scope === "indexed_db" ? record : cacheEntry]
+                : [
+                    scope === "indexed_db"
+                      ? {
+                          ...record,
+                          key: { type: "number", value: 2 },
+                          primaryKey: { type: "number", value: 2 },
+                        }
+                      : {
+                          ...cacheEntry,
+                          requestURL: "https://example.test/second",
+                        },
+                  ];
+          return method === "IndexedDB.requestData"
+            ? { objectStoreDataEntries: rows, hasMore: params.skipCount === 0 }
+            : { cacheDataEntries: rows, returnCount: 3 };
+        }
+        if (!(method in replies)) throw new Error(method);
+        return replies[method];
+      },
+    } as unknown as CdpConnection;
+    const { value } = await captureStorage(
+      {
+        connection,
+        sessionId: undefined,
+        input: inspectWebPageInputSchema.parse({
+          cdp_endpoint: "http://127.0.0.1:9222",
+          target_id: "page",
+          include_storage_keys: true,
+          include_storage_fingerprints: true,
+        }),
+      },
+      "https://example.test/",
+      [],
+    );
+    expect(offsets).toEqual([0, 2]);
+    expect(
+      value.content_fingerprints.filter(
+        (item) =>
+          item.scope ===
+          (scope === "indexed_db" ? "indexed_db_record" : "cache_entry"),
+      ),
+    ).toHaveLength(2);
+    expect(value.fingerprints_complete).toBe(false);
+  },
+);
+
+const cookieBase = {
+  name: "fixture",
+  value: "same",
+  domain: "example.test",
+  path: "/",
+  expires: -1,
+  size: 11,
+  httpOnly: false,
+  secure: false,
+  session: true,
+  sameSite: "Lax",
+};
+
+const captureCookies = async (
+  cookies: readonly Record[],
+): Promise => {
+  const replies: Record = {
+    "Storage.getUsageAndQuota": { usage: 0, quota: 1000 },
+    "DOMStorage.getDOMStorageItems": { entries: [] },
+    "IndexedDB.requestDatabaseNames": { databaseNames: [] },
+    "CacheStorage.requestCacheNames": { caches: [] },
+    "Network.getCookies": { cookies },
+  };
+  const connection = {
+    async send(method: string): Promise {
+      if (!(method in replies)) throw new Error(method);
+      return replies[method];
+    },
+  } as unknown as CdpConnection;
+  const { value } = await captureStorage(
+    {
+      connection,
+      sessionId: undefined,
+      input: inspectWebPageInputSchema.parse({
+        cdp_endpoint: "http://127.0.0.1:9222",
+        target_id: "fixture-page",
+        include_storage_keys: true,
+        include_storage_fingerprints: true,
+      }),
+    },
+    "https://example.test/",
+    [],
+  );
+  return value;
+};
+
+const storageComparison = (
+  before: WebPageInspection["storage"],
+  after: WebPageInspection["storage"],
+) => {
+  const inspection = (storage: WebPageInspection["storage"]) => {
+    const capture = passiveNetworkCapture();
+    capture.storage = storage;
+    return capture;
+  };
+  return webCaptureDiffSchema.parse(
+    compareBrowserCaptures(
+      browserCaptureComparisonInputSchema.parse({
+        before: { inspection: inspection(before) },
+        after: { inspection: inspection(after) },
+      }),
+    ),
+  ).dimensions.storage;
+};
+
+it("keeps an opaque cookie partition unknown without dropping its value", async () => {
+  const plain = await captureCookies([cookieBase]);
+  const opaque = await captureCookies([
+    { ...cookieBase, partitionKeyOpaque: true },
+  ]);
+  const known = await captureCookies([
+    {
+      ...cookieBase,
+      partitionKey: {
+        topLevelSite: "https://top.example.test",
+        hasCrossSiteAncestor: false,
+      },
+      partitionKeyOpaque: false,
+    },
+  ]);
+  const cookieFingerprint = (storage: WebPageInspection["storage"]) => {
+    const fingerprint = storage.content_fingerprints.find(
+      (item) => item.scope === "cookie",
+    );
+    expect(fingerprint).toBeDefined();
+    return fingerprint!;
+  };
+  const plainCookie = cookieFingerprint(plain);
+  const opaqueCookie = cookieFingerprint(opaque);
+  const knownCookie = cookieFingerprint(known);
+  expect(opaque.fingerprints_complete).toBe(false);
+  expect(opaqueCookie).toMatchObject({
+    complete: false,
+    value_sha256: plainCookie.value_sha256,
+  });
+  expect(opaqueCookie.identity_sha256).not.toBe(plainCookie.identity_sha256);
+  expect(known.fingerprints_complete).toBe(true);
+  expect(knownCookie).toMatchObject({ complete: true });
+  expect(knownCookie.identity_sha256).not.toBe(plainCookie.identity_sha256);
+  expect(storageComparison(plain, opaque)).toMatchObject({
+    status: "unknown",
+    total_changes: 0,
+    changes: [],
+  });
+  expect(storageComparison(opaque, opaque)).toMatchObject({
+    status: "unknown",
+    total_changes: 0,
+    changes: [],
+  });
+  expect(storageComparison(plain, known).status).toBe("changed");
+});
diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts
index 26e7528657..3d2e33103d 100644
--- a/src/browser/CdpCaptureStorageFingerprints.ts
+++ b/src/browser/CdpCaptureStorageFingerprints.ts
@@ -148,6 +148,11 @@ const addCookies = async (
       run.state.complete = false;
       continue;
     }
+    // CDP Cookie.partitionKeyOpaque means the partition exists but is not
+    // serializable. A missing partitionKey means unpartitioned, so hashing
+    // that absence would align an unresolved partition with a different cookie.
+    // https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-Cookie
+    const partitionOpaque = cookie.partitionKeyOpaque === true;
     addFingerprint(run.state, {
       scope: "cookie",
       identity: {
@@ -155,9 +160,10 @@ const addCookies = async (
         path,
         name,
         partition_key: cookie.partitionKey ?? null,
+        ...(partitionOpaque ? { partition_opaque: true } : {}),
       },
       value,
-      complete: cookie.partitionKeyOpaque !== true,
+      complete: !partitionOpaque,
     });
   }
 };
@@ -313,6 +319,7 @@ const addIndexedDbRecords = async (
       run.state.complete = false;
       return;
     }
+    const examinedCount = result.objectStoreDataEntries.length;
     const entries = recordsValue(result.objectStoreDataEntries);
     if (entries.length !== result.objectStoreDataEntries.length)
       run.state.complete = false;
@@ -335,13 +342,13 @@ const addIndexedDbRecords = async (
     }
     if (result.hasMore !== true) return;
     if (
-      entries.length === 0 ||
-      skipCount > Number.MAX_SAFE_INTEGER - entries.length
+      examinedCount === 0 ||
+      skipCount > Number.MAX_SAFE_INTEGER - examinedCount
     ) {
       run.state.complete = false;
       return;
     }
-    skipCount += entries.length;
+    skipCount += examinedCount;
   }
 };
 
@@ -373,20 +380,21 @@ const addCaches = async (
         run.state.complete = false;
         break;
       }
+      const examinedCount = result.cacheDataEntries.length;
       const entries = recordsValue(result.cacheDataEntries);
       if (entries.length !== result.cacheDataEntries.length)
         run.state.complete = false;
       for (const entry of entries) await addCacheEntry(run, cache, entry);
       returnCount = result.returnCount;
-      if (skipCount + entries.length >= returnCount) break;
+      if (skipCount + examinedCount >= returnCount) break;
       if (
-        entries.length === 0 ||
-        skipCount > Number.MAX_SAFE_INTEGER - entries.length
+        examinedCount === 0 ||
+        skipCount > Number.MAX_SAFE_INTEGER - examinedCount
       ) {
         run.state.complete = false;
         break;
       }
-      skipCount += entries.length;
+      skipCount += examinedCount;
     }
   }
 };
diff --git a/src/browser/CdpDiscoveryTargets.test.ts b/src/browser/CdpDiscoveryTargets.test.ts
new file mode 100644
index 0000000000..3b7eabb892
--- /dev/null
+++ b/src/browser/CdpDiscoveryTargets.test.ts
@@ -0,0 +1,221 @@
+import { once } from "node:events";
+import { createServer } from "node:http";
+
+import { expect, it } from "vitest";
+import { BrowserObservationError } from "../domain/browserObservationError.js";
+
+import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
+import { inspectElectronPageInputSchema } from "../domain/javascript/electronObservation.js";
+import { V8InspectorProvider } from "../inspector/V8InspectorProvider.js";
+import { CdpBrowserProvider } from "./CdpBrowserProvider.js";
+import { CdpElectronProvider } from "./CdpElectronProvider.js";
+import { parseCdpDiscoveryTargets } from "./CdpDiscoveryTargets.js";
+import { discoverCdpBrowserSocket } from "./CdpEndpoint.js";
+import { authorizeCdpTarget } from "./CdpAuthorizedTarget.js";
+
+it.each(["http", "browser-socket"] as const)(
+  "%s retains sibling identities and marks duplicates before malformed rows are excluded",
+  (source) => {
+    const idField = source === "http" ? "id" : "targetId";
+    const result = parseCdpDiscoveryTargets(
+      [
+        { [idField]: "duplicate", type: "page", url: "https://allowed.test/" },
+        { [idField]: "duplicate", type: "page" },
+        {
+          [idField]: "sibling",
+          type: "page",
+          url: "https://allowed.test/other",
+        },
+      ],
+      "list_browser_targets",
+      source,
+    );
+    expect(result.targets).toMatchObject([
+      { id: "duplicate" },
+      { id: "sibling" },
+    ]);
+    expect(result.ambiguousTargetIds).toEqual(["duplicate"]);
+    expect(result.limitations).toEqual(
+      expect.arrayContaining([
+        expect.stringContaining("identity is ambiguous"),
+        expect.stringContaining("identity is malformed"),
+      ]),
+    );
+  },
+);
+
+it.each([0, 1, 2])(
+  "browser-socket selection rejects duplicate IDs before origin policy (malformed duplicate rows: %s)",
+  async (malformedRows) => {
+    const discovery = await discoverCdpBrowserSocket(
+      {
+        send: async (method) =>
+          method === "Browser.getVersion"
+            ? {
+                protocolVersion: "1.3",
+                product: "Chrome",
+                revision: "r",
+                userAgent: "ua",
+                jsVersion: "v8",
+              }
+            : {
+                targetInfos: [
+                  {
+                    targetId: "duplicate",
+                    type: "page",
+                    ...(malformedRows === 2
+                      ? {}
+                      : { url: "https://allowed.test/" }),
+                  },
+                  {
+                    targetId: "duplicate",
+                    type: "page",
+                    ...(malformedRows > 0
+                      ? {}
+                      : { url: "https://foreign.test/" }),
+                  },
+                  {
+                    targetId: "sibling",
+                    type: "page",
+                    url: "https://allowed.test/other",
+                  },
+                ],
+              },
+      },
+      "ws://127.0.0.1:9222/devtools/browser/fixture",
+      "inspect_web_page",
+    );
+    expect(() =>
+      authorizeCdpTarget(
+        discovery,
+        {
+          target_id: "duplicate",
+          allowed_origins: ["https://allowed.test"],
+        },
+        "inspect_web_page",
+      ),
+    ).toThrow(
+      expect.objectContaining({
+        reason: "invalid_endpoint_response",
+        detail: expect.stringContaining("ambiguous"),
+      }),
+    );
+    expect(
+      authorizeCdpTarget(
+        discovery,
+        {
+          target_id: "sibling",
+          allowed_origins: ["https://allowed.test"],
+        },
+        "inspect_web_page",
+      ).target.id,
+    ).toBe("sibling");
+  },
+);
+
+it.each(
+  (["browser", "electron", "inspector"] as const).flatMap((providerKind) => [
+    { providerKind, malformedRows: 0 },
+    { providerKind, malformedRows: 1 },
+    { providerKind, malformedRows: 2 },
+  ]),
+)(
+  "$providerKind rejects ambiguous HTTP target identity before authorizing its location or opening a socket (malformed duplicate rows: $malformedRows)",
+  async ({ providerKind, malformedRows }) => {
+    let upgrades = 0;
+    const server = createServer((request, response) => {
+      const address = server.address();
+      if (address === null || typeof address === "string")
+        throw new Error("Missing TCP address");
+      response.setHeader("Content-Type", "application/json");
+      response.end(
+        JSON.stringify(
+          request.url === "/json/version"
+            ? {
+                Browser: "Node.js/v24",
+                "Protocol-Version": "1.3",
+                "User-Agent": "fixture",
+                "V8-Version": "v8",
+                "WebKit-Version": "r",
+                webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/devtools/browser/fixture`,
+              }
+            : [
+                {
+                  id: "duplicate",
+                  type: "page",
+                  ...(malformedRows === 2
+                    ? {}
+                    : { url: "https://allowed.test/" }),
+                },
+                {
+                  id: "duplicate",
+                  type: "node",
+                  ...(malformedRows > 0
+                    ? {}
+                    : { url: "file:///missing/foreign.js" }),
+                },
+                {
+                  id: "sibling",
+                  type: "page",
+                  url: "https://allowed.test/other",
+                },
+              ],
+        ),
+      );
+    });
+    server.on("upgrade", (_request, socket) => {
+      upgrades += 1;
+      socket.destroy();
+    });
+    server.listen(0, "127.0.0.1");
+    await once(server, "listening");
+    const address = server.address();
+    if (address === null || typeof address === "string")
+      throw new Error("Missing TCP address");
+    const endpoint = `http://127.0.0.1:${String(address.port)}`;
+    const provider =
+      providerKind === "browser"
+        ? new CdpBrowserProvider()
+        : providerKind === "electron"
+          ? new CdpElectronProvider()
+          : new V8InspectorProvider();
+    try {
+      const result =
+        provider instanceof CdpBrowserProvider
+          ? await provider.inspectPage(
+              inspectWebPageInputSchema.parse({
+                cdp_endpoint: endpoint,
+                target_id: "duplicate",
+                allowed_origins: ["https://allowed.test"],
+                observation_ms: 0,
+              }),
+            )
+          : provider instanceof CdpElectronProvider
+            ? await provider.inspectPage(
+                inspectElectronPageInputSchema.parse({
+                  cdp_endpoint: endpoint,
+                  target_id: "duplicate",
+                  observation_ms: 0,
+                }),
+              )
+            : await provider.observe({
+                inspector_endpoint: endpoint,
+                target_id: "duplicate",
+                observation_ms: 0,
+              });
+      expect(result.ok).toBe(false);
+      if (result.ok) throw new Error("Expected ambiguous target rejection");
+      expect(result.error).toBeInstanceOf(BrowserObservationError);
+      expect(result.error.userMessage).toContain("ambiguous");
+      expect(upgrades).toBe(0);
+    } finally {
+      await provider.close();
+      server.closeAllConnections();
+      await new Promise((resolve, reject) =>
+        server.close((error) =>
+          error === undefined ? resolve() : reject(error),
+        ),
+      );
+    }
+  },
+);
diff --git a/src/browser/CdpDiscoveryTargets.ts b/src/browser/CdpDiscoveryTargets.ts
index 1e0967d475..eac2b772cc 100644
--- a/src/browser/CdpDiscoveryTargets.ts
+++ b/src/browser/CdpDiscoveryTargets.ts
@@ -12,7 +12,11 @@ const identitySchema = z.object({
   webSocketDebuggerUrl: z.unknown().optional(),
 });
 
-/** Shared HTTP /json/list facts; socket authority stays with each transport owner. */
+const browserSocketIdentitySchema = identitySchema.omit({ id: true }).extend({
+  targetId: identitySchema.shape.id,
+});
+
+/** Shared discovery facts; socket authority stays with each transport owner. */
 export interface CdpDiscoveryTarget {
   readonly id: string;
   readonly type: string;
@@ -26,39 +30,61 @@ export interface CdpDiscoveryTarget {
 export const parseCdpDiscoveryTargets = (
   input: unknown,
   operation: BrowserObservationOperation,
+  source: "http" | "browser-socket" = "http",
 ): {
   readonly targets: readonly CdpDiscoveryTarget[];
+  readonly ambiguousTargetIds: readonly string[];
   readonly limitations: readonly string[];
 } => {
+  const label = source === "http" ? "CDP /json/list" : "CDP Target.getTargets";
   if (!Array.isArray(input))
     throw new BrowserObservationError(operation, "invalid_endpoint_response", {
-      detail: "CDP /json/list must return an array of target rows.",
+      detail: `${label} must return an array of target rows.`,
     });
+  const schema =
+    source === "browser-socket" ? browserSocketIdentitySchema : identitySchema;
   const targets: CdpDiscoveryTarget[] = [];
   const limitations: string[] = [];
+  const idCounts = new Map();
+  for (const row of input) {
+    if (typeof row !== "object" || row === null || Array.isArray(row)) continue;
+    const id: unknown = Reflect.get(row, source === "http" ? "id" : "targetId");
+    if (typeof id === "string" && id !== "")
+      idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
+  }
+  const ambiguousTargetIds: string[] = [];
+  for (const [id, count] of idCounts) {
+    if (count > 1) {
+      ambiguousTargetIds.push(id);
+      limitations.push(
+        `${label} reported target ID ${id} in ${String(count)} rows; its identity is ambiguous and cannot be selected.`,
+      );
+    }
+  }
   for (const [index, row] of input.entries()) {
-    const parsed = identitySchema.safeParse(row);
+    const parsed = schema.safeParse(row);
     if (!parsed.success) {
       limitations.push(
-        `CDP /json/list row ${String(index)} was excluded because its target identity is malformed: ${JSON.stringify(parsed.error.issues)}.`,
+        `${label} row ${String(index)} was excluded because its target identity is malformed: ${JSON.stringify(parsed.error.issues)}.`,
       );
       continue;
     }
     const target = parsed.data;
+    const id = "targetId" in target ? target.targetId : target.id;
     if (
       target.attached !== undefined &&
       target.attached !== null &&
       typeof target.attached !== "boolean"
     )
       limitations.push(
-        `CDP /json/list row ${String(index)} target ${target.id} reported malformed attached metadata ${JSON.stringify(target.attached)}; attachment state is unknown.`,
+        `${label} row ${String(index)} target ${id} reported malformed attached metadata ${JSON.stringify(target.attached)}; attachment state is unknown.`,
       );
     if (target.title !== undefined && typeof target.title !== "string")
       limitations.push(
-        `CDP /json/list row ${String(index)} target ${target.id} reported malformed title metadata ${JSON.stringify(target.title)}; title is unavailable.`,
+        `${label} row ${String(index)} target ${id} reported malformed title metadata ${JSON.stringify(target.title)}; title is unavailable.`,
       );
     targets.push({
-      id: target.id,
+      id,
       type: target.type,
       url: target.url,
       title: typeof target.title === "string" ? target.title : "",
@@ -66,5 +92,25 @@ export const parseCdpDiscoveryTargets = (
       webSocketDebuggerUrl: target.webSocketDebuggerUrl,
     });
   }
-  return { targets, limitations };
+  return { targets, limitations, ambiguousTargetIds };
+};
+
+/** Select only an unambiguous identity before applying target-location policy. */
+export const selectCdpDiscoveryTarget = <
+  Target extends { readonly id: string },
+>(
+  targets: readonly Target[],
+  targetId: string,
+  operation: BrowserObservationOperation,
+  ambiguousTargetIds: readonly string[] = [],
+): Target | undefined => {
+  const matches = targets.filter((target) => target.id === targetId);
+  if (matches.length > 1 || ambiguousTargetIds.includes(targetId))
+    throw new BrowserObservationError(operation, "invalid_endpoint_response", {
+      detail: `Target ID ${targetId} is ambiguous in endpoint discovery; REA cannot select its identity or location.`,
+      diagnostics: { target_id: targetId },
+      remediationAction:
+        "Refresh discovery after the endpoint reports a unique row for this target ID, or select a different target.",
+    });
+  return matches[0];
 };
diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts
index f4eaa6ff48..c19bd67342 100644
--- a/src/browser/CdpElectronInspection.ts
+++ b/src/browser/CdpElectronInspection.ts
@@ -114,7 +114,7 @@ export const inspectCdpElectronPage = async (
       "Known script frame identity is retained independently from frame_scope authorization. Unknown frame scope is attach-limited; scripts from known excluded document generations are omitted before source reads.",
       ...(context.target.attached === null
         ? [
-            "CDP discovery omitted the target attachment state; attached is reported as unknown.",
+            "CDP discovery could not establish the target's debugger attachment state.",
           ]
         : []),
     ],
diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts
index 7924c61749..1dc918975d 100644
--- a/src/browser/CdpElectronProvider.ts
+++ b/src/browser/CdpElectronProvider.ts
@@ -1,3 +1,4 @@
+import { selectCdpDiscoveryTarget } from "./CdpDiscoveryTargets.js";
 import {
   DEFAULT_ASAR_HEADER_ADMISSION,
   type AsarHeaderAdmission,
@@ -206,7 +207,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
                 ]),
             ...(allowed.some((target) => target.attached === null)
               ? [
-                  "CDP discovery omitted attachment state for one or more targets; attached is reported as unknown.",
+                  "CDP discovery could not establish debugger attachment state for one or more targets.",
                 ]
               : []),
           ],
@@ -283,7 +284,7 @@ export class CdpElectronProvider implements ElectronObservationPort {
         options.signal,
       );
       const target = await authorizeTarget(
-        discovery.targets,
+        discovery,
         input.target_id,
         this.#asarHeaderAdmission,
         options.signal,
@@ -431,13 +432,18 @@ export class CdpElectronProvider implements ElectronObservationPort {
 }
 
 const authorizeTarget = async (
-  targets: readonly CdpEndpointTarget[],
+  discovery: CdpEndpointDiscovery,
   targetId: string,
   asarHeaderAdmission: AsarHeaderAdmission,
   signal?: AbortSignal,
 ): Promise => {
   throwIfElectronOperationCancelled(signal, "inspect_electron_page");
-  const target = targets.find(({ id }) => id === targetId);
+  const target = selectCdpDiscoveryTarget(
+    discovery.targets,
+    targetId,
+    "inspect_electron_page",
+    discovery.ambiguousTargetIds,
+  );
   if (target === undefined)
     throw new BrowserObservationError("inspect_web_page", "target_not_found");
   if (target.type !== "page")
diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts
index 5ed027cf62..0e7aa45333 100644
--- a/src/browser/CdpElectronScripts.ts
+++ b/src/browser/CdpElectronScripts.ts
@@ -4,10 +4,9 @@ import type {
   InspectElectronPageInput,
 } from "../domain/javascript/electronObservation.js";
 import { digestCanonicalValue } from "../domain/canonicalDigest.js";
-import { createWebTextArtifact } from "../domain/webContentArtifact.js";
 import type { CdpConnection } from "./CdpConnection.js";
 import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
-import { requiredScriptSource } from "./CdpCaptureValues.js";
+import { capturePassiveCdpScriptSource } from "./CdpScriptSourceCapture.js";
 import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
 import { CDP_CAPTURE_SCRIPT_METADATA_LIMITS } from "./CdpCaptureEventTypes.js";
 import type {
@@ -18,11 +17,7 @@ import { authorizedElectronFile } from "./ElectronFileScope.js";
 import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
 import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
 import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
-import {
-  CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
-  cdpScriptSourceDeadlineOmissionReason,
-  CdpScriptSourceBudget,
-} from "./CdpScriptSourceBudget.js";
+import { CdpScriptSourceBudget } from "./CdpScriptSourceBudget.js";
 
 interface ElectronScriptCaptureInput {
   readonly connection: CdpConnection;
@@ -48,8 +43,6 @@ export const captureElectronScripts = async (
   const commandTimeoutMs = selectedCdpCommandTimeoutMs(
     input.request.command_timeout_ms,
   );
-  const deadlineOmission =
-    cdpScriptSourceDeadlineOmissionReason(commandTimeoutMs);
   const sourceBudget =
     input.request.include_script_sources && input.scripts.length > 0
       ? new CdpScriptSourceBudget(input.signal, commandTimeoutMs)
@@ -104,35 +97,21 @@ export const captureElectronScripts = async (
       seen.add(scriptKey);
       metadataBytes += retainedBytes;
       total += 1;
-      let source: ElectronScriptSource = input.request.include_script_sources
-        ? {
-            included: false,
-            reason: deadlineOmission,
-          }
-        : {
-            included: false,
-            reason: "source capture was not selected",
-          };
-      if (
-        input.request.include_script_sources &&
-        isSourceDeadlineExpired(sourceBudget)
-      ) {
-        excludeScriptSource(input.completeness);
-      } else if (
-        input.request.include_script_sources &&
-        sourceBudget !== undefined
-      ) {
-        try {
-          source = await captureScriptSource(input, script, sourceBudget);
-        } catch (cause: unknown) {
-          throwIfCallerCancelled(input.signal);
-          if (isSourceDeadlineExpired(sourceBudget)) {
-            excludeScriptSource(input.completeness);
-          } else {
-            throw cause;
-          }
-        }
-      }
+      const source =
+        sourceBudget === undefined
+          ? {
+              included: false as const,
+              reason: "source capture was not selected",
+            }
+          : await capturePassiveCdpScriptSource({
+              connection: input.connection,
+              sessionId: input.sessionId,
+              signal: input.signal,
+              operation: "inspect_electron_page",
+              scriptId: script.scriptId,
+              sourceBudget,
+              completeness: input.completeness,
+            });
       items.push({ script_key: scriptKey, ...identity, source });
     }
   } finally {
@@ -148,52 +127,9 @@ export const captureElectronScripts = async (
   };
 };
 
-type ElectronScriptSource =
-  ElectronPageInspection["scripts"]["items"][number]["source"];
-
-const captureScriptSource = async (
-  input: ElectronScriptCaptureInput,
-  script: ElectronScriptDraft,
-  sourceBudget: CdpScriptSourceBudget,
-): Promise => {
-  const response = await input.connection.send(
-    "Debugger.getScriptSource",
-    { scriptId: script.scriptId },
-    input.sessionId,
-    sourceBudget.signal,
-  );
-  throwIfCallerCancelled(input.signal);
-  const text = requiredScriptSource(response, "inspect_electron_page");
-  if (sourceBudget.timedOut) {
-    excludeScriptSource(input.completeness);
-    return {
-      included: false,
-      reason: cdpScriptSourceDeadlineOmissionReason(
-        sourceBudget.commandTimeoutMs,
-      ),
-    };
-  }
-  if (!sourceBudget.admit(text)) {
-    excludeScriptSource(input.completeness);
-    return { included: false, reason: CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON };
-  }
-  return {
-    included: true,
-    artifact: createWebTextArtifact(text, "text/javascript"),
-  };
-};
-
-const excludeScriptSource = (completeness: CdpCaptureCompleteness): void => {
-  completeness.exclude("script_sources", "resource_budget_exhausted");
-};
-
 const throwIfCallerCancelled = (signal: AbortSignal | undefined): void => {
   if (signal?.aborted === true)
     throw new AnalysisCancelledError("inspect_electron_page", {
       cause: signal.reason,
     });
 };
-
-const isSourceDeadlineExpired = (
-  sourceBudget: CdpScriptSourceBudget | undefined,
-): boolean => sourceBudget?.timedOut === true;
diff --git a/src/browser/CdpEndpoint.test.ts b/src/browser/CdpEndpoint.test.ts
index ea9e8776e4..9daa4e4a4f 100644
--- a/src/browser/CdpEndpoint.test.ts
+++ b/src/browser/CdpEndpoint.test.ts
@@ -7,8 +7,10 @@ import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
 
 import { afterEach, describe, expect, it } from "vitest";
 
+import type { CdpConnection } from "./CdpConnection.js";
 import {
   discoverCdpEndpoint,
+  discoverCdpBrowserSocket,
   readCdpJson,
   withCdpDiscoveryDeadline,
 } from "./CdpEndpoint.js";
@@ -323,6 +325,54 @@ it("names the HTTP discovery document budget when that response deadline expires
   );
 });
 
+const socketDiscoveryConnection = (
+  targetReply: unknown,
+): Pick => ({
+  async send(method: string): Promise {
+    if (method === "Browser.getVersion")
+      return {
+        protocolVersion: "1.3",
+        product: "fixture",
+        revision: "fixture",
+        userAgent: "fixture",
+        jsVersion: "fixture",
+      };
+    if (method === "Target.getTargets") return targetReply;
+    throw new Error(method);
+  },
+});
+
+it.each([
+  { label: "absent document", reply: undefined },
+  { label: "null document", reply: null },
+  { label: "array document", reply: [] },
+  { label: "missing target list", reply: {} },
+  { label: "null target list", reply: { targetInfos: null } },
+  { label: "string target list", reply: { targetInfos: "invalid" } },
+  { label: "object target list", reply: { targetInfos: {} } },
+])("rejects an $label during browser-socket discovery", async ({ reply }) => {
+  await expect(
+    discoverCdpBrowserSocket(
+      socketDiscoveryConnection(reply),
+      "ws://127.0.0.1:9222/devtools/browser/fixture",
+      "list_browser_targets",
+    ),
+  ).rejects.toMatchObject({
+    reason: "invalid_endpoint_response",
+    detail: expect.stringContaining("CDP Target.getTargets"),
+  });
+});
+
+it("retains an explicitly empty browser-socket target inventory", async () => {
+  await expect(
+    discoverCdpBrowserSocket(
+      socketDiscoveryConnection({ targetInfos: [] }),
+      "ws://127.0.0.1:9222/devtools/browser/fixture",
+      "list_browser_targets",
+    ),
+  ).resolves.toMatchObject({ targets: [], limitations: [] });
+});
+
 it("preserves an independent typed cancellation when the discovery deadline also expires", async () => {
   const primary = new AnalysisCancelledError("inspect_web_page", {
     cause: "independent cancellation",
@@ -338,3 +388,67 @@ it("preserves an independent typed cancellation when the discovery deadline also
   ).catch((cause: unknown) => cause);
   expect(failure).toBe(primary);
 });
+
+it("preserves browser-socket sibling identities and reports malformed optional metadata", async () => {
+  const discovery = await discoverCdpBrowserSocket(
+    {
+      send: async (method) =>
+        method === "Browser.getVersion"
+          ? {
+              protocolVersion: "1.3",
+              product: "Chrome/fixture",
+              revision: "r",
+              userAgent: "fixture",
+              jsVersion: "v8",
+            }
+          : {
+              targetInfos: [
+                {
+                  targetId: "valid",
+                  type: "page",
+                  url: "https://example.test/",
+                  title: "Example",
+                  attached: false,
+                },
+                {
+                  targetId: "partial",
+                  type: "page",
+                  url: "https://example.test/other",
+                  title: 42,
+                  attached: "yes",
+                },
+                { targetId: "broken", type: "page" },
+                {
+                  id: "wrong-identity-field",
+                  type: "page",
+                  url: "https://example.test/",
+                },
+              ],
+            },
+    },
+    "ws://127.0.0.1:9222/devtools/browser/fixture",
+    "list_browser_targets",
+  );
+  expect(discovery.targets).toEqual([
+    {
+      id: "valid",
+      type: "page",
+      url: "https://example.test/",
+      title: "Example",
+      attached: false,
+    },
+    {
+      id: "partial",
+      type: "page",
+      url: "https://example.test/other",
+      title: "",
+      attached: null,
+    },
+  ]);
+  expect(discovery.limitations).toHaveLength(4);
+  expect(
+    discovery.limitations?.every((limitation) =>
+      limitation.startsWith("CDP Target.getTargets row"),
+    ),
+  ).toBe(true);
+});
diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts
index c6012fd5d7..b21f934860 100644
--- a/src/browser/CdpEndpoint.ts
+++ b/src/browser/CdpEndpoint.ts
@@ -16,6 +16,7 @@ import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js";
 import { safeParseJson } from "../domain/safeJson.js";
 import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
 import type { CdpConnection } from "./CdpConnection.js";
+import { recordValue } from "./CdpCaptureValues.js";
 import {
   parseCdpDiscoveryTargets,
   type CdpDiscoveryTarget,
@@ -73,18 +74,6 @@ const browserSocketVersionSchema = z.object({
   jsVersion: z.string(),
 });
 
-const browserSocketTargetsSchema = z.object({
-  targetInfos: z.array(
-    z.object({
-      targetId: z.string().min(1),
-      type: z.string().min(1),
-      title: z.string().default(""),
-      url: z.string(),
-      attached: z.boolean().optional(),
-    }),
-  ),
-});
-
 /** Validated direct CDP WebSocket bound to one discovered page target. */
 interface CdpPageWebSocketEndpoint {
   readonly scope: "page";
@@ -109,6 +98,7 @@ export interface CdpEndpointTarget {
 
 export interface CdpEndpointDiscovery {
   readonly limitations?: readonly string[];
+  readonly ambiguousTargetIds?: readonly string[];
   readonly webSocket: CdpWebSocketEndpoint;
   readonly version: {
     readonly product: string;
@@ -141,12 +131,13 @@ export const discoverCdpEndpoint = async (
     versionInput,
     operation,
   );
-  const { targets, limitations } = parseCdpDiscoveryTargets(
+  const { targets, limitations, ambiguousTargetIds } = parseCdpDiscoveryTargets(
     targetsInput,
     operation,
   );
   return {
     limitations,
+    ambiguousTargetIds,
     webSocket: safeCdpWebSocketEndpoint(
       endpoint,
       version.webSocketDebuggerUrl,
@@ -164,7 +155,7 @@ export const discoverCdpEndpoint = async (
       type: target.type,
       title: sanitizeTargetTitle(target.title, target.url),
       url: target.url,
-      attached: target.attached ?? null,
+      attached: target.attached,
       ...targetWebSocket(endpoint, target, operation),
     })),
   };
@@ -231,7 +222,7 @@ export const withCdpDiscoveryDeadline = async (
  * apply the discovery deadline through `signal`.
  */
 export const discoverCdpBrowserSocket = async (
-  connection: CdpConnection,
+  connection: Pick,
   url: string,
   operation: BrowserObservationOperation,
   signal?: AbortSignal,
@@ -253,12 +244,14 @@ export const discoverCdpBrowserSocket = async (
     versionInput,
     operation,
   );
-  const targets = parseCdpEndpointValue(
-    browserSocketTargetsSchema,
-    targetsInput,
+  const { targets, limitations, ambiguousTargetIds } = parseCdpDiscoveryTargets(
+    recordValue(targetsInput)?.targetInfos,
     operation,
+    "browser-socket",
   );
   return {
+    limitations,
+    ambiguousTargetIds,
     webSocket: { scope: "browser", url },
     version: {
       product: version.product,
@@ -267,12 +260,12 @@ export const discoverCdpBrowserSocket = async (
       user_agent: version.userAgent,
       js_version: version.jsVersion,
     },
-    targets: targets.targetInfos.map((target) => ({
-      id: target.targetId,
+    targets: targets.map((target) => ({
+      id: target.id,
       type: target.type,
       title: sanitizeTargetTitle(target.title, target.url),
       url: target.url,
-      attached: target.attached ?? null,
+      attached: target.attached,
     })),
   };
 };
diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts
index e2550ba8ab..03cfd7cc30 100644
--- a/src/browser/CdpPageCapture.ts
+++ b/src/browser/CdpPageCapture.ts
@@ -96,7 +96,7 @@ export const capturePage = async (
       "URL-less scripts and console events without an allowed source URL are excluded because their origin cannot be proven.",
       ...(context.target.attached === null
         ? [
-            "CDP discovery omitted the target attachment state; attached is reported as unknown.",
+            "CDP discovery could not establish the target's debugger attachment state.",
           ]
         : []),
     ],
diff --git a/src/browser/CdpPageCaptureScripts.ts b/src/browser/CdpPageCaptureScripts.ts
index 14a8531775..7e8507e704 100644
--- a/src/browser/CdpPageCaptureScripts.ts
+++ b/src/browser/CdpPageCaptureScripts.ts
@@ -6,22 +6,13 @@ import {
 import type { CapturedResource } from "./CdpCaptureDocuments.js";
 import type { CdpCaptureEvents } from "./CdpCaptureEvents.js";
 import type { CapturedScript } from "./CdpCaptureEventTypes.js";
-import {
-  requiredScriptSource,
-  sourceExcluded,
-  sourceResult,
-} from "./CdpCaptureValues.js";
+import { sourceExcluded } from "./CdpCaptureValues.js";
+import { capturePassiveCdpScriptSource } from "./CdpScriptSourceCapture.js";
 import type { CaptureContext } from "./CdpPageCapture.js";
 import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
 import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
-import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
-import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
 import { selectedCdpCommandTimeoutMs } from "../domain/browserDeadline.js";
-import {
-  CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
-  cdpScriptSourceDeadlineOmissionReason,
-  CdpScriptSourceBudget,
-} from "./CdpScriptSourceBudget.js";
+import { CdpScriptSourceBudget } from "./CdpScriptSourceBudget.js";
 
 export interface CapturedScripts {
   readonly inventory: WebPageInspection["scripts"];
@@ -67,8 +58,6 @@ export const captureScripts = async (
   const commandTimeoutMs = selectedCdpCommandTimeoutMs(
     context.input.command_timeout_ms,
   );
-  const deadlineOmission =
-    cdpScriptSourceDeadlineOmissionReason(commandTimeoutMs);
   const sourceBudget =
     context.input.include_script_sources && scripts.size > 0
       ? new CdpScriptSourceBudget(context.signal, commandTimeoutMs)
@@ -84,34 +73,18 @@ export const captureScripts = async (
           resources,
         ),
       };
-      let source: WebPageInspection["scripts"]["items"][number]["source"] =
-        context.input.include_script_sources
-          ? sourceExcluded(deadlineOmission)
-          : sourceExcluded("source capture was not selected");
-      if (
-        context.input.include_script_sources &&
-        isSourceDeadlineExpired(sourceBudget)
-      ) {
-        events.completeness.exclude(
-          "script_sources",
-          "resource_budget_exhausted",
-        );
-      } else if (
-        context.input.include_script_sources &&
-        sourceBudget !== undefined
-      ) {
-        try {
-          source = await captureScriptSource(script, state, sourceBudget);
-        } catch (cause: unknown) {
-          throwIfCallerCancelled(context.signal, context.operation);
-          if (isSourceDeadlineExpired(sourceBudget))
-            events.completeness.exclude(
-              "script_sources",
-              "resource_budget_exhausted",
-            );
-          else throw cause;
-        }
-      }
+      const source =
+        sourceBudget === undefined
+          ? sourceExcluded("source capture was not selected")
+          : await capturePassiveCdpScriptSource({
+              connection: context.connection,
+              sessionId: context.sessionId,
+              signal: context.signal,
+              operation: context.operation,
+              scriptId: script.scriptId,
+              sourceBudget,
+              completeness: events.completeness,
+            });
       drafts.push(buildScriptDraft(script, state, source));
     }
   } finally {
@@ -168,51 +141,6 @@ const buildScriptDraft = (
   };
 };
 
-const captureScriptSource = async (
-  script: CapturedScript,
-  state: ScriptDraftState,
-  sourceBudget: CdpScriptSourceBudget,
-): Promise => {
-  const { context } = state;
-  const response = await context.connection.send(
-    "Debugger.getScriptSource",
-    { scriptId: script.scriptId },
-    context.sessionId,
-    sourceBudget.signal,
-  );
-  throwIfCallerCancelled(context.signal, context.operation);
-  const content = requiredScriptSource(response, context.operation);
-  if (sourceBudget.timedOut) {
-    state.events.completeness.exclude(
-      "script_sources",
-      "resource_budget_exhausted",
-    );
-    return sourceExcluded(
-      cdpScriptSourceDeadlineOmissionReason(sourceBudget.commandTimeoutMs),
-    );
-  }
-  if (!sourceBudget.admit(content)) {
-    state.events.completeness.exclude(
-      "script_sources",
-      "resource_budget_exhausted",
-    );
-    return sourceExcluded(CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON);
-  }
-  return sourceResult(content);
-};
-
-const throwIfCallerCancelled = (
-  signal: AbortSignal | undefined,
-  operation: BrowserObservationOperation,
-): void => {
-  if (signal?.aborted === true)
-    throw new AnalysisCancelledError(operation, { cause: signal.reason });
-};
-
-const isSourceDeadlineExpired = (
-  sourceBudget: CdpScriptSourceBudget | undefined,
-): boolean => sourceBudget?.timedOut === true;
-
 const keyAndBuildInventory = (
   drafts: readonly {
     readonly item: ScriptDraftItem;
diff --git a/src/browser/CdpSafeMetadata.test.ts b/src/browser/CdpSafeMetadata.test.ts
index 3adb0dc803..efa72d212e 100644
--- a/src/browser/CdpSafeMetadata.test.ts
+++ b/src/browser/CdpSafeMetadata.test.ts
@@ -428,6 +428,52 @@ describe("Link header value delimiters", () => {
     },
   );
 
+  it.each(["\u00a0", "\u000b", "\u000c", "\u2003", "\ufeff"])(
+    "preserves non-HTTP whitespace %j without inventing agent relations",
+    (separator) => {
+      for (const declaration of [
+        `"alternate${separator}mcp"`,
+        `${separator}mcp${separator}`,
+        `"${separator}mcp${separator}"`,
+      ]) {
+        const captured = safeResponseMetadata(
+          "owned",
+          "https://owned.test/page",
+          { headers: { Link: `; rel=${declaration}` } },
+          new Set(["https://owned.test"]),
+        );
+        expect(captured.response.links[0]?.rel).toEqual([
+          declaration.replace(/^"|"$/gu, ""),
+        ]);
+        expect(captured.agentHints).toEqual([]);
+      }
+    },
+  );
+
+  it("does not trim non-HTTP whitespace into link or parameter syntax", () => {
+    expect(captureLinks("\u00a0; rel=mcp")).toEqual([]);
+    const links = captureLinks(
+      "; \u00a0rel=mcp; rel\u00a0=service-desc",
+    );
+    expect(links[0]?.rel).toEqual([]);
+  });
+
+  it("uses HTTP spaces and tabs for relation and parameter boundaries", () => {
+    const captured = safeResponseMetadata(
+      "owned",
+      "https://owned.test/page",
+      { headers: { Link: '\t; \trel\t=\t" MCP\t service-desc "\t' } },
+      new Set(["https://owned.test"]),
+    );
+    expect(captured.response.links[0]?.rel).toEqual(["mcp", "service-desc"]);
+    expect(captured.agentHints).toContainEqual({
+      mechanism: "link_rel",
+      declaration: "mcp service-desc",
+      url: "https://owned.test/agent",
+      trust: "page-declared-untrusted",
+    });
+  });
+
   it("keeps delimiters scoped to each CDP field line", () => {
     const links = captureLinks(
       '; title="unterminated\n; rel=mcp',
@@ -494,7 +540,7 @@ describe("safe CDP response metadata referrer policy", () => {
     ],
     ["origin, future-policy", "origin"],
     ["future-policy, no-referrer", "no-referrer"],
-    ["unsafe-url, NO-REFERRER", "no-referrer"],
+    ["unsafe-url, NO-REFERRER", "unsafe-url"],
     ["origin,, no-referrer", "no-referrer"],
     // CDP joins repeated field lines with LF.
     ["no-referrer\nstrict-origin", "strict-origin"],
@@ -502,6 +548,18 @@ describe("safe CDP response metadata referrer policy", () => {
     ["future-policy", null],
     ['unsafe-url, "no-referrer"', null],
     ["no-referrer; report-to=x, origin", null],
+    ["origin, no-referrer; report-to=x", null],
+    ["origin, \u00a0no-referrer", null],
+    ["\u00a0no-referrer, origin", null],
+    ["NO-REFERRER", null],
+    ["NO-REFERRER, origin", "origin"],
+    ["origin, \u212a", null],
+    ["origin, \u017f", null],
+    // Legacy meta names do not declare a policy through the response header.
+    ["never, always, default, origin-when-crossorigin", null],
+    ["no-referrer, never, always", "no-referrer"],
+    ['"no-referrer"', null],
+    ["no-referrer; report-to=x", null],
     ["", null],
     [undefined, null],
     ...[
@@ -607,3 +665,81 @@ describe("Link context identity and display", () => {
     },
   );
 });
+
+describe("response metadata whitespace boundaries", () => {
+  it.each(["\u00a0", "\u2003", "\ufeff", "\v"])(
+    "keeps %j out of CSP delimiters and HTTP optional whitespace",
+    (whitespace) => {
+      const captured = safeResponseMetadata(
+        "whitespace",
+        `${origin}/page`,
+        {
+          mimeType: `${whitespace}Text/Plain${whitespace}`,
+          headers: {
+            "Content-Security-Policy":
+              `default-src${whitespace}'self'; ` +
+              `script-src 'self'${whitespace}https:; ` +
+              `${whitespace}img-src data:`,
+            "Content-Length": `${whitespace}100${whitespace}`,
+            "Cross-Origin-Resource-Policy": `${whitespace}same-origin${whitespace}`,
+            "X-Content-Type-Options": `${whitespace}nosniff${whitespace}`,
+            "Referrer-Policy": `${whitespace}no-referrer${whitespace}`,
+            "Permissions-Policy": `${whitespace}camera=(), geolocation=()`,
+            "Content-Encoding": `${whitespace}GZip${whitespace}`,
+          },
+        },
+        new Set([origin]),
+      );
+      expect(captured.response.csp).toEqual({
+        directives:
+          whitespace === "\v"
+            ? [
+                {
+                  name: "script-src",
+                  sources: [{ kind: "other", value: null }],
+                },
+              ]
+            : [],
+        nonce_count: 0,
+        hash_count: 0,
+      });
+      expect(captured.response.content_length).toBeNull();
+      expect(captured.response.policies).toMatchObject({
+        corp: null,
+        x_content_type_options: null,
+        referrer_policy: null,
+        permissions_policy_features: ["geolocation"],
+      });
+      expect(captured.response.mime_type).toBe(
+        `${whitespace}text/plain${whitespace}`,
+      );
+      expect(captured.response.content_encoding).toBe(
+        `${whitespace}gzip${whitespace}`,
+      );
+    },
+  );
+
+  it("accepts CSP ASCII whitespace and HTTP SP/HTAB at their owning boundaries", () => {
+    const captured = safeResponseMetadata(
+      "ascii-whitespace",
+      `${origin}/page`,
+      {
+        headers: {
+          "Content-Security-Policy":
+            "\tdefault-src\f'self'\r; script-src\t'data' ",
+          "Content-Length": "\t100 \t",
+          "Cross-Origin-Resource-Policy": "\tsame-origin \t",
+          "Referrer-Policy": "\tno-referrer \t",
+        },
+      },
+      new Set([origin]),
+    );
+    expect(captured.response.csp.directives).toEqual([
+      { name: "default-src", sources: [{ kind: "keyword", value: "'self'" }] },
+      { name: "script-src", sources: [{ kind: "keyword", value: "'data'" }] },
+    ]);
+    expect(captured.response.content_length).toBe(100);
+    expect(captured.response.policies.corp).toBe("same-origin");
+    expect(captured.response.policies.referrer_policy).toBe("no-referrer");
+  });
+});
diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts
index a5b6d1ee18..7001be2d6a 100644
--- a/src/browser/CdpSafeMetadata.ts
+++ b/src/browser/CdpSafeMetadata.ts
@@ -115,7 +115,11 @@ const parseCsp = (
   let hashCount = 0;
   const policies = (lines ?? []).flatMap((line) => line.split(","));
   for (const rawDirective of policies.flatMap((policy) => policy.split(";"))) {
-    const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
+    // CSP3 rejects non-ASCII directives and uses Infra ASCII whitespace.
+    if (/[^\x00-\x7f]/u.test(rawDirective)) continue;
+    const [rawName, ...tokens] = rawDirective
+      .replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/gu, "")
+      .split(/[\t\n\f\r ]+/u);
     const name = (rawName ?? "").toLowerCase();
     if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
     const sources: ResponseMetadata["csp"]["directives"][number]["sources"] =
@@ -219,7 +223,7 @@ const parseLinks = (
   // Quoted delimiters belong to one field line.
   for (const line of lines ?? []) {
     for (const entry of splitLinkHeader(line)) {
-      const match = /^\s*<([^>]*)>(.*)$/u.exec(entry);
+      const match = /^[\t ]*<([^>]*)>(.*)$/u.exec(entry);
       if (match === null) continue;
       const parameters = linkParameters(match[2] ?? "");
       const anchor = firstLinkParameter(parameters, "anchor");
@@ -240,7 +244,11 @@ const parseLinks = (
           destination_scope: destination.scope,
           context_url: context.url,
           context_scope: context.scope,
-          rel: rel.toLowerCase().split(/\s+/u).filter(Boolean),
+          // RFC 8288 Appendix B.2 uses HTTP RWS (SP / HTAB), not HTML whitespace.
+          rel: rel
+            .toLowerCase()
+            .split(/[\t ]+/u)
+            .filter(Boolean),
           as: boundedHeader(lastLinkParameter(parameters, "as")?.value),
           type: mediaTypeDeclaration(
             firstLinkParameter(parameters, "type")?.value,
@@ -287,15 +295,20 @@ const splitLinkHeader = (
   return entries;
 };
 
+// HTTP OWS/BWS never includes Unicode or HTML-only whitespace.
+const trimHttpWhitespace = (value: string): string =>
+  value.replace(/^[\t ]+|[\t ]+$/gu, "");
+
 const linkParameters = (value: string): readonly LinkParameter[] => {
   const parameters: LinkParameter[] = [];
   for (const raw of splitLinkHeader(value, ";").slice(1)) {
     const separator = raw.indexOf("=");
-    const name = (separator < 0 ? raw : raw.slice(0, separator))
-      .trim()
-      .toLowerCase();
+    const name = trimHttpWhitespace(
+      separator < 0 ? raw : raw.slice(0, separator),
+    ).toLowerCase();
     if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
-    const parameter = separator < 0 ? "" : raw.slice(separator + 1).trim();
+    const parameter =
+      separator < 0 ? "" : trimHttpWhitespace(raw.slice(separator + 1));
     parameters.push({ name, value: decodeLinkParameter(parameter) });
   }
   return parameters;
@@ -348,7 +361,9 @@ const permissionFeatures = (value: string | undefined): string[] =>
     ...new Set(
       (value ?? "")
         .split(",")
-        .map((entry) => entry.split("=", 1)[0]?.trim().toLowerCase() ?? "")
+        .map((entry) =>
+          trimHttpWhitespace(entry.split("=", 1)[0] ?? "").toLowerCase(),
+        )
         .filter((feature) => /^[a-z][a-z0-9\x2d]*$/u.test(feature)),
     ),
   ].sort();
@@ -394,34 +409,36 @@ const referrerPolicies = new Set([
 const referrerPolicy = (value: string | undefined): string | null => {
   let policy: string | null = null;
   for (const raw of (value ?? "").split(",")) {
-    const token = raw.trim().toLowerCase();
+    const token = trimHttpWhitespace(raw);
     if (token === "") continue;
-    if (!/^[a-z\x2d]+$/u.test(token)) return null;
+    // Fetch header extraction first validates Referrer-Policy's ABNF;
+    // only syntactically valid extension tokens can be ignored as unknown.
+    if (!/^[A-Za-z\x2d]+$/u.test(token)) return null;
     if (referrerPolicies.has(token)) policy = token;
   }
   return policy;
 };
 
 const policyToken = (value: string | undefined): string | null => {
-  const token = (value ?? "").trim().toLowerCase();
+  const token = trimHttpWhitespace(value ?? "").toLowerCase();
   return /^[a-z][a-z0-9_.\x2d]*$/u.test(token) ? token : null;
 };
 
 // RFC 9110 section 8.6: Content-Length = 1*DIGIT. Other spellings declare no length.
 const contentLength = (value: string | undefined): number | null => {
-  const token = (value ?? "").trim();
+  const token = trimHttpWhitespace(value ?? "");
   if (!/^[0-9]+$/u.test(token)) return null;
   const length = Number(token);
   return Number.isSafeInteger(length) ? length : null;
 };
 
 const boundedHeader = (value: string | undefined): string | null =>
-  value === undefined ? null : value.trim().toLowerCase();
+  value === undefined ? null : trimHttpWhitespace(value).toLowerCase();
 
 // RFC 9110 section 8.3.1: type/subtype are case-insensitive; parameters may not be.
 const mediaTypeDeclaration = (value: string | undefined): string | null => {
   if (value === undefined) return null;
-  const declaration = value.trim();
+  const declaration = trimHttpWhitespace(value);
   const separator = declaration.indexOf(";");
   return separator < 0
     ? declaration.toLowerCase()
diff --git a/src/browser/CdpScriptSourceCapture.ts b/src/browser/CdpScriptSourceCapture.ts
new file mode 100644
index 0000000000..ab4698d08d
--- /dev/null
+++ b/src/browser/CdpScriptSourceCapture.ts
@@ -0,0 +1,63 @@
+import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
+import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
+import type { CdpConnection } from "./CdpConnection.js";
+import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
+import {
+  requiredScriptSource,
+  sourceExcluded,
+  sourceResult,
+} from "./CdpCaptureValues.js";
+import {
+  CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
+  cdpScriptSourceDeadlineOmissionReason,
+  type CdpScriptSourceBudget,
+} from "./CdpScriptSourceBudget.js";
+
+/** One owner for passive page/Electron source admission and artifact creation. */
+export const capturePassiveCdpScriptSource = async (input: {
+  readonly connection: CdpConnection;
+  readonly sessionId: string | undefined;
+  readonly signal: AbortSignal | undefined;
+  readonly operation: BrowserObservationOperation;
+  readonly scriptId: string;
+  readonly sourceBudget: CdpScriptSourceBudget;
+  readonly completeness: CdpCaptureCompleteness;
+}): Promise<
+  ReturnType | ReturnType
+> => {
+  const omitForBudget = () => {
+    input.completeness.exclude("script_sources", "resource_budget_exhausted");
+    return sourceExcluded(
+      input.sourceBudget.timedOut
+        ? cdpScriptSourceDeadlineOmissionReason(
+            input.sourceBudget.commandTimeoutMs,
+          )
+        : CDP_SCRIPT_SOURCE_BYTES_OMISSION_REASON,
+    );
+  };
+  const throwIfCancelled = () => {
+    if (input.signal?.aborted === true)
+      throw new AnalysisCancelledError(input.operation, {
+        cause: input.signal.reason,
+      });
+  };
+  throwIfCancelled();
+  if (input.sourceBudget.timedOut) return omitForBudget();
+  try {
+    const response = await input.connection.send(
+      "Debugger.getScriptSource",
+      { scriptId: input.scriptId },
+      input.sessionId,
+      input.sourceBudget.signal,
+    );
+    throwIfCancelled();
+    const text = requiredScriptSource(response, input.operation);
+    if (input.sourceBudget.timedOut || !input.sourceBudget.admit(text))
+      return omitForBudget();
+    return sourceResult(text);
+  } catch (cause: unknown) {
+    throwIfCancelled();
+    if (input.sourceBudget.timedOut) return omitForBudget();
+    throw cause;
+  }
+};
diff --git a/src/browser/ElectronFileScope.nodes.test.ts b/src/browser/ElectronFileScope.nodes.test.ts
new file mode 100644
index 0000000000..cf4b094b93
--- /dev/null
+++ b/src/browser/ElectronFileScope.nodes.test.ts
@@ -0,0 +1,103 @@
+import { createServer } from "node:http";
+import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+import { expect, it } from "vitest";
+
+import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
+import { CdpElectronProvider } from "./CdpElectronProvider.js";
+
+it("lists only ASAR members with regular-file declarations while preserving unrelated metadata", async () => {
+  const root = await realpath(await mkdtemp(join(tmpdir(), "rea-asar-node-")));
+  const archive = join(root, "app.asar");
+  const files = {
+    "empty.html": { size: 0, offset: "0" },
+    "unpacked.html": { size: 1, unpacked: true },
+    // Authorization does not interpret optional artifact-analysis metadata.
+    "metadata.html": { size: 1, integrity: "uninterpreted", executable: null },
+    "negative.html": { size: -1 },
+    "fractional.html": { size: 0.5 },
+    "unsafe.html": { size: Number.MAX_SAFE_INTEGER + 1 },
+    "missing.html": {},
+    "null.html": { size: null },
+    "directory.html": { size: 0, files: {} },
+    "symlink.html": { size: 0, link: "empty.html" },
+    directory: {
+      size: 0,
+      files: { "nested.html": { size: 0, offset: "0" } },
+    },
+    linked: {
+      link: "elsewhere",
+      files: { "nested.html": { size: 0, offset: "0" } },
+    },
+    malformedLink: {
+      link: null,
+      files: { "nested.html": { size: 0, offset: "0" } },
+    },
+  };
+  const names = [
+    ...Object.keys(files),
+    "directory/nested.html",
+    "linked/nested.html",
+    "malformedLink/nested.html",
+  ];
+  let endpoint = "";
+  const server = createServer((request, response) => {
+    response.setHeader("Content-Type", "application/json");
+    response.end(
+      JSON.stringify(
+        request.url === "/json/version"
+          ? {
+              Browser: "Electron/fixture",
+              "Protocol-Version": "1.3",
+              "WebKit-Version": "revision",
+              "User-Agent": "fixture",
+              "V8-Version": "V8",
+              webSocketDebuggerUrl:
+                endpoint.replace("http:", "ws:") + "/devtools/browser/fixture",
+            }
+          : names.map((name) => ({
+              id: name,
+              type: "page",
+              title: name,
+              url: pathToFileURL(join(archive, name)).href,
+              attached: false,
+            })),
+      ),
+    );
+  });
+  const provider = new CdpElectronProvider();
+  try {
+    await writeFile(archive, asarHeaderBytes(JSON.stringify({ files })));
+    await new Promise((resolve) =>
+      server.listen(0, "127.0.0.1", resolve),
+    );
+    const address = server.address();
+    if (address === null || typeof address === "string")
+      throw new Error("Missing port");
+    endpoint = `http://127.0.0.1:${address.port}`;
+    const result = await provider.listTargets({ cdp_endpoint: endpoint }, {});
+    if (!result.ok) throw result.error;
+    expect(result.value.targets.map(({ target_id }) => target_id)).toEqual([
+      "directory/nested.html",
+      "empty.html",
+      "metadata.html",
+      "unpacked.html",
+    ]);
+    expect(result.value.targets.map(({ file_path }) => file_path)).toEqual([
+      join(archive, "directory", "nested.html"),
+      join(archive, "empty.html"),
+      join(archive, "metadata.html"),
+      join(archive, "unpacked.html"),
+    ]);
+  } finally {
+    await provider.close();
+    await new Promise((resolve, reject) =>
+      server.close((error) =>
+        error === undefined ? resolve() : reject(error),
+      ),
+    );
+    await rm(root, { recursive: true, force: true });
+  }
+});
diff --git a/src/browser/ElectronFileScope.ts b/src/browser/ElectronFileScope.ts
index a955241a6b..facec35c8d 100644
--- a/src/browser/ElectronFileScope.ts
+++ b/src/browser/ElectronFileScope.ts
@@ -14,6 +14,7 @@ import {
   bindArtifactFileCleanup,
   withArtifactFileHandle,
 } from "../artifacts/ArtifactFileOperation.js";
+import { asarNodeSchema } from "../artifacts/AsarNode.js";
 import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
 import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
 import {
@@ -311,21 +312,32 @@ const asarFiles = async (
   return admitted.files;
 };
 
+// File authorization interprets structure and size, not optional integrity or
+// executable declarations that belong to artifact analysis.
+const asarFileScopeNodeSchema = asarNodeSchema
+  .pick({ files: true, link: true, size: true })
+  .strip();
+
 const isAsarRegularFile = (
   files: unknown,
   member: readonly string[],
 ): boolean => {
   let node: unknown = { files };
   for (const part of member) {
-    const children = isRecord(node) ? node.files : undefined;
+    // Electron follows a directory link before looking up its child. Its
+    // co-declared file tree cannot establish the requested member's identity;
+    // link resolution is outside this authorization boundary.
+    if (!isRecord(node) || Object.hasOwn(node, "link")) return false;
+    const children = node.files;
     if (!isRecord(children) || !Object.hasOwn(children, part)) return false;
     node = children[part];
   }
+  const parsed = asarFileScopeNodeSchema.safeParse(node);
   return (
-    isRecord(node) &&
-    typeof node.size === "number" &&
-    !("files" in node) &&
-    !("link" in node)
+    parsed.success &&
+    parsed.data.size !== undefined &&
+    parsed.data.files === undefined &&
+    parsed.data.link === undefined
   );
 };
 
diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts
index fc80920223..8b5b4be99e 100644
--- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts
+++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts
@@ -1,3 +1,6 @@
+import type { BrowserContext, Page } from "playwright-core";
+import { capturePlaywrightStepArtifacts } from "./PlaywrightScenarioArtifacts.js";
+import { resolveScenarioSecrets } from "../../tests/fixtures/browserScenarioSecrets.js";
 import {
   failBrowserScenarioOperation,
   PlaywrightScenarioBrowserCleanupOwner,
@@ -1273,3 +1276,58 @@ it("keeps an earlier physical failure primary when final cleanup also observes c
     await provider.close();
   }
 });
+
+it.each([false, true])(
+  "keeps planned secret screenshots unavailable through success and cleanup failure (failure: %s)",
+  async (cleanupFailure) => {
+    const input = browserScenarioSchema.parse({
+      ...scenario({ captures: ["screenshot"] }),
+      actions: [
+        {
+          step_id: "request",
+          action: "request",
+          method: "POST",
+          path: "/api/items",
+          save: [{ name: "token", json_pointer: "/token", secret: true }],
+        },
+      ],
+    });
+    const secrets = resolveScenarioSecrets(input, {});
+    const screenshot = vi.fn(async () =>
+      Buffer.from("private screenshot pixels"),
+    );
+    const session = new FakeSession(
+      "launch",
+      cleanupFailure ? { close: new Error("cleanup failed") } : {},
+    );
+    session.capture = async (requested) =>
+      capturePlaywrightStepArtifacts({
+        context: {} as BrowserContext,
+        page: { screenshot } as unknown as Page,
+        secrets,
+        requested,
+      });
+    session.onPerform = () =>
+      secrets.save("request.token", "token", "late-pixel-token", true);
+    const provider = new PlaywrightBrowserScenarioProvider(async () => session);
+    const result = await provider.captureScenario(input);
+    const step = {
+      artifacts: { screenshot: { state: "missing" } },
+      completeness: {
+        equality_eligible: false,
+        missing_sections: ["screenshot"],
+      },
+    };
+    if (cleanupFailure)
+      expect(result).toMatchObject({
+        ok: false,
+        error: { partialObservation: { capture: { steps: [step, step] } } },
+      });
+    else {
+      if (!result.ok) throw result.error;
+      expect(result.value.steps).toMatchObject([step, step]);
+      expect(result.value.completeness.equality_eligible).toBe(false);
+    }
+    expect(screenshot).not.toHaveBeenCalled();
+  },
+);
diff --git a/src/browser/PlaywrightScenarioActions.ts b/src/browser/PlaywrightScenarioActions.ts
index ad6f3f2532..792c6bb830 100644
--- a/src/browser/PlaywrightScenarioActions.ts
+++ b/src/browser/PlaywrightScenarioActions.ts
@@ -1,11 +1,17 @@
 import type { Locator, Page } from "playwright-core";
 
-import type {
-  BrowserScenarioAction,
-  BrowserScenarioValue,
+import {
+  SAVED_URL_PLACEHOLDER,
+  type BrowserScenarioAction,
+  type BrowserScenarioValue,
 } from "../domain/browserScenarioValues.js";
 import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
-import { performScenarioRequest } from "./PlaywrightScenarioRequest.js";
+import {
+  awaitSavedResponse,
+  performScenarioRequest,
+  withSavedPlaceholders,
+  type SavedResponseWait,
+} from "./PlaywrightScenarioRequest.js";
 
 type ScenarioLocator = Extract<
   BrowserScenarioAction,
@@ -41,6 +47,23 @@ interface PerformActionInput {
   readonly signal?: AbortSignal | undefined;
 }
 
+/** Watch the response selector before the UI action; matching does not prove action causality. */
+const savingResponse = (
+  input: PerformActionInput,
+  action: Extract,
+  timeout: number | undefined,
+): SavedResponseWait | undefined =>
+  action.save_response === undefined
+    ? undefined
+    : awaitSavedResponse(input.page, {
+        stepId: action.step_id,
+        spec: action.save_response,
+        startOrigin: input.startOrigin,
+        timeout,
+        secrets: input.secrets,
+        signal: input.signal,
+      });
+
 /** Execute one admitted declarative action; a request step sends one declared request through the page's own fetch, and no action runs caller-supplied code. */
 export const performPlaywrightScenarioAction = async (
   input: PerformActionInput,
@@ -50,27 +73,49 @@ export const performPlaywrightScenarioAction = async (
   const options = timeout === undefined ? {} : { timeout };
   switch (action.action) {
     case "goto":
-      await page.goto(secrets.url(action.destination), {
-        ...options,
-        waitUntil: action.wait_until,
-      });
+      // Placeholders fill the URL as written, before structured query values join it.
+      await page.goto(
+        secrets.url({
+          ...action.destination,
+          url: withSavedPlaceholders(
+            action.destination.url,
+            secrets,
+            SAVED_URL_PLACEHOLDER,
+          ),
+        }),
+        { ...options, waitUntil: action.wait_until },
+      );
       return;
-    case "click":
-      await resolveLocator(page, action.locator).click({
-        ...options,
-        button: action.button,
-        clickCount: action.click_count,
-      });
+    case "click": {
+      const saving = savingResponse(input, action, timeout);
+      try {
+        await resolveLocator(page, action.locator).click({
+          ...options,
+          button: action.button,
+          clickCount: action.click_count,
+        });
+        await saving?.saved();
+      } finally {
+        saving?.stop();
+      }
       return;
+    }
     case "fill":
       await resolveLocator(page, action.locator).fill(
         resolveValue(action.value, secrets),
         options,
       );
       return;
-    case "press":
-      await resolveLocator(page, action.locator).press(action.key, options);
+    case "press": {
+      const saving = savingResponse(input, action, timeout);
+      try {
+        await resolveLocator(page, action.locator).press(action.key, options);
+        await saving?.saved();
+      } finally {
+        saving?.stop();
+      }
       return;
+    }
     case "select_option":
       await resolveLocator(page, action.locator).selectOption(
         resolveValue(action.value, secrets),
diff --git a/src/browser/PlaywrightScenarioArtifacts.test.ts b/src/browser/PlaywrightScenarioArtifacts.test.ts
index c4c2c9d382..be2f9964c8 100644
--- a/src/browser/PlaywrightScenarioArtifacts.test.ts
+++ b/src/browser/PlaywrightScenarioArtifacts.test.ts
@@ -1,4 +1,4 @@
-import { expect, it } from "vitest";
+import { expect, it, vi } from "vitest";
 import type { BrowserContext, Page } from "playwright-core";
 
 import { browserScenarioSchema } from "../domain/browserScenario.js";
@@ -159,3 +159,145 @@ it("redacts a declared secret used as a cookie or storage name", async () => {
     },
   });
 });
+
+it.each(["declared", "saved"])(
+  "withholds selected screenshots with %s sensitive values before requesting pixels",
+  async (kind) => {
+    const selected = browserScenarioSchema.parse({
+      ...scenario,
+      secrets:
+        kind === "declared"
+          ? [{ secret_id: "token", environment_variable: "TOKEN" }]
+          : [],
+    });
+    const protectedSecrets = resolveScenarioSecrets(
+      selected,
+      kind === "declared" ? { TOKEN: "pixel-token-729a" } : {},
+    );
+    if (kind === "saved")
+      protectedSecrets.save(
+        "response.token",
+        "token",
+        "pixel-token-729a",
+        true,
+      );
+    const screenshot = vi.fn(async () => Buffer.from("private pixels"));
+    const result = await capturePlaywrightStepArtifacts({
+      page: { screenshot } as unknown as Page,
+      context: {} as BrowserContext,
+      secrets: protectedSecrets,
+      requested: new Set(["screenshot"]),
+    });
+    expect(result.screenshot).toMatchObject({
+      state: "missing",
+      reason: expect.stringContaining("sensitive"),
+    });
+    expect(screenshot).not.toHaveBeenCalled();
+  },
+);
+
+it.each(["request", "click", "press"])(
+  "withholds pixels from the first capture when a future %s response is marked sensitive",
+  async (action) => {
+    const save = [{ name: "token", json_pointer: "/token", secret: true }];
+    const selected = browserScenarioSchema.parse({
+      ...scenario,
+      actions: [
+        action === "request"
+          ? {
+              step_id: "request",
+              action,
+              method: "POST",
+              path: "/api/items",
+              save,
+            }
+          : {
+              step_id: "ui",
+              action,
+              locator: { kind: "css", selector: "#save" },
+              ...(action === "press" ? { key: "Enter" } : {}),
+              save_response: { method: "POST", path: "/api/items", save },
+            },
+      ],
+    });
+    const screenshot = vi.fn(async () => Buffer.from("private pixels"));
+    const result = await capturePlaywrightStepArtifacts({
+      page: { screenshot } as unknown as Page,
+      context: {} as BrowserContext,
+      secrets: resolveScenarioSecrets(selected, {}),
+      requested: new Set(["screenshot"]),
+    });
+    expect(result.screenshot).toMatchObject({
+      state: "missing",
+      reason: expect.stringContaining("sensitive"),
+    });
+    expect(screenshot).not.toHaveBeenCalled();
+  },
+);
+
+it.each(["cookie", "local_storage", "basic_credentials"])(
+  "withholds pixels before planned %s authentication values are read",
+  async (source) => {
+    const value =
+      source === "basic_credentials"
+        ? {
+            source,
+            parts: [
+              { source: "literal", value: "user" },
+              { source: "cookie", name: "password" },
+            ],
+          }
+        : source === "local_storage"
+          ? { source, key: "session" }
+          : { source, name: "session" };
+    const selected = browserScenarioSchema.parse({
+      ...scenario,
+      actions: [
+        {
+          step_id: "request",
+          action: "request",
+          method: "POST",
+          path: "/api/items",
+          headers: [{ name: "Authorization", value }],
+        },
+      ],
+    });
+    const screenshot = vi.fn(async () => Buffer.from("private pixels"));
+    const result = await capturePlaywrightStepArtifacts({
+      page: { screenshot } as unknown as Page,
+      context: {} as BrowserContext,
+      secrets: resolveScenarioSecrets(selected, {}),
+      requested: new Set(["screenshot"]),
+    });
+    expect(result.screenshot).toMatchObject({ state: "missing" });
+    expect(screenshot).not.toHaveBeenCalled();
+  },
+);
+
+it("preserves requested pixels when saved response values are not marked sensitive", async () => {
+  const selected = browserScenarioSchema.parse({
+    ...scenario,
+    actions: [
+      {
+        step_id: "request",
+        action: "request",
+        method: "POST",
+        path: "/api/items",
+        save: [{ name: "id", json_pointer: "/id", secret: false }],
+      },
+    ],
+  });
+  const pixels = Buffer.from("public pixels");
+  const screenshot = vi.fn(async () => pixels);
+  const result = await capturePlaywrightStepArtifacts({
+    page: { screenshot } as unknown as Page,
+    context: {} as BrowserContext,
+    secrets: resolveScenarioSecrets(selected, {}),
+    requested: new Set(["screenshot"]),
+  });
+  expect(result.screenshot).toMatchObject({
+    state: "captured",
+    value: { data_base64: pixels.toString("base64") },
+  });
+  expect(screenshot).toHaveBeenCalledTimes(1);
+});
diff --git a/src/browser/PlaywrightScenarioArtifacts.ts b/src/browser/PlaywrightScenarioArtifacts.ts
index 036c863136..3f9eebff8b 100644
--- a/src/browser/PlaywrightScenarioArtifacts.ts
+++ b/src/browser/PlaywrightScenarioArtifacts.ts
@@ -43,7 +43,14 @@ const textArtifact = (text: string) => {
   };
 };
 
-const captureScreenshot = async (page: Page) => {
+const SCREENSHOT_PRIVACY_REASON =
+  "Screenshot withheld because this scenario may display explicitly sensitive values; safe pixel redaction is unavailable.";
+
+const captureScreenshot = async (
+  page: Page,
+  secrets: BrowserScenarioSecrets,
+) => {
+  if (secrets.withholdsScreenshots()) return missing(SCREENSHOT_PRIVACY_REASON);
   try {
     const bytes = await page.screenshot({
       type: "png",
@@ -196,7 +203,9 @@ export const capturePlaywrightStepArtifacts = async (input: {
     Value | ReturnType | ReturnType
   > => (!requested.has(kind) ? Promise.resolve(notRequested()) : capture());
   return browserStepArtifactsSchema.parse({
-    screenshot: await state("screenshot", () => captureScreenshot(page)),
+    screenshot: await state("screenshot", () =>
+      captureScreenshot(page, secrets),
+    ),
     dom: await state("dom", () => captureDom({ page, secrets })),
     accessibility: await state("accessibility", () =>
       captureAccessibility({ page, secrets }),
diff --git a/src/browser/PlaywrightScenarioEvents.test.ts b/src/browser/PlaywrightScenarioEvents.test.ts
index d8da9bcc05..a139c96f39 100644
--- a/src/browser/PlaywrightScenarioEvents.test.ts
+++ b/src/browser/PlaywrightScenarioEvents.test.ts
@@ -110,6 +110,7 @@ const captureNetworkBudgetFixture = async (
   });
   const secrets = resolveScenarioSecrets(scenario, {});
   const redactBytes = vi.spyOn(secrets, "redactBytes");
+  const redactJson = vi.spyOn(secrets, "redactJsonBody");
   const capture = new PlaywrightScenarioEvents({
     page,
     context,
@@ -155,6 +156,7 @@ const captureNetworkBudgetFixture = async (
   return {
     result: capture.result(),
     redactedByteValues: redactBytes.mock.calls.length,
+    processedJsonBodies: redactJson.mock.calls.length,
   };
 };
 
@@ -174,12 +176,12 @@ describe("PlaywrightScenarioEvents", () => {
     expect(
       baseline.result.omissions.network_transactions.length,
     ).toBeGreaterThan(0);
-    expect(baseline.redactedByteValues).toBe(
-      2 *
-        baseline.result.items.filter(
-          (event) => event.kind === "network-content",
-        ).length,
-    );
+    const retainedBodies = baseline.result.items.filter(
+      (event) =>
+        event.kind === "network-content" && event.body.state === "captured",
+    ).length;
+    expect(baseline.processedJsonBodies).toBe(retainedBodies);
+    expect(baseline.redactedByteValues).toBe(2 * retainedBodies);
     expect(raised.result).toMatchObject({
       dropped: 0,
       omissions: { network_transactions: [] },
@@ -401,3 +403,504 @@ it("caps live descendant listeners and reuses capacity after closure", () => {
   listeners.get("websocket")?.(sockets[2048]);
   expect(sockets[2048]?.on).toHaveBeenCalledTimes(3);
 });
+
+/** Emit the request before attaching the source policy, matching Playwright listener order. */
+const sourceTransactionCapture = async (
+  options: {
+    fail?: boolean;
+    followup?: boolean;
+    metadataOnly?: boolean;
+    learnDirect?: boolean;
+    latePageValue?: string;
+    value?: string;
+    padding?: number;
+    metadataLimit?: number;
+  } = {},
+) => {
+  const listeners = new Map void>();
+  const context = {
+    on: (name: string, listener: (value: unknown) => void) =>
+      listeners.set(name, listener),
+    off: vi.fn(),
+  } as unknown as BrowserContext;
+  const page = {
+    context: () => context,
+    on: vi.fn(),
+    off: vi.fn(),
+  } as unknown as Page;
+  const value = options.value ?? "credential-82ea";
+  const url = `https://example.test/exchange?token=${value.repeat(options.padding ?? 1)}&safe=visible`;
+  const body = JSON.stringify({
+    token: value,
+    ...(options.latePageValue === undefined
+      ? {}
+      : { page: options.latePageValue }),
+    public: "kept",
+  });
+  const parsed = browserScenarioSchema.parse({
+    browser: { mode: "launch", executable_path: "/fixture/browser" },
+    start_url: { url: "https://example.test/" },
+    actions: [{ step_id: "click", action: "wait_for_timeout", duration_ms: 1 }],
+    capture: {
+      events: ["network"],
+      network: {
+        request_body: !options.metadataOnly,
+        response_body: !options.metadataOnly,
+        header_values: !options.metadataOnly,
+      },
+      ...(options.metadataLimit === undefined
+        ? {}
+        : {
+            limits: {
+              max_network_metadata_allocation_bytes: options.metadataLimit,
+            },
+          }),
+    },
+  });
+  const secrets = resolveScenarioSecrets(parsed, {});
+  const capture = new PlaywrightScenarioEvents({
+    page,
+    context,
+    ownsContext: true,
+    enabled: new Set(["network"]),
+    network: parsed.capture.network,
+    limits: parsed.capture.limits,
+    secrets,
+  });
+  const request = {
+    method: () => "POST",
+    url: () => url,
+    resourceType: () => "fetch",
+    redirectedFrom: () => null,
+    postDataBuffer: () => Buffer.from(body),
+    failure: () => null,
+    sizes: async () => ({ responseBodySize: Buffer.byteLength(body) }),
+    headers: () => ({
+      "content-type": "application/json",
+      "x-source-token": value,
+    }),
+    headersArray: async () => [
+      { name: "Content-Type", value: "application/json" },
+      { name: "X-Source-Token", value },
+    ],
+  };
+  listeners.get("request")!(request);
+  if (!options.metadataOnly)
+    await vi.waitFor(() =>
+      expect(capture.result().items).toContainEqual(
+        expect.objectContaining({ kind: "network-content", phase: "request" }),
+      ),
+    );
+  if (options.latePageValue !== undefined)
+    secrets.admitPageValue("later.cookie", options.latePageValue);
+  const sourceId = capture
+    .result()
+    .items.find((event) => event.kind === "request")!.transaction_id;
+  // This same-URL transaction has no source policy; unknown source values must not withhold it.
+  const unrelated = { ...request };
+  listeners.get("request")!(unrelated);
+  listeners.get("requestfinished")!(unrelated);
+  secrets.responseProtector("click", [
+    { name: "token", json_pointer: "/token", secret: true },
+  ])?.(request);
+  if (options.learnDirect)
+    secrets.responseProtector("click", [
+      { name: "token", json_pointer: "/token", secret: true },
+    ])?.(unrelated);
+  listeners.get("response")!({
+    request: () => request,
+    url: () => url,
+    status: () => 200,
+    headers: () => ({ "content-type": "application/json" }),
+    headersArray: async () => [],
+    body: async () => {
+      if (options.fail) throw new Error(`Read failed for ${url}`);
+      return Buffer.from(body);
+    },
+  });
+  if (options.followup) {
+    const followup = {
+      ...request,
+      url: () => `https://example.test/followup?token=${value}`,
+    };
+    listeners.get("request")!(followup);
+    listeners.get("response")!({
+      request: () => followup,
+      url: () => followup.url(),
+      status: () => 200,
+      headers: () => ({ "content-type": "application/json" }),
+      headersArray: async () => [{ name: "X-Followup", value }],
+      body: async () => Buffer.from(body),
+    });
+    listeners.get("requestfinished")!(followup);
+    if (!options.metadataOnly)
+      await vi.waitFor(() =>
+        expect(
+          capture
+            .result()
+            .items.filter(
+              (event) =>
+                event.kind === "network-content" && event.phase === "response",
+            ),
+        ).toHaveLength(1),
+      );
+  }
+  if (options.learnDirect) secrets.save("click.token", "token", value, true);
+  listeners.get("requestfinished")!(request);
+  await capture.finish();
+  return { capture, sourceId, value, url };
+};
+
+it("redacts earlier source transaction metadata and request content before publishing", async () => {
+  const { capture, sourceId, value } = await sourceTransactionCapture();
+  const source = capture
+    .result()
+    .items.filter(
+      (event) => "transaction_id" in event && event.transaction_id === sourceId,
+    );
+  expect(source.filter((event) => event.kind === "request")).toHaveLength(1);
+  expect(source).toContainEqual(
+    expect.objectContaining({
+      kind: "request",
+      url: expect.objectContaining({
+        origin: "https://example.test",
+        query_parameter_names: ["token", "safe"],
+        redacted: true,
+        url: expect.stringContaining("safe=visible"),
+      }),
+    }),
+  );
+  expect(JSON.stringify(source)).not.toContain(value);
+  for (const event of source)
+    if (event.kind === "network-content" && event.body.state === "captured") {
+      const text = Buffer.from(event.body.content, "base64").toString();
+      expect(text).not.toContain(value);
+      expect(JSON.parse(text).token).toBe("[REDACTED:page:click.token]");
+    }
+  expect(capture.result().items).toContainEqual(
+    expect.objectContaining({
+      kind: "request",
+      transaction_id: expect.not.stringMatching(new RegExp(`^${sourceId}$`)),
+      url: expect.objectContaining({
+        origin: "https://example.test",
+        redacted: true,
+        url: expect.stringContaining("safe=visible"),
+      }),
+    }),
+  );
+  const finished = JSON.stringify(capture.result());
+  await capture.finish();
+  expect(JSON.stringify(capture.result())).toBe(finished);
+});
+
+it("withholds source URLs and earlier content if source values cannot be read", async () => {
+  const { capture, sourceId, value } = await sourceTransactionCapture({
+    fail: true,
+  });
+  const source = capture
+    .result()
+    .items.filter(
+      (event) => "transaction_id" in event && event.transaction_id === sourceId,
+    );
+  expect(JSON.stringify(source)).not.toContain(value);
+  expect(source).toContainEqual(
+    expect.objectContaining({
+      kind: "request",
+      url: { url: "", origin: null, query_parameter_names: [], redacted: true },
+    }),
+  );
+  expect(source).toContainEqual(
+    expect.objectContaining({
+      kind: "network-content",
+      phase: "request",
+      body: expect.objectContaining({ state: "unavailable" }),
+    }),
+  );
+  expect(capture.limitations().join(" ")).toContain(
+    "marked response values were unavailable",
+  );
+});
+
+it("accounts for source metadata redaction expansion before publishing replacements", async () => {
+  const { capture, sourceId } = await sourceTransactionCapture({
+    value: "a",
+    padding: 1000,
+    metadataLimit: 50_000,
+  });
+  expect(capture.result().omissions.network_transactions).toContainEqual(
+    expect.objectContaining({
+      transaction_id: sourceId,
+      reasons: expect.arrayContaining([
+        "max-network-metadata-allocation-bytes",
+      ]),
+    }),
+  );
+});
+
+it("re-protects follow-up URLs, headers and both body phases recorded before source secrets were learned", async () => {
+  const { capture, value } = await sourceTransactionCapture({ followup: true });
+  const request = capture
+    .result()
+    .items.find(
+      (event) =>
+        event.kind === "request" && event.url.url.includes("/followup?"),
+    );
+  expect(request).toBeDefined();
+  if (request?.kind !== "request")
+    throw new Error("Expected actual follow-up request");
+  const events = capture
+    .result()
+    .items.filter(
+      (event) =>
+        "transaction_id" in event &&
+        event.transaction_id === request.transaction_id,
+    );
+  expect(JSON.stringify(events)).not.toContain(value);
+  const bodies = events.flatMap((event) =>
+    event.kind === "network-content" && event.body.state === "captured"
+      ? [Buffer.from(event.body.content, "base64").toString()]
+      : [],
+  );
+  expect(bodies).toHaveLength(2);
+  for (const body of bodies) {
+    expect(body).not.toContain(value);
+    expect(JSON.parse(body)).toEqual({
+      token: "[REDACTED:page:click.token]",
+      public: "kept",
+    });
+  }
+});
+
+it("protects metadata-only follow-ups when direct saves learn a secret without unambiguous source identity", async () => {
+  const { capture, value } = await sourceTransactionCapture({
+    followup: true,
+    metadataOnly: true,
+    learnDirect: true,
+  });
+  const request = capture
+    .result()
+    .items.find(
+      (event) =>
+        event.kind === "request" && event.url.url.includes("/followup?"),
+    );
+  expect(request).toMatchObject({
+    url: { redacted: true, origin: "https://example.test" },
+  });
+  expect(JSON.stringify(capture.result().items)).not.toContain(value);
+  expect(
+    capture.result().items.some((event) => event.kind === "network-content"),
+  ).toBe(false);
+});
+
+it("protects earlier non-network event text after a response secret is learned, without network selection", async () => {
+  const listeners = new Map void>();
+  const socketListeners = new Map void>();
+  const page = {
+    on: (name: string, listener: (value: unknown) => void) =>
+      listeners.set(name, listener),
+    off: vi.fn(),
+  } as unknown as Page;
+  const parsed = browserScenarioSchema.parse({
+    browser: { mode: "launch", executable_path: "/fixture/browser" },
+    start_url: { url: "https://example.test/" },
+    actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+  });
+  const secrets = resolveScenarioSecrets(parsed, {});
+  const capture = new PlaywrightScenarioEvents({
+    page,
+    enabled: new Set([
+      "console",
+      "page-errors",
+      "websockets",
+      "frames",
+      "workers",
+      "popups",
+      "downloads",
+    ]),
+    secrets,
+  });
+  const value = "late-secret-829a";
+  const url = `https://example.test/?token=${value}`;
+  listeners.get("console")!({
+    type: () => "log",
+    text: () => value,
+    location: () => ({ url }),
+  });
+  const error = new Error(value);
+  error.stack = value;
+  listeners.get("pageerror")!(error);
+  listeners.get("websocket")!({
+    url: () => url,
+    on: (name: string, listener: (value: unknown) => void) =>
+      socketListeners.set(name, listener),
+    off: vi.fn(),
+  });
+  socketListeners.get("framesent")!({ payload: value });
+  listeners.get("framenavigated")!({ url: () => url, name: () => value });
+  listeners.get("worker")!({ url: () => url, on: vi.fn(), off: vi.fn() });
+  listeners.get("popup")!({ url: () => url, on: vi.fn(), off: vi.fn() });
+  listeners.get("download")!({
+    url: () => url,
+    suggestedFilename: () => value,
+    cancel: async () => undefined,
+  });
+  expect(capture.result().items.length).toBeGreaterThanOrEqual(8);
+  expect(JSON.stringify(capture.result().items)).toContain(value);
+  secrets.save("late.token", "token", value, true);
+  await capture.finish();
+  expect(JSON.stringify(capture.result().items)).not.toContain(value);
+  expect(JSON.stringify(capture.result().items)).toContain("REDACTED");
+  const finished = JSON.stringify(capture.result());
+  await capture.finish();
+  expect(JSON.stringify(capture.result())).toBe(finished);
+});
+
+it("accounts for late non-network redaction expansion and reports omissions once", async () => {
+  const listeners = new Map void>();
+  const page = {
+    on: (name: string, listener: (value: unknown) => void) =>
+      listeners.set(name, listener),
+    off: vi.fn(),
+  } as unknown as Page;
+  const parsed = browserScenarioSchema.parse({
+    browser: { mode: "launch", executable_path: "/fixture/browser" },
+    start_url: { url: "https://example.test/" },
+    actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+    capture: { limits: { max_event_allocation_bytes: 20_000 } },
+  });
+  const secrets = resolveScenarioSecrets(parsed, {});
+  const capture = new PlaywrightScenarioEvents({
+    page,
+    enabled: new Set(["console"]),
+    limits: parsed.capture.limits,
+    secrets,
+  });
+  listeners.get("console")!({
+    type: () => "log",
+    text: () => "x".repeat(1000),
+    location: () => ({ url: "" }),
+  });
+  expect(capture.result().retained).toBe(1);
+  secrets.save("late.token", "token", "x", true);
+  await capture.finish();
+  expect(capture.result()).toMatchObject({
+    retained: 0,
+    dropped: 1,
+    omissions: {
+      by_kind: [
+        { kind: "console", count: 1, reasons: ["max-event-allocation-bytes"] },
+      ],
+      network_transactions: [],
+    },
+  });
+  await capture.finish();
+  expect(capture.result().dropped).toBe(1);
+});
+
+it.each(["page", "basic"])(
+  "preserves pre-read %s facts while backfilling only response-marked values",
+  async (kind) => {
+    for (const responseSave of [false, true]) {
+      const listeners = new Map void>();
+      const page = {
+        on: (name: string, listener: (value: unknown) => void) =>
+          listeners.set(name, listener),
+        off: vi.fn(),
+      } as unknown as Page;
+      const parsed = browserScenarioSchema.parse({
+        browser: { mode: "launch", executable_path: "/fixture/browser" },
+        start_url: { url: "https://example.test/" },
+        actions: [
+          { step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
+        ],
+      });
+      const secrets = resolveScenarioSecrets(parsed, {});
+      const capture = new PlaywrightScenarioEvents({
+        page,
+        enabled: new Set(["console"]),
+        secrets,
+      });
+      const value =
+        kind === "page"
+          ? "csrf-page-value-729d"
+          : Buffer.from("user:password").toString("base64");
+      const message = `${value} response-value-82ea`;
+      const emit = () =>
+        listeners.get("console")!({
+          type: () => "log",
+          text: () => message,
+          location: () => ({ url: "" }),
+        });
+      emit();
+      if (kind === "page") secrets.admitPageValue("request.cookie", value);
+      else secrets.basicCredential("request.auth", "user", "password");
+      emit();
+      if (responseSave)
+        secrets.save("response.token", "token", "response-value-82ea", true);
+      await capture.finish();
+      const events = capture.result().items;
+      expect(events).toHaveLength(2);
+      expect(events[0]).toMatchObject({
+        kind: "console",
+        text: expect.stringContaining(value),
+      });
+      expect(events[1]).toMatchObject({
+        kind: "console",
+        text: expect.not.stringContaining(value),
+      });
+      expect(JSON.stringify(events).includes("response-value-82ea")).toBe(
+        !responseSave,
+      );
+    }
+  },
+);
+
+it("preserves a response field recorded before a later page-value read while backfilling response secrets", async () => {
+  const pageValue = "csrf-page-value-729d";
+  const { capture, sourceId, value } = await sourceTransactionCapture({
+    latePageValue: pageValue,
+  });
+  const first = capture
+    .result()
+    .items.find(
+      (event) =>
+        event.kind === "network-content" &&
+        event.transaction_id === sourceId &&
+        event.phase === "request",
+    );
+  expect(first).toBeDefined();
+  if (first?.kind !== "network-content" || first.body.state !== "captured")
+    throw new Error("Expected retained earlier request bytes");
+  const body = JSON.parse(Buffer.from(first.body.content, "base64").toString());
+  expect(body.page).toBe(pageValue);
+  expect(body.token).toBe("[REDACTED:page:click.token]");
+  expect(body.token).not.toContain(value);
+});
+
+it("promotes an already held page value to response scope without changing its first marker", () => {
+  const parsed = browserScenarioSchema.parse({
+    browser: { mode: "launch", executable_path: "/fixture/browser" },
+    start_url: { url: "https://example.test/" },
+    actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+  });
+  const secrets = resolveScenarioSecrets(parsed, {});
+  const value = "existing-page-value-82ea";
+  secrets.admitPageValue("cookie.token", value);
+  expect(secrets.redact(value, "response")).toBe(value);
+  expect(secrets.hasLearnedResponseSecrets()).toBe(false);
+  secrets.save("response.token", "token", value, true);
+  expect(secrets.hasLearnedResponseSecrets()).toBe(true);
+  expect(secrets.redact(value, "response")).toBe(
+    "[REDACTED:page:cookie.token]",
+  );
+  expect(secrets.redactBytes(Buffer.from(value), "response").toString()).toBe(
+    "[REDACTED:page:cookie.token]",
+  );
+  secrets.admitPageValue("later.unrelated", "unrelated-page-value-729d");
+  expect(secrets.redact("unrelated-page-value-729d", "response")).toBe(
+    "unrelated-page-value-729d",
+  );
+  expect(secrets.redact(value, "response")).toBe(
+    "[REDACTED:page:cookie.token]",
+  );
+});
diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts
index ef1cb14d16..99a07700b8 100644
--- a/src/browser/PlaywrightScenarioEvents.ts
+++ b/src/browser/PlaywrightScenarioEvents.ts
@@ -25,6 +25,7 @@ import type {
   BrowserScenarioEventOmissions,
 } from "../domain/browserScenarioCaptureValues.js";
 import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
+import { captureNetworkBody } from "./network/PlaywrightNetworkContent.js";
 import { PlaywrightScenarioNetwork } from "./network/PlaywrightScenarioNetwork.js";
 
 type EventName =
@@ -71,6 +72,30 @@ const allocationCost = (value: unknown, remaining: number): number => {
   return cost;
 };
 
+const retainedEventCost = (
+  event: BrowserScenarioEvent,
+  limit: number,
+): number => {
+  const { sequence: _sequence, step_index: _stepIndex, ...unindexed } = event;
+  return allocationCost(unindexed, limit) + 128;
+};
+
+type TextEvent = Exclude<
+  BrowserScenarioEvent,
+  { readonly transaction_id: string }
+>;
+
+/** Count parsed text without allocating another serialization or copying bodies. */
+const eventTextUnits = (value: unknown): number => {
+  if (typeof value === "string") return value.length;
+  if (value === null || typeof value !== "object") return 0;
+  let units = 0;
+  for (const key in value)
+    if (Object.hasOwn(value, key))
+      units += eventTextUnits((value as Record)[key]);
+  return units;
+};
+
 type EventKind = BrowserScenarioEvent["kind"];
 /** Arrival-ordered Playwright event capture with current-step attribution. */
 export class PlaywrightScenarioEvents {
@@ -100,6 +125,8 @@ export class PlaywrightScenarioEvents {
   private stopNetwork: (() => void) | undefined;
   private readonly stopListeners = new Map void>>();
   private sealed = false;
+  private sourcePrivacyIncomplete = false;
+  private eventPrivacyFinalized = false;
 
   constructor(options: EventCaptureOptions) {
     const network = options.network ?? {
@@ -192,7 +219,11 @@ export class PlaywrightScenarioEvents {
     try {
       await this.network?.finish();
     } finally {
-      this.sealed = true;
+      try {
+        await this.finalizeEventPrivacy();
+      } finally {
+        this.sealed = true;
+      }
     }
   }
 
@@ -203,6 +234,10 @@ export class PlaywrightScenarioEvents {
         `Popup ${family} events before Page discovery are unavailable; later events are retained.`,
     );
     const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])];
+    if (this.sourcePrivacyIncomplete)
+      gaps.push(
+        "Source transaction URLs or content were withheld because marked response values were unavailable.",
+      );
     if (this.dropped > 0)
       gaps.push(
         "Browser event collection reached a selected resource budget; per-kind omissions and affected network transaction IDs are listed in events.omissions, and event coverage is incomplete.",
@@ -255,6 +290,279 @@ export class PlaywrightScenarioEvents {
     };
   }
 
+  /** Apply final known secrets to retained event text independently of source readiness. */
+  private async finalizeEventPrivacy(): Promise {
+    if (this.eventPrivacyFinalized) return;
+    const sources = this.network?.sourcePrivacy() ?? new Map();
+    const learnedSecrets = this.secrets.hasLearnedResponseSecrets();
+    if (sources.size === 0 && !learnedSecrets) return;
+    let retained = 0;
+    for (const original of this.items) {
+      const sourceReady =
+        "transaction_id" in original
+          ? sources.get(original.transaction_id)
+          : undefined;
+      if (sourceReady === undefined && !learnedSecrets) {
+        this.items[retained++] = original;
+        continue;
+      }
+      const ready = sourceReady ?? true;
+      if (!ready) this.sourcePrivacyIncomplete = true;
+      const oldCost = retainedEventCost(
+        original,
+        this.limits.max_event_allocation_bytes,
+      );
+      this.retainedCost -= oldCost;
+      const available = Math.min(
+        this.limits.max_event_allocation_bytes,
+        this.limits.max_total_event_allocation_bytes - this.retainedCost,
+      );
+      const extra = eventTextUnits(
+        original.kind === "network-content"
+          ? {
+              headers: original.headers,
+              message:
+                original.body.state === "unavailable"
+                  ? original.body.message
+                  : "",
+            }
+          : original,
+      );
+      let reason: BrowserScenarioEventBudgetReason | undefined =
+        "transaction_id" in original &&
+        this.secrets.redactionAllocationCost(extra, "response") + 4096 >
+          this.limits.max_network_metadata_allocation_bytes
+          ? "max-network-metadata-allocation-bytes"
+          : undefined;
+      const bodyCopy =
+        original.kind === "network-content" &&
+        original.body.state === "captured"
+          ? Math.max(
+              this.secrets.redactionAllocationCost(
+                original.body.bytes,
+                "response",
+              ),
+              Math.ceil(original.body.bytes / 3) * 4 * 8 + 2,
+            )
+          : 0;
+      const workingCost =
+        oldCost +
+        this.secrets.redactionAllocationCost(extra, "response") +
+        bodyCopy +
+        4096;
+      reason ??=
+        workingCost > this.limits.max_event_allocation_bytes
+          ? "max-event-allocation-bytes"
+          : workingCost > available
+            ? "max-total-event-allocation-bytes"
+            : undefined;
+      let event: BrowserScenarioEvent = original;
+      if (reason === undefined) {
+        event =
+          "transaction_id" in original
+            ? await this.protectNetworkEvent(
+                original,
+                ready,
+                Math.max(1, available),
+              )
+            : this.protectTextEvent(original);
+        const cost = retainedEventCost(
+          event,
+          this.limits.max_event_allocation_bytes,
+        );
+        reason =
+          cost > this.limits.max_event_allocation_bytes
+            ? "max-event-allocation-bytes"
+            : cost > available
+              ? "max-total-event-allocation-bytes"
+              : undefined;
+        if (reason === undefined) {
+          this.retainedCost += cost;
+          this.items[retained++] = event;
+          continue;
+        }
+      }
+      this.omitEvent(original.kind, reason);
+      if ("transaction_id" in original)
+        this.omitNetwork(original.transaction_id, original.kind, reason);
+    }
+    this.items.length = retained;
+    this.eventPrivacyFinalized = true;
+  }
+
+  private protectUrl(value: SanitizedBrowserUrl): SanitizedBrowserUrl {
+    const url = this.secrets.sanitizeUrl(value.url, "response");
+    return { ...url, redacted: url.redacted || value.redacted };
+  }
+
+  private protectTextEvent(event: TextEvent): TextEvent {
+    switch (event.kind) {
+      case "console":
+        return {
+          ...event,
+          url: event.url === null ? null : this.protectUrl(event.url),
+          level: this.secrets.redact(event.level, "response"),
+          text: this.secrets.redact(event.text, "response"),
+        };
+      case "page-error":
+        return {
+          ...event,
+          message: this.secrets.redact(event.message, "response"),
+          stack:
+            event.stack === null
+              ? null
+              : this.secrets.redact(event.stack, "response"),
+        };
+      case "websocket-frame-sent":
+      case "websocket-frame-received":
+        return event.payload_type === "text"
+          ? {
+              ...event,
+              url: this.protectUrl(event.url),
+              payload_text: this.secrets.redact(event.payload_text, "response"),
+            }
+          : { ...event, url: this.protectUrl(event.url) };
+      case "websocket-opened":
+      case "websocket-closed":
+        return { ...event, url: this.protectUrl(event.url) };
+      case "download-cancelled":
+        return {
+          ...event,
+          url: this.protectUrl(event.url),
+          suggested_filename: this.secrets.redact(
+            event.suggested_filename,
+            "response",
+          ),
+        };
+      default:
+        return {
+          ...event,
+          url: event.url === null ? null : this.protectUrl(event.url),
+          name:
+            event.name === null
+              ? null
+              : this.secrets.redact(event.name, "response"),
+        };
+    }
+  }
+
+  private async protectNetworkEvent(
+    event: BrowserScenarioEvent,
+    ready: boolean,
+    available: number,
+  ): Promise {
+    const message =
+      "Source transaction data was withheld because its marked response fields could not be read safely.";
+    if (event.kind === "network-content") {
+      if (!ready) {
+        return {
+          ...event,
+          headers:
+            event.headers.state === "not_requested"
+              ? event.headers
+              : { state: "unavailable", message },
+          body:
+            event.body.state === "not_requested" ||
+            event.body.state === "not_exposed"
+              ? event.body
+              : { state: "unavailable", reason: "body-read-failed", message },
+        };
+      }
+      const headers: BrowserNetworkHeaders =
+        event.headers.state === "unavailable"
+          ? {
+              ...event.headers,
+              message: this.secrets.redact(event.headers.message, "response"),
+            }
+          : event.headers.state !== "captured"
+            ? event.headers
+            : {
+                state: "captured",
+                items: event.headers.items.map((item) => {
+                  const name = this.secrets.redact(item.name, "response"),
+                    value =
+                      item.value === null
+                        ? null
+                        : this.secrets.redact(item.value, "response");
+                  return {
+                    ...item,
+                    name,
+                    value,
+                    redacted:
+                      item.redacted ||
+                      name !== item.name ||
+                      value !== item.value,
+                  };
+                }),
+              };
+      let body = event.body;
+      if (body.state === "unavailable")
+        body = {
+          ...body,
+          message: this.secrets.redact(body.message, "response"),
+        };
+      if (body.state === "captured") {
+        const prior = body;
+        const source = {
+          headers: (): Record =>
+            prior.media_type === null
+              ? {}
+              : { "content-type": prior.media_type },
+          headersArray: async () => [],
+        };
+        const protectedBody = await captureNetworkBody(
+          async () => Buffer.from(prior.content, "base64"),
+          event.phase,
+          source,
+          this.secrets,
+          {
+            redactionScope: "response",
+            timeoutMs: 5000,
+            maxBodyBytes: this.limits.max_network_body_bytes,
+            maxBodyAllocationBytes: Math.min(
+              this.limits.max_network_body_allocation_bytes,
+              available,
+            ),
+            maxHeaderAllocationBytes:
+              this.limits.max_network_header_allocation_bytes,
+            maxMetadataAllocationBytes:
+              this.limits.max_network_metadata_allocation_bytes,
+          },
+        );
+        body =
+          protectedBody.state === "omitted"
+            ? { state: "unavailable", reason: "body-read-failed", message }
+            : protectedBody;
+        if (body.state === "captured")
+          body = { ...body, redacted: body.redacted || prior.redacted };
+      }
+      return { ...event, headers, body };
+    }
+    if (!("transaction_id" in event) || !("url" in event)) return event;
+    const url = ready
+      ? this.protectUrl(event.url)
+      : { url: "", origin: null, query_parameter_names: [], redacted: true };
+    const metadata = {
+      url: { ...url, redacted: url.redacted || event.url.redacted },
+      method: this.secrets.redact(event.method, "response"),
+      resource_type: this.secrets.redact(event.resource_type, "response"),
+      header_names: event.header_names.map((name) =>
+        ready
+          ? this.secrets.redact(name, "response")
+          : "[REDACTED:source-unavailable]",
+      ),
+    };
+    return event.kind === "request-failed"
+      ? {
+          ...event,
+          ...metadata,
+          failure: ready
+            ? this.secrets.redact(event.failure, "response")
+            : message,
+        }
+      : { ...event, ...metadata };
+  }
+
   private push(event: UnindexedEvent): BrowserScenarioEventAdmission {
     this.sequence += 1;
     const cost =
diff --git a/src/browser/PlaywrightScenarioRequest.test.ts b/src/browser/PlaywrightScenarioRequest.test.ts
index 287540065e..3b87e04885 100644
--- a/src/browser/PlaywrightScenarioRequest.test.ts
+++ b/src/browser/PlaywrightScenarioRequest.test.ts
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
 import { browserScenarioSchema } from "../domain/browserScenario.js";
 import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
 import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
+import { responsePointerValue } from "./BrowserScenarioResponseValues.js";
 import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js";
 
 const ORIGIN = "https://app.example.test";
@@ -158,6 +159,8 @@ const fakePage = (options: {
     origin: options.sendingOrigin ?? new URL(url).origin,
   });
   return {
+    on: vi.fn(),
+    off: vi.fn(),
     route: async () => {},
     unroute: async () => {},
     url: () => url,
@@ -183,11 +186,11 @@ const fakePage = (options: {
   } as unknown as Page;
 };
 
-const stubFetch = (status: number) => {
+const stubFetch = (status: number, bodies: readonly string[] = []) => {
   const calls: FakeFetch[] = [];
   vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
     calls.push({ url, init });
-    return new Response("{}", { status });
+    return new Response(bodies[calls.length - 1] ?? "{}", { status });
   });
   return calls;
 };
@@ -196,12 +199,14 @@ const perform = async (
   page: Page,
   action: BrowserScenarioAction,
   secrets: BrowserScenarioSecrets,
+  signal?: AbortSignal,
 ) =>
   performPlaywrightScenarioAction({
     page,
     action,
     secrets,
     startOrigin: ORIGIN,
+    signal,
   });
 
 const resolved = (parsed: ReturnType, environment = {}) => {
@@ -378,6 +383,75 @@ describe("request dispatch boundary", () => {
     expect(sent).not.toHaveBeenCalled();
     expect(unroute).toHaveBeenCalledOnce();
   });
+});
+
+describe("request route cleanup", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("preserves the primary request failure when removing its route also fails", async () => {
+    const parsed = scenario([requestStep({})]);
+    const page = fakePage({});
+    const primary = new Error("page.evaluate: Target page has been closed");
+    const cleanup = new Error("page.unroute: Target page has been closed");
+    vi.spyOn(page, "evaluate").mockRejectedValue(primary);
+    const unroute = vi.spyOn(page, "unroute").mockRejectedValue(cleanup);
+    const failure = await perform(
+      page,
+      parsed.actions[0]!,
+      resolved(parsed),
+    ).catch((cause: unknown) => cause);
+    expect(failure).toBeInstanceOf(AggregateError);
+    expect((failure as AggregateError).cause).toBe(primary);
+    expect((failure as AggregateError).errors).toEqual([primary, cleanup]);
+    expect(unroute).toHaveBeenCalledOnce();
+  });
+
+  it("preserves an unsuccessful page response when removing its route fails", async () => {
+    stubFetch(503);
+    const parsed = scenario([requestStep({ expected_status: [200] })]);
+    const page = fakePage({});
+    const cleanup = new Error("route cleanup failed");
+    vi.spyOn(page, "unroute").mockRejectedValue(cleanup);
+    const failure = await perform(
+      page,
+      parsed.actions[0]!,
+      resolved(parsed),
+    ).catch((cause: unknown) => cause);
+    expect(failure).toBeInstanceOf(AggregateError);
+    expect((failure as AggregateError).errors[0].message).toContain(
+      "answered 503",
+    );
+    expect((failure as AggregateError).errors[1]).toBe(cleanup);
+    expect((failure as AggregateError).cause).toBe(
+      (failure as AggregateError).errors[0],
+    );
+  });
+
+  it("publishes no saved values if route cleanup fails after a successful response", async () => {
+    stubFetch(200, [JSON.stringify({ id: "created" })]);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "id", json_pointer: "/id" }] }),
+    ]);
+    const page = fakePage({});
+    const secrets = resolved(parsed);
+    const save = vi.spyOn(secrets, "save");
+    const ready = vi.spyOn(secrets, "markResponseSecretsReady");
+    const cleanup = new Error("route cleanup failed");
+    vi.spyOn(page, "unroute").mockRejectedValue(cleanup);
+    await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toBe(
+      cleanup,
+    );
+    expect(save).not.toHaveBeenCalled();
+    expect(ready).not.toHaveBeenCalled();
+  });
+});
+
+describe("request page boundaries", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
 
   it("refuses foreign-origin storage before reading its value", async () => {
     const calls = stubFetch(200);
@@ -510,6 +584,54 @@ describe("request step confinement", () => {
       expect(calls.at(-1)!.url, path).toBe(`${ORIGIN}${sent}`);
     }
   });
+});
+
+describe("request page value admission", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("preserves exact local storage pointer values and refuses lossy numbers before dispatch", async () => {
+    const calls = stubFetch(200);
+    const parsed = scenario([
+      requestStep({
+        headers: [
+          {
+            name: "X-Account",
+            value: {
+              source: "local_storage",
+              key: "auth",
+              json_pointer: "/id",
+            },
+          },
+        ],
+      }),
+    ]);
+    for (const literal of ["9007199254740993", "1e400", "-0"]) {
+      await expect(
+        perform(
+          fakePage({ storage: { auth: `{"id":${literal}}` } }),
+          parsed.actions[0]!,
+          resolved(parsed),
+        ),
+      ).rejects.toThrow("cannot be read exactly");
+    }
+    expect(calls).toEqual([]);
+    for (const [literal, expected] of [
+      ["9007199254740991", "9007199254740991"],
+      ['"9007199254740993"', "9007199254740993"],
+      ["42", "42"],
+    ]) {
+      await perform(
+        fakePage({
+          storage: { auth: `{"id":${literal},"unselected":9007199254740993}` },
+        }),
+        parsed.actions[0]!,
+        resolved(parsed),
+      );
+      expect(calls.at(-1)!.init.headers).toEqual([["X-Account", expected]]);
+    }
+  });
 
   it("refuses page values a header cannot carry, and stored values it cannot read", async () => {
     const calls = stubFetch(200);
@@ -593,3 +715,1272 @@ describe("page value redaction", () => {
     expect(held.redact(text)).toBe(text);
   });
 });
+
+const savedValue = (name: string) => ({ source: "saved", name });
+const saveIdStep = (stepId: string, name: string) =>
+  requestStep({
+    step_id: stepId,
+    save: [{ name, json_pointer: "/id" }],
+  });
+const clickSavingItem = (name: string) => ({
+  step_id: "save-item",
+  action: "click",
+  locator: { kind: "role", role: "button", name: "Save" },
+  save_response: {
+    method: "POST",
+    path: "/api/items",
+    save: [{ name, json_pointer: "/id" }],
+  },
+});
+const gotoStep = (url: string) => ({
+  step_id: "open",
+  action: "goto",
+  destination: { url },
+  wait_until: "load",
+});
+
+describe("saved value schema", () => {
+  it("refuses a saved value no earlier step saves, wherever a step sends it", () => {
+    const sendsItem = requestStep({
+      step_id: "edit",
+      method: "PUT",
+      path: "/api/items/{saved:item}",
+    });
+    expect(issues([sendsItem])).toEqual([
+      "Step edit sends saved value item, which no earlier step saves",
+    ]);
+    expect(issues([sendsItem, saveIdStep("create", "item")])).toContainEqual(
+      expect.stringContaining("which no earlier step saves"),
+    );
+    expect(issues([saveIdStep("create", "item"), sendsItem])).toEqual([]);
+    expect(issues([clickSavingItem("item"), sendsItem])).toEqual([]);
+    for (const step of [
+      gotoStep(`${ORIGIN}/items/{saved:item}`),
+      gotoStep(`${ORIGIN}/items/%7Bsaved%3Aitem%7D`),
+      requestStep({ path: "/api/items?parent={saved:item}" }),
+      requestStep({ json: { list: [{ parent: "{saved:item}" }] } }),
+      requestStep({ json: { note: "Copied from {saved:item}" } }),
+      requestStep({ headers: [{ name: "X-Item", value: savedValue("item") }] }),
+      requestStep({
+        headers: [
+          {
+            name: "Authorization",
+            value: {
+              source: "basic_credentials",
+              parts: [savedValue("item"), { source: "literal", value: "x" }],
+            },
+          },
+        ],
+      }),
+    ])
+      expect(issues([step]), JSON.stringify(step)).toContainEqual(
+        expect.stringContaining("sends saved value item"),
+      );
+  });
+
+  it("reads only `{saved:name}` as written in a JSON string, not its encoded spelling", () => {
+    expect(
+      issues([requestStep({ json: { note: "%7Bsaved:item%7D" } })]),
+    ).toEqual([]);
+  });
+
+  it("sends other braces as written", () => {
+    expect(
+      issues([
+        gotoStep(`${ORIGIN}/search/{q}`),
+        requestStep({ path: "/api/items/{id}", json: { name: "{draft}" } }),
+      ]),
+    ).toEqual([]);
+  });
+});
+
+describe("saved value schema: credentials and response paths", () => {
+  it("refuses a literal Basic password, which Evidence would record as written", () => {
+    const basic = (password: unknown) =>
+      requestStep({
+        headers: [
+          {
+            name: "Authorization",
+            value: {
+              source: "basic_credentials",
+              parts: [{ source: "literal", value: "admin" }, password],
+            },
+          },
+        ],
+      });
+    expect(
+      issues([basic({ source: "literal", value: "hunter2" })]),
+    ).toContainEqual(expect.stringContaining("not a literal"));
+    expect(issues([basic({ source: "literal", value: "" })])).toEqual([]);
+  });
+
+  it("builds Basic credentials from page values and refuses undeclared secret parts", () => {
+    const basic = (username: unknown, password: unknown) =>
+      requestStep({
+        headers: [
+          {
+            name: "Authorization",
+            value: { source: "basic_credentials", parts: [username, password] },
+            prefix: "Basic ",
+          },
+        ],
+      });
+    expect(
+      issues([
+        basic(
+          { source: "local_storage", key: "user", json_pointer: "/name" },
+          { source: "cookie", name: "pw" },
+        ),
+      ]),
+    ).toEqual([]);
+    expect(
+      issues([
+        basic(
+          { source: "literal", value: "admin" },
+          { source: "secret", secret_id: "password" },
+        ),
+      ]),
+    ).toContainEqual(expect.stringContaining("not declared"));
+    const literal = { source: "literal", value: "admin" };
+    expect(
+      issues([
+        requestStep({
+          headers: [
+            {
+              name: "Authorization",
+              value: { source: "basic_credentials", parts: [literal] },
+            },
+          ],
+        }),
+      ]).length,
+    ).toBeGreaterThan(0);
+  });
+
+  it("requires a save_response to name a same-origin path and something to save", () => {
+    const click = (saveResponse: unknown) => ({
+      ...clickSavingItem("item"),
+      save_response: saveResponse,
+    });
+    for (const path of ["//evil.test/api", "api/items", "https://evil.test/"])
+      expect(
+        issues([
+          click({
+            method: "POST",
+            path,
+            save: [{ name: "item", json_pointer: "/id" }],
+          }),
+        ]).length,
+        path,
+      ).toBeGreaterThan(0);
+    expect(
+      issues([click({ method: "POST", path: "/api/items", save: [] })]).length,
+    ).toBeGreaterThan(0);
+  });
+});
+
+describe("saved value execution", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("sends saved ids in the path and body with their saved type, and a saved secret only redacted", async () => {
+    const calls = stubFetch(200, [
+      '{"id":42,"session":{"token":"session-token-9f3e"}}',
+    ]);
+    const parsed = scenario([
+      requestStep({
+        json: { title: "Scratch folder 1" },
+        save: [
+          { name: "folder", json_pointer: "/id" },
+          { name: "token", json_pointer: "/session/token", secret: true },
+        ],
+      }),
+      requestStep({
+        step_id: "move",
+        method: "PUT",
+        path: "/api/folders/{saved:folder}",
+        json: {
+          title: "Moved",
+          parent: "{saved:folder}",
+          note: "Moved into {saved:folder}",
+        },
+        headers: [
+          {
+            name: "Authorization",
+            value: { source: "saved", name: "token" },
+            prefix: "Bearer ",
+          },
+        ],
+      }),
+    ]);
+    const secrets = resolved(parsed);
+    const page = fakePage({});
+    for (const action of parsed.actions) await perform(page, action, secrets);
+    expect(calls[1]).toEqual({
+      url: `${ORIGIN}/api/folders/42`,
+      init: expect.objectContaining({
+        method: "PUT",
+        headers: [
+          ["Authorization", "Bearer session-token-9f3e"],
+          ["content-type", "application/json"],
+        ],
+        body: '{"title":"Moved","parent":42,"note":"Moved into 42"}',
+      }),
+    });
+    expect(secrets.redact("folder 42 with session-token-9f3e")).toBe(
+      "folder 42 with [REDACTED:page:create.token]",
+    );
+  });
+});
+
+describe("saved value credentials and limits", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("redacts Basic credentials and secret saves however short", async () => {
+    stubFetch(200, ['{"pin":"x7"}']);
+    const parsed = scenario(
+      [
+        requestStep({
+          headers: [
+            {
+              name: "X-Auth",
+              value: {
+                source: "basic_credentials",
+                parts: [
+                  { source: "secret", secret_id: "user" },
+                  { source: "secret", secret_id: "password" },
+                ],
+              },
+            },
+          ],
+          save: [{ name: "pin", json_pointer: "/pin", secret: true }],
+        }),
+      ],
+      [
+        { secret_id: "user", environment_variable: "APP_USER" },
+        { secret_id: "password", environment_variable: "APP_PASSWORD" },
+      ],
+    );
+    const secrets = resolved(parsed, { APP_USER: "a", APP_PASSWORD: "b" });
+    await perform(fakePage({}), parsed.actions[0]!, secrets);
+    const encoded = Buffer.from("a:b").toString("base64");
+    const redacted = secrets.redact(`X-Auth: ${encoded} pin x7`);
+    expect(redacted).not.toContain(encoded);
+    expect(redacted).not.toContain("x7");
+  });
+
+  it("saves no integer JSON parsing would round, so no later step names another object", async () => {
+    stubFetch(201, ['{"id":9007199254740993}']);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
+    ]);
+    const secrets = resolved(parsed);
+    await expect(
+      perform(fakePage({}), parsed.actions[0]!, secrets),
+    ).rejects.toThrow("cannot be saved exactly");
+    expect(() => secrets.savedValue("folder")).toThrow();
+  });
+
+  it("sends a JSON string that spells a placeholder percent-encoded as written", async () => {
+    const calls = stubFetch(200, ['{"id":7}']);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+      requestStep({
+        step_id: "note",
+        json: { note: "%7Bsaved:item%7D", id: "{saved:item}" },
+      }),
+    ]);
+    const secrets = resolved(parsed);
+    for (const action of parsed.actions)
+      await perform(fakePage({}), action, secrets);
+    expect(calls[1]!.init.body).toBe('{"note":"%7Bsaved:item%7D","id":7}');
+  });
+
+  it("stops reading a response to save from once it passes the limit", async () => {
+    const chunk = new Uint8Array(3_000_000).fill(97);
+    const read = vi
+      .fn()
+      .mockResolvedValueOnce({ done: false, value: chunk })
+      .mockResolvedValueOnce({ done: false, value: chunk })
+      .mockResolvedValueOnce({ done: false, value: chunk })
+      .mockResolvedValueOnce({ done: true });
+    const cancel = vi.fn(async () => undefined);
+    vi.stubGlobal("fetch", async () => ({
+      status: 200,
+      type: "basic",
+      body: { getReader: () => ({ read, cancel, releaseLock: vi.fn() }) },
+    }));
+    const parsed = scenario([
+      requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
+    ]);
+    await expect(
+      perform(fakePage({}), parsed.actions[0]!, resolved(parsed)),
+    ).rejects.toThrow("too large to save values from");
+    expect(read).toHaveBeenCalledTimes(2);
+    expect(cancel).toHaveBeenCalledOnce();
+  });
+
+  it("fails a step whose response holds no value to save", async () => {
+    stubFetch(200, ['{"items":[]}', "not json"]);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
+    ]);
+    const secrets = resolved(parsed);
+    await expect(
+      perform(fakePage({}), parsed.actions[0]!, secrets),
+    ).rejects.toThrow("no string or number at /id to save as folder");
+    await expect(
+      perform(fakePage({}), parsed.actions[0]!, secrets),
+    ).rejects.toThrow("is not JSON");
+    expect(() => secrets.savedValue("folder")).toThrow();
+  });
+
+  it("encodes Basic credentials read from local storage and a cookie, and redacts both parts", async () => {
+    const calls = stubFetch(200);
+    const parsed = scenario([
+      requestStep({
+        headers: [
+          {
+            name: "Authorization",
+            value: {
+              source: "basic_credentials",
+              parts: [
+                {
+                  source: "local_storage",
+                  key: "user",
+                  json_pointer: "/name",
+                },
+                { source: "cookie", name: "api_key" },
+              ],
+            },
+            prefix: "Basic ",
+          },
+        ],
+      }),
+    ]);
+    const secrets = resolved(parsed);
+    await perform(
+      fakePage({
+        cookies: [{ name: "api_key", value: "cookie-key-5d1c" }],
+        storage: { user: JSON.stringify({ name: "operator-7" }) },
+      }),
+      parsed.actions[0]!,
+      secrets,
+    );
+    const encoded = Buffer.from("operator-7:cookie-key-5d1c").toString(
+      "base64",
+    );
+    expect(calls[0]!.init.headers).toEqual([
+      ["Authorization", `Basic ${encoded}`],
+    ]);
+    const redacted = secrets.redact(`${encoded} operator-7 cookie-key-5d1c`);
+    for (const value of [encoded, "operator-7", "cookie-key-5d1c"])
+      expect(redacted).not.toContain(value);
+  });
+});
+
+describe("saved response JSON pointers", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("does not coerce a terminal newline in an array index", () => {
+    expect(responsePointerValue({ items: ["record-7"] }, "/items/0\n")).toBe(
+      undefined,
+    );
+  });
+
+  it.each(["length", "01", "-"])(
+    "refuses the non-index array token %s",
+    async (token) => {
+      stubFetch(201, ['{"items":["record-7","record-8"]}']);
+      const parsed = scenario([
+        requestStep({
+          save: [{ name: "item", json_pointer: `/items/${token}` }],
+        }),
+      ]);
+      const secrets = resolved(parsed);
+      await expect(
+        perform(fakePage({}), parsed.actions[0]!, secrets),
+      ).rejects.toThrow("has no string or number");
+      expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    },
+  );
+
+  it.each([
+    ["/items/0", "record-7"],
+    ["/length", 12],
+    ["/01", "object-key"],
+    ["/a~1b/~0key", "escaped-key"],
+  ])("preserves JSON member identity at %s", async (pointer, expected) => {
+    stubFetch(201, [
+      '{"items":["record-7"],"length":12,"01":"object-key","a/b":{"~key":"escaped-key"}}',
+    ]);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: pointer }] }),
+    ]);
+    const secrets = resolved(parsed);
+    await perform(fakePage({}), parsed.actions[0]!, secrets);
+    expect(secrets.savedValue("item")).toBe(expected);
+  });
+});
+
+describe("saved numbers JSON cannot spell back", () => {
+  it.each([
+    ["0", 0],
+    ["0.0", 0],
+    ["1.5", 1.5],
+    ["12.50", 12.5],
+    ["1E2", 100],
+    ["0.1", 0.1],
+  ])(
+    "preserves an equivalent JSON decimal spelling (%s)",
+    async (number, kept) => {
+      stubFetch(201, [`{"id":${number}}`]);
+      const parsed = scenario([
+        requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+      ]);
+      const secrets = resolved(parsed);
+      await perform(fakePage({}), parsed.actions[0]!, secrets);
+      expect(secrets.savedValue("item")).toBe(kept);
+    },
+  );
+  it.each(["-0", "1e-400", "-1e-400", "1.0000000000000000001"])(
+    "refuses a saved numeric identity that loses precision during parsing: %s",
+    async (number) => {
+      stubFetch(201, [`{"id":${number}}`]);
+      const parsed = scenario([
+        requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+      ]);
+      const secrets = resolved(parsed);
+      await expect(
+        perform(fakePage({}), parsed.actions[0]!, secrets),
+      ).rejects.toThrow("cannot be saved exactly");
+      expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    },
+  );
+
+  it("keeps an exact saved field when an unrelated response number is inexact", async () => {
+    stubFetch(201, ['{"id":"record-7","unrelated":1e-400}']);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+    ]);
+    const secrets = resolved(parsed);
+    await perform(fakePage({}), parsed.actions[0]!, secrets);
+    expect(secrets.savedValue("item")).toBe("record-7");
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it.each(["1e400", "-1e400"])(
+    "saves no number JSON cannot spell back (%s)",
+    async (number) => {
+      stubFetch(201, [`{"id":${number}}`]);
+      const parsed = scenario([
+        requestStep({ save: [{ name: "folder", json_pointer: "/id" }] }),
+      ]);
+      const secrets = resolved(parsed);
+      await expect(
+        perform(fakePage({}), parsed.actions[0]!, secrets),
+      ).rejects.toThrow("not finite");
+      expect(() => secrets.savedValue("folder")).toThrow();
+    },
+  );
+});
+
+describe("values a URL path or Basic header cannot carry", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("fails a Basic header whose username holds a colon, before sending it", async () => {
+    const calls = stubFetch(200, ["{}"]);
+    const parsed = scenario(
+      [
+        requestStep({
+          headers: [
+            {
+              name: "Authorization",
+              value: {
+                source: "basic_credentials",
+                parts: [
+                  { source: "secret", secret_id: "user" },
+                  { source: "secret", secret_id: "password" },
+                ],
+              },
+            },
+          ],
+        }),
+      ],
+      [
+        { secret_id: "user", environment_variable: "APP_USER" },
+        { secret_id: "password", environment_variable: "APP_PASSWORD" },
+      ],
+    );
+    const secrets = resolved(parsed, {
+      APP_USER: "ops:admin",
+      APP_PASSWORD: "b",
+    });
+    await expect(
+      perform(fakePage({}), parsed.actions[0]!, secrets),
+    ).rejects.toThrow("Basic username with a colon");
+    expect(calls).toHaveLength(0);
+  });
+
+  it.each([".", ".."])(
+    "fails a step whose saved %s fills a whole path segment, which the URL would drop",
+    async (dots) => {
+      const parsed = scenario([
+        requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+        {
+          step_id: "open",
+          action: "goto",
+          destination: { url: `${ORIGIN}/items/{saved:item}/edit` },
+          wait_until: "load",
+        },
+        requestStep({ step_id: "edit", path: "/api/items/{saved:item}" }),
+      ]);
+      const secrets = resolved(parsed);
+      secrets.save("create.item", "item", dots, false);
+      const goto = vi.fn(async () => null);
+      await expect(
+        perform({ goto } as unknown as Page, parsed.actions[1]!, secrets),
+      ).rejects.toThrow("dot segment");
+      expect(goto).not.toHaveBeenCalled();
+      await expect(
+        perform(fakePage({}), parsed.actions[2]!, secrets),
+      ).rejects.toThrow("dot segment");
+    },
+  );
+
+  it.each([
+    ["/api/items/{saved:item}.", `${ORIGIN}/items/{saved:item}.`],
+    ["/api/items/%2e{saved:item}", `${ORIGIN}/items/%2E{saved:item}`],
+    ["/safe\\{saved:item}.", `${ORIGIN}/safe\\{saved:item}.`],
+    ["/safe\\%2e{saved:item}", `${ORIGIN}/safe\\%2e{saved:item}`],
+  ])(
+    "fails a step whose saved . and the text beside it make a dot segment (%s)",
+    async (path, url) => {
+      const parsed = scenario([
+        requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+        {
+          step_id: "open",
+          action: "goto",
+          destination: { url },
+          wait_until: "load",
+        },
+        requestStep({ step_id: "edit", path }),
+      ]);
+      const secrets = resolved(parsed);
+      secrets.save("create.item", "item", ".", false);
+      const goto = vi.fn(async () => null);
+      await expect(
+        perform({ goto } as unknown as Page, parsed.actions[1]!, secrets),
+      ).rejects.toThrow("dot segment");
+      expect(goto).not.toHaveBeenCalled();
+      await expect(
+        perform(fakePage({}), parsed.actions[2]!, secrets),
+      ).rejects.toThrow("dot segment");
+    },
+  );
+
+  it("sends dots that are part of a segment or of the query", async () => {
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+      {
+        step_id: "open",
+        action: "goto",
+        destination: { url: `${ORIGIN}/items/v{saved:item}?at={saved:item}` },
+        wait_until: "load",
+      },
+    ]);
+    const secrets = resolved(parsed);
+    secrets.save("create.item", "item", "..", false);
+    const goto = vi.fn(async () => null);
+    await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
+    expect(goto).toHaveBeenCalledWith(
+      `${ORIGIN}/items/v..?at=..`,
+      expect.anything(),
+    );
+  });
+});
+
+describe("bounded derived credentials", () => {
+  it("refuses over-budget Unicode spellings before building encoded copies", () => {
+    const secrets = resolved(scenario([requestStep({})]));
+    const encode = vi.spyOn(globalThis, "encodeURIComponent");
+    try {
+      expect(() =>
+        secrets.save("create.token", "token", "私".repeat(1_000_000), true),
+      ).toThrow("more than 4000000 characters");
+      expect(encode).not.toHaveBeenCalled();
+      expect(() => secrets.savedValue("token")).toThrow(
+        "No earlier step saved",
+      );
+    } finally {
+      encode.mockRestore();
+    }
+  });
+  it("charges distinct Basic credentials while admitting repeated identical spellings once", () => {
+    const secrets = resolved(scenario([requestStep({})]));
+    const password = "x".repeat(300_000);
+    secrets.save("create.token", "token", password, false);
+    const first = secrets.basicCredential("login.auth", "operator-0", password);
+    for (let index = 0; index < 8; index++)
+      expect(
+        secrets.basicCredential("again.auth", "operator-0", password),
+      ).toBe(first);
+    expect(() => {
+      for (let index = 1; index < 16; index++)
+        secrets.basicCredential(
+          `s${String(index)}.auth`,
+          `operator-${String(index)}`,
+          password,
+        );
+    }).toThrow("more than 4000000 characters");
+    expect(secrets.savedValue("token")).toBe(password);
+    expect(secrets.redact(first)).not.toContain(first);
+  });
+
+  it("refuses excessive base64 expansion before allocating the credential buffer", () => {
+    const secrets = resolved(scenario([requestStep({})]));
+    const allocate = vi.spyOn(Buffer, "allocUnsafe");
+    try {
+      expect(() =>
+        secrets.basicCredential("login.auth", "u", "x".repeat(3_000_001)),
+      ).toThrow("encoded form exceeds");
+      expect(allocate).not.toHaveBeenCalled();
+    } finally {
+      allocate.mockRestore();
+    }
+  });
+});
+
+describe("saved value retention", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("charges repeated identical secret saves only for retained values", () => {
+    const secrets = resolved(scenario([requestStep({})]));
+    const token = "s".repeat(1_000_000);
+    for (let index = 0; index < 8; index++)
+      secrets.save(`step${String(index)}.token`, "token", token, true);
+    expect(secrets.savedValue("token")).toBe(token);
+    expect(secrets.redact(token)).not.toContain(token);
+  });
+
+  it("charges trimmed secret spellings before changing either saved or redaction state", () => {
+    const secrets = resolved(scenario([requestStep({})]));
+    secrets.save("seed.fill", "fill", "x".repeat(3_999_000), false);
+    const trimmed = "a".repeat(198);
+    expect(() =>
+      secrets.save("step.padded", "padded", ` ${trimmed} `, true),
+    ).toThrow("more than 4000000 characters of saved values");
+    expect(() => secrets.savedValue("padded")).toThrow("No earlier step saved");
+    expect(secrets.redact(trimmed)).toBe(trimmed);
+    expect(String(secrets.savedValue("fill"))).toHaveLength(3_999_000);
+  });
+
+  it("refuses a new saved name that would keep more than the limit, before keeping it", () => {
+    const secrets = resolved(scenario([requestStep({})]));
+    secrets.save("a.first", "first", "x".repeat(3_000_000), false);
+    expect(() =>
+      secrets.save("b.second", "second", "y".repeat(1_500_000), false),
+    ).toThrow("more than 4000000 characters of saved values");
+    expect(() => secrets.savedValue("second")).toThrow("No earlier step saved");
+    // A later save of the same name replaces its value rather than adding to it.
+    secrets.save("c.first", "first", "z".repeat(3_500_000), false);
+    expect(String(secrets.savedValue("first"))).toHaveLength(3_500_000);
+  });
+
+  it("counts the redaction spellings a secret save keeps", () => {
+    const plain = resolved(scenario([requestStep({})]));
+    plain.save("a.token", "token", "t".repeat(2_500_000), false);
+    const secret = resolved(scenario([requestStep({})]));
+    expect(() =>
+      secret.save("a.token", "token", "t".repeat(2_500_000), true),
+    ).toThrow("more than 4000000 characters of saved values");
+  });
+
+  it("fails the step that would save past the limit", async () => {
+    stubFetch(201, [`{"id":"${"y".repeat(200_000)}"}`]);
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+    ]);
+    const secrets = resolved(parsed);
+    secrets.save("seed.big", "big", "x".repeat(3_900_000), false);
+    await expect(
+      perform(fakePage({}), parsed.actions[0]!, secrets),
+    ).rejects.toThrow(
+      "Step create: item cannot be saved: the scenario would keep more than",
+    );
+  });
+});
+
+describe("goto placeholders", () => {
+  it("fills a goto destination's placeholders, percent-encoded", async () => {
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+      {
+        step_id: "open",
+        action: "goto",
+        destination: { url: `${ORIGIN}/items/{saved:item}/edit` },
+        wait_until: "load",
+      },
+    ]);
+    const secrets = resolved(parsed);
+    secrets.save("create.item", "item", "a b/c", false);
+    const goto = vi.fn(async () => null);
+    await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
+    expect(goto).toHaveBeenCalledWith(
+      `${ORIGIN}/items/a%20b%2Fc/edit`,
+      expect.anything(),
+    );
+  });
+
+  it("keeps a structured query value that spells a placeholder as written", async () => {
+    const parsed = scenario([
+      requestStep({ save: [{ name: "item", json_pointer: "/id" }] }),
+      {
+        step_id: "open",
+        action: "goto",
+        destination: {
+          url: `${ORIGIN}/items/{saved:item}`,
+          query: [
+            {
+              name: "note",
+              value: { source: "literal", value: "{saved:item}" },
+            },
+          ],
+        },
+        wait_until: "load",
+      },
+    ]);
+    const secrets = resolved(parsed);
+    secrets.save("create.item", "item", "7", false);
+    const goto = vi.fn(async () => null);
+    await perform({ goto } as unknown as Page, parsed.actions[1]!, secrets);
+    const [address] = goto.mock.calls[0] as unknown as [string];
+    const url = new URL(address);
+    expect(url.pathname).toBe("/items/7");
+    expect(url.searchParams.get("note")).toBe("{saved:item}");
+  });
+});
+
+interface FakeResponse {
+  readonly method: string;
+  readonly url: string;
+  readonly status: number;
+  readonly body: string;
+  /** A request already running when the step began, such as an earlier autosave. */
+  readonly earlier?: boolean;
+  /** The body size the browser received, when it differs from the body's. */
+  readonly size?: number;
+  readonly encoding?: string;
+  /** A body still arriving: the text resolves with it. */
+  readonly late?: Promise;
+}
+
+/**
+ * A page whose click starts the given requests, emitting each that did not start earlier to request listeners, and
+ * answers them in order; waitForResponse resolves with the first answer it names once the click acted.
+ */
+const uiPage = (responses: readonly FakeResponse[], clickFails = false) => {
+  const order: string[] = [];
+  const reads = { count: 0 };
+  const listeners = new Set<(request: unknown) => void>();
+  let acted: () => void = () => undefined;
+  const clicked = new Promise((resolve) => {
+    acted = resolve;
+  });
+  const answers = responses.map((response) => {
+    const request = {
+      method: () => response.method,
+      url: () => response.url,
+      sizes: async () => ({
+        responseBodySize: response.size ?? response.body.length,
+      }),
+    };
+    return {
+      response,
+      request,
+      answer: {
+        request: () => request,
+        url: () => response.url,
+        status: () => response.status,
+        headers: () =>
+          response.encoding === undefined
+            ? {}
+            : { "content-encoding": response.encoding },
+        body: async () => {
+          reads.count += 1;
+          return Buffer.from(await (response.late ?? response.body));
+        },
+      },
+    };
+  });
+  const page = {
+    on: (_event: string, listener: (request: unknown) => void) =>
+      listeners.add(listener),
+    off: (_event: string, listener: (request: unknown) => void) =>
+      listeners.delete(listener),
+    getByRole: () => ({
+      click: async () => {
+        order.push("click");
+        if (clickFails) throw new Error("Element is not attached");
+        for (const { response, request } of answers)
+          if (response.earlier !== true)
+            for (const listener of listeners) listener(request);
+        acted();
+      },
+    }),
+    waitForResponse: async (
+      names: (response: unknown) => boolean,
+      options: { timeout?: number },
+    ) => {
+      order.push(`wait ${String(options.timeout)}`);
+      await clicked;
+      const found = answers.map(({ answer }) => answer).find(names);
+      if (found === undefined) throw new Error("Timeout exceeded");
+      return found;
+    },
+  } as unknown as Page;
+  return { page, order, listeners, reads };
+};
+
+const clickSaving = (path: string) =>
+  scenario([
+    {
+      step_id: "save-item",
+      action: "click",
+      timeout_ms: 5000,
+      locator: { kind: "role", role: "button", name: "Save" },
+      save_response: {
+        method: "POST",
+        path,
+        save: [{ name: "item", json_pointer: "/id" }],
+      },
+    },
+  ]);
+
+describe("ambiguous saved response observations", () => {
+  it("refuses an ambiguous poll and click request without choosing either identity", async () => {
+    const parsed = clickSaving("/api/items");
+    const secrets = resolved(parsed);
+    const { page, listeners } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: '{"id":"poll"}',
+      },
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: '{"id":"created"}',
+      },
+    ]);
+    await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toThrow(
+      "ambiguous",
+    );
+    expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    expect(listeners.size).toBe(0);
+  });
+
+  it("refuses a second matching request observed while the selected body is pending", async () => {
+    const parsed = clickSaving("/api/items");
+    const secrets = resolved(parsed);
+    let release!: (body: string) => void;
+    const late = new Promise((resolve) => {
+      release = resolve;
+    });
+    const { page, listeners, reads } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: "",
+        size: 30,
+        late,
+      },
+    ]);
+    const running = perform(page, parsed.actions[0]!, secrets);
+    const rejected = expect(running).rejects.toThrow("ambiguous");
+    await vi.waitFor(() => expect(reads.count).toBe(1));
+    for (const listener of listeners)
+      listener({ method: () => "POST", url: () => `${ORIGIN}/api/items` });
+    release('{"id":"wrong-object"}');
+    await rejected;
+    expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    expect(listeners.size).toBe(0);
+  });
+});
+
+describe("saving from a UI step's response", () => {
+  it("waits before clicking, and saves from the response the step names", async () => {
+    const parsed = clickSaving("/api/lists/{*}/items");
+    const secrets = resolved(parsed);
+    const { page, order } = uiPage([
+      {
+        method: "GET",
+        url: `${ORIGIN}/api/lists/3/items`,
+        status: 200,
+        body: '{"id":"read"}',
+      },
+      {
+        method: "POST",
+        url: `https://other.test/api/lists/3/items`,
+        status: 201,
+        body: '{"id":"foreign"}',
+      },
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/lists/3/4/items`,
+        status: 201,
+        body: '{"id":"deeper"}',
+      },
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/lists/3/items?draft=1`,
+        status: 201,
+        body: '{"id":"item-7"}',
+      },
+    ]);
+    await perform(page, parsed.actions[0]!, secrets);
+    expect(order).toEqual(["wait 5000", "click"]);
+    expect(secrets.savedValue("item")).toBe("item-7");
+  });
+
+  it("saves from a request the step started, not one already running, and matches an encoded path", async () => {
+    const parsed = clickSaving("/api/lists/My List/items");
+    const secrets = resolved(parsed);
+    const { page, listeners } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/lists/My%20List/items`,
+        status: 201,
+        body: '{"id":"autosaved"}',
+        earlier: true,
+      },
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/lists/My%20List/items`,
+        status: 201,
+        body: '{"id":"item-8"}',
+      },
+    ]);
+    await perform(page, parsed.actions[0]!, secrets);
+    expect(secrets.savedValue("item")).toBe("item-8");
+    expect(listeners.size).toBe(0);
+  });
+
+  it("refuses a response too large to save from without reading it", async () => {
+    const parsed = clickSaving("/api/items");
+    const { page, reads } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: '{"id":"x"}',
+        size: 12_000_001,
+      },
+    ]);
+    await expect(
+      perform(page, parsed.actions[0]!, resolved(parsed)),
+    ).rejects.toThrow("too large to save values from");
+    expect(reads.count).toBe(0);
+  });
+
+  it.each([
+    { encoding: "gzip", size: 32 },
+    { encoding: "br", size: 32 },
+    { encoding: "identity", size: 0 },
+    { size: -1 },
+    { size: Number.NaN },
+  ])(
+    "refuses unknown decoded UI response bounds before body allocation: %j",
+    async (bounds) => {
+      const parsed = clickSaving("/api/items");
+      const secrets = resolved(parsed);
+      const { page, reads, listeners } = uiPage([
+        {
+          method: "POST",
+          url: `${ORIGIN}/api/items`,
+          status: 201,
+          body: '{"id":"record-7"}',
+          ...bounds,
+        },
+      ]);
+      await expect(perform(page, parsed.actions[0]!, secrets)).rejects.toThrow(
+        "decoded response size",
+      );
+      expect(reads.count).toBe(0);
+      expect(listeners.size).toBe(0);
+      expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    },
+  );
+
+  it("stops watching requests when the step fails before its response", async () => {
+    const parsed = clickSaving("/api/items");
+    const { page, listeners } = uiPage([], true);
+    await expect(
+      perform(page, parsed.actions[0]!, resolved(parsed)),
+    ).rejects.toThrow("Element is not attached");
+    expect(listeners.size).toBe(0);
+  });
+
+  it("fails the step when no named response arrives or it is not 2xx", async () => {
+    const parsed = clickSaving("/api/items");
+    await expect(
+      perform(uiPage([]).page, parsed.actions[0]!, resolved(parsed)),
+    ).rejects.toThrow(
+      "no POST /api/items response arrived before the step's timeout",
+    );
+    await expect(
+      perform(
+        uiPage([
+          {
+            method: "POST",
+            url: `${ORIGIN}/api/items`,
+            status: 422,
+            body: '{"id":"x"}',
+          },
+        ]).page,
+        parsed.actions[0]!,
+        resolved(parsed),
+      ),
+    ).rejects.toThrow(
+      "answered 422; values are saved only from a 2xx response",
+    );
+  });
+});
+
+describe("saved response limits and deadlines", () => {
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  const lateBody = () => {
+    let arrive: (body: string) => void = () => undefined;
+    const late = new Promise((resolve) => {
+      arrive = resolve;
+    });
+    return { late, arrive };
+  };
+
+  it("saves nothing from a body still arriving when the step's time runs out", async () => {
+    vi.useFakeTimers();
+    const parsed = clickSaving("/api/items");
+    const secrets = resolved(parsed);
+    const { late, arrive } = lateBody();
+    const { page, listeners } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: "",
+        size: Buffer.byteLength('{"id":"item-late"}'),
+        late,
+      },
+    ]);
+    const outcome = perform(page, parsed.actions[0]!, secrets).then(
+      () => "saved",
+      (cause: Error) => cause.message,
+    );
+    await vi.advanceTimersByTimeAsync(5001);
+    arrive('{"id":"item-late"}');
+    await expect(outcome).resolves.toContain(
+      "ended before its saved response was read",
+    );
+    expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    expect(listeners.size).toBe(0);
+  });
+
+  it("saves nothing from a body still arriving when the step is cancelled", async () => {
+    const parsed = clickSaving("/api/items");
+    const secrets = resolved(parsed);
+    const { late, arrive } = lateBody();
+    const { page, listeners } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: "",
+        size: Buffer.byteLength('{"id":"item-late"}'),
+        late,
+      },
+    ]);
+    const cancel = new AbortController();
+    const outcome = perform(
+      page,
+      parsed.actions[0]!,
+      secrets,
+      cancel.signal,
+    ).then(
+      () => "saved",
+      (cause: Error) => cause.message,
+    );
+    await new Promise((resolve) => setTimeout(resolve, 0));
+    cancel.abort();
+    arrive('{"id":"item-late"}');
+    await expect(outcome).resolves.toContain(
+      "ended before its saved response was read",
+    );
+    expect(() => secrets.savedValue("item")).toThrow("No earlier step saved");
+    expect(listeners.size).toBe(0);
+  });
+
+  it("reads a response whose received bytes exceed the character limit but whose text is within it", async () => {
+    const parsed = clickSaving("/api/items");
+    const secrets = resolved(parsed);
+    const { page } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: '{"id":"item-9"}',
+        // Two million three-byte characters, as CJK text spells them in UTF-8.
+        size: 6_000_000,
+      },
+    ]);
+    await perform(page, parsed.actions[0]!, secrets);
+    expect(secrets.savedValue("item")).toBe("item-9");
+  });
+
+  it("fails a response whose text is longer than the character limit", async () => {
+    const parsed = clickSaving("/api/items");
+    const { page, reads } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items`,
+        status: 201,
+        body: `{"id":"${"x".repeat(4_000_000)}"}`,
+      },
+    ]);
+    await expect(
+      perform(page, parsed.actions[0]!, resolved(parsed)),
+    ).rejects.toThrow("too large to save values from");
+    expect(reads.count).toBe(1);
+  });
+});
+
+it.each(["array", "embedded", "headers"])(
+  "refuses repeated large saved JSON values in %s before expansion or browser dispatch",
+  async (kind) => {
+    const parsed = scenario([
+      requestStep({ save: [{ name: "large", json_pointer: "/value" }] }),
+      requestStep({
+        step_id: "expanded",
+        headers:
+          kind === "headers"
+            ? Array.from({ length: 1000 }, (_, i) => ({
+                name: `X-Repeated-${i}`,
+                value: { source: "saved" as const, name: "large" },
+              }))
+            : [],
+        json:
+          kind === "headers"
+            ? undefined
+            : kind === "array"
+              ? Array.from({ length: 1000 }, () => "{saved:large}")
+              : "prefix" + "{saved:large}".repeat(1000),
+      }),
+    ]);
+    const secrets = resolved(parsed);
+    secrets.save("source.large", "large", "x".repeat(3_000_000), false);
+    const page = { ...fakePage({}), evaluate: vi.fn() } as unknown as Page;
+    await expect(perform(page, parsed.actions[1]!, secrets)).rejects.toThrow(
+      "serialization allowance",
+    );
+    expect(page.evaluate).not.toHaveBeenCalled();
+  },
+);
+
+it("refuses repeated saved URL expansion before browser navigation", async () => {
+  const parsed = scenario([
+    requestStep({ save: [{ name: "large", json_pointer: "/value" }] }),
+    {
+      step_id: "open",
+      action: "goto",
+      destination: { url: `${ORIGIN}/` + "{saved:large}".repeat(1000) },
+      wait_until: "load",
+    },
+  ]);
+  const secrets = resolved(parsed);
+  secrets.save("source.large", "large", "x".repeat(3_000_000), false);
+  const goto = vi.fn();
+  await expect(
+    perform({ goto } as unknown as Page, parsed.actions[1]!, secrets),
+  ).rejects.toThrow("serialization allowance");
+  expect(goto).not.toHaveBeenCalled();
+});
+
+it.each([
+  "/api\\items/{*}",
+  "/api/unused/../items/{*}",
+  "/api/unused/%2e%2e/items/{*}",
+])(
+  "normalizes the entire observed response path %s before matching",
+  async (path) => {
+    const parsed = clickSaving(path);
+    const { page } = uiPage([
+      {
+        method: "POST",
+        url: `${ORIGIN}/api/items/42`,
+        status: 201,
+        body: '{"id":"42"}',
+      },
+    ]);
+    const secrets = resolved(parsed);
+    await perform(page, parsed.actions[0]!, secrets);
+    expect(secrets.savedValue("item")).toBe("42");
+  },
+);
+
+it("keeps literal encoded wildcard text distinct from observed-response wildcard segments", async () => {
+  const parsed = clickSaving("/api/%7B*%7D");
+  const { page } = uiPage([
+    {
+      method: "POST",
+      url: `${ORIGIN}/api/other`,
+      status: 201,
+      body: '{"id":"42"}',
+    },
+  ]);
+  await expect(
+    perform(page, parsed.actions[0]!, resolved(parsed)),
+  ).rejects.toThrow("no POST");
+});
+
+it("normalizes wildcard paths without colliding with literal marker-like segments", async () => {
+  const parsed = clickSaving("/~rea-wildcard~/items/{*}");
+  const { page } = uiPage([
+    {
+      method: "POST",
+      url: `${ORIGIN}/~rea-wildcard~/items/42`,
+      status: 201,
+      body: '{"id":"42"}',
+    },
+  ]);
+  const secrets = resolved(parsed);
+  await perform(page, parsed.actions[0]!, secrets);
+  expect(secrets.savedValue("item")).toBe("42");
+});
+
+it("redacts a saved secret as a parsed URL query spells it", () => {
+  const secrets = resolved(scenario([requestStep({})]));
+  secrets.save("create.token", "token", "ab'cd~ef", true);
+  const sent = new URL(
+    `${ORIGIN}/api/items?token=${encodeURIComponent("ab'cd~ef")}`,
+  ).href;
+  expect(sent).toContain("ab%27cd~ef");
+  expect(secrets.redact(sent)).toBe(
+    `${ORIGIN}/api/items?token=[REDACTED:page:create.token]`,
+  );
+});
diff --git a/src/browser/PlaywrightScenarioRequest.ts b/src/browser/PlaywrightScenarioRequest.ts
index 5d3f291260..615af4acca 100644
--- a/src/browser/PlaywrightScenarioRequest.ts
+++ b/src/browser/PlaywrightScenarioRequest.ts
@@ -1,9 +1,27 @@
-import type { Page, Route } from "playwright-core";
+import { constants as bufferConstants } from "node:buffer";
+import { getHeapStatistics } from "node:v8";
+import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
+import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
+import type { JsonValue } from "../domain/jsonValue.js";
+import {
+  MAX_SAVED_RESPONSE_CHARACTERS,
+  MAX_SAVED_RESPONSE_BYTES,
+  responsePointerValue,
+  parseScenarioValueJson,
+  readResponseValues,
+  readBoundedResponseBody,
+} from "./BrowserScenarioResponseValues.js";
+import type { Page, Request, Route } from "playwright-core";
 
 import {
   HEADER_FIELD_VALUE,
+  SAVED_PLACEHOLDER,
+  validBasicCredentialUsername,
   type BrowserScenarioAction,
+  type BrowserScenarioCredentialPart,
   type BrowserScenarioRequestHeader,
+  type BrowserScenarioSaveEntry,
+  type BrowserScenarioSaveResponse,
 } from "../domain/browserScenarioValues.js";
 import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
 
@@ -11,7 +29,7 @@ type RequestAction = Extract;
 
 /** What the page's fetch returned; a redirect is not followed, so its status is unknown. */
 type PageResponse =
-  | { readonly status: number }
+  | { readonly status: number; readonly body?: string }
   | { readonly redirected: true }
   | { readonly left: string }
   | { readonly error: string };
@@ -29,41 +47,23 @@ const originOf = (url: string): string | undefined => {
   }
 };
 
-/** The string or number at a JSON pointer, as text. */
-const pointerText = (value: unknown, pointer: string): string | undefined => {
-  let current = value;
-  if (pointer !== "")
-    for (const token of pointer.slice(1).split("/")) {
-      const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
-      if (Array.isArray(current) && /^(?:0|[1-9][0-9]*)$/u.test(key))
-        current = current[Number(key)];
-      else if (
-        typeof current === "object" &&
-        current !== null &&
-        Object.hasOwn(current, key)
-      )
-        current = (current as Record)[key];
-      else return undefined;
-    }
-  return typeof current === "string" || typeof current === "number"
-    ? String(current)
-    : undefined;
-};
+interface PageValueStep {
+  readonly page: Page;
+  readonly url: URL;
+  readonly stepId: string;
+  readonly secrets: BrowserScenarioSecrets;
+}
 
-/** Resolve one header value; values read from the page are redacted from later records. */
-const headerValue = async (
-  step: {
-    readonly page: Page;
-    readonly url: URL;
-    readonly stepId: string;
-    readonly secrets: BrowserScenarioSecrets;
-  },
-  header: BrowserScenarioRequestHeader,
+/** Read a value from the page, a cookie or a local storage entry, and redact it from later records under a label. */
+const readPageValue = async (
+  step: PageValueStep,
+  value: Extract<
+    BrowserScenarioCredentialPart,
+    { source: "cookie" | "local_storage" }
+  >,
+  label: string,
 ): Promise => {
   const { page, url, stepId, secrets } = step;
-  const { value } = header;
-  if (value.source === "literal" || value.source === "secret")
-    return secrets.value(value);
   let read: string | undefined;
   if (value.source === "cookie") {
     const cookies = await page.context().cookies(url.href);
@@ -98,25 +98,68 @@ const headerValue = async (
       );
     if (value.json_pointer === undefined) read = stored;
     else {
-      let parsed: unknown;
-      try {
-        parsed = JSON.parse(stored);
-      } catch {
+      const parsed = parseScenarioValueJson(stored);
+      if (!parsed.ok) {
         throw new ScenarioRequestError(
           `Request step ${stepId}: local storage ${value.key} is not JSON.`,
         );
       }
-      read = pointerText(parsed, value.json_pointer);
+      const selected = responsePointerValue(parsed.value, value.json_pointer);
+      if (typeof selected === "number" && !Number.isFinite(selected))
+        throw new ScenarioRequestError(
+          `Request step ${stepId}: local storage ${value.key} has a number at ${value.json_pointer} that cannot be read exactly.`,
+        );
+      read =
+        typeof selected === "string" || typeof selected === "number"
+          ? String(selected)
+          : undefined;
       if (read === undefined)
         throw new ScenarioRequestError(
           `Request step ${stepId}: local storage ${value.key} has no string or number at ${value.json_pointer}.`,
         );
     }
   }
-  secrets.admitPageValue(`${stepId}.${header.name}`, read);
+  secrets.admitPageValue(label, read);
   return read;
 };
 
+/** One part of Basic credentials: a literal, secret or saved value, or a value read from the page. */
+const credentialPart = (
+  step: PageValueStep,
+  part: BrowserScenarioCredentialPart,
+  label: string,
+): Promise | string =>
+  part.source === "cookie" || part.source === "local_storage"
+    ? readPageValue(step, part, label)
+    : step.secrets.value(part);
+
+/** Resolve one header value; values read from the page, and credentials built from them, are redacted from later records. */
+const headerValue = async (
+  step: PageValueStep,
+  header: BrowserScenarioRequestHeader,
+): Promise => {
+  const { stepId, secrets } = step;
+  const { value } = header;
+  const label = `${stepId}.${header.name}`;
+  if (
+    value.source === "literal" ||
+    value.source === "secret" ||
+    value.source === "saved"
+  )
+    return secrets.value(value);
+  if (value.source === "basic_credentials") {
+    const [username, password] = value.parts;
+    const user = await credentialPart(step, username!, `${label}.username`);
+    if (!validBasicCredentialUsername(user))
+      throw new ScenarioRequestError(
+        `Request step ${stepId} has a Basic username with a colon, which the server would read as the end of the username.`,
+      );
+    const pass = await credentialPart(step, password!, `${label}.password`);
+    return secrets.basicCredential(label, user, pass);
+  }
+  return readPageValue(step, value, label);
+};
+
 /** Append declared query entries after any literal query the path carries, leaving that query as written. */
 const withQuery = (
   url: URL,
@@ -148,6 +191,8 @@ interface PageRequest {
   readonly headers: [string, string][];
   readonly body: string | undefined;
   readonly deadline: number | undefined;
+  /** Return the response text up to this many characters, for a step that saves values from it. */
+  readonly readLimit: number | undefined;
 }
 
 /** Send the request with the page's own fetch, from the page's main world, and read the whole answer. */
@@ -191,6 +236,31 @@ const sendFromPage = (
         signal: controller.signal,
         ...(request.body === undefined ? {} : { body: request.body }),
       });
+      if (answer.type === "opaqueredirect") return { redirected: true };
+      if (request.readLimit !== undefined) {
+        // Keep the text only up to the limit, so a large answer is never held whole.
+        const reader = answer.body?.getReader();
+        if (reader === undefined) return { status: answer.status, body: "" };
+        const decoder = new TextDecoder("utf-8", {
+          fatal: true,
+          ignoreBOM: true,
+        });
+        let text = "";
+        try {
+          for (;;) {
+            const chunk = await reader.read();
+            if (chunk.done) break;
+            text += decoder.decode(chunk.value, { stream: true });
+            if (text.length > request.readLimit) {
+              await reader.cancel();
+              return { status: answer.status };
+            }
+          }
+        } finally {
+          reader.releaseLock();
+        }
+        return { status: answer.status, body: text + decoder.decode() };
+      }
       const reader = answer.body?.getReader();
       if (reader !== undefined)
         try {
@@ -200,10 +270,9 @@ const sendFromPage = (
         } finally {
           reader.releaseLock();
         }
-      return answer.type === "opaqueredirect"
-        ? { redirected: true }
-        : { status: answer.status };
+      return { status: answer.status };
     } catch (cause: unknown) {
+      controller.abort();
       return {
         error: cause instanceof Error ? cause.message : String(cause),
       };
@@ -234,7 +303,7 @@ export const performScenarioRequest = async (input: {
     throw new ScenarioRequestError(
       `Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; navigate back to it with a goto step first.`,
     );
-  const url = new URL(action.path, startOrigin);
+  const url = new URL(withSavedPlaceholders(action.path, secrets), startOrigin);
   if (url.origin !== startOrigin)
     throw new ScenarioRequestError(
       `Request step ${action.step_id} must stay on the start origin ${startOrigin}.`,
@@ -245,45 +314,100 @@ export const performScenarioRequest = async (input: {
   );
   const step = { page, url, stepId: action.step_id, secrets };
   const headers: [string, string][] = [];
-  for (const header of action.headers) {
-    const value = `${header.prefix ?? ""}${await headerValue(step, header)}`;
-    if (!HEADER_FIELD_VALUE.test(value))
-      throw new ScenarioRequestError(
-        `Request step ${action.step_id}: the ${header.name} value holds characters a header cannot carry, such as a line break.`,
-      );
-    headers.push([header.name, value]);
-  }
+  const envelopeLimit = requestSerializationAllowance();
+  let headerBytes = 2;
+  const headerValues = new Map();
+  const appendHeader = (name: string, value: string): void => {
+    const knownSize = headerValues.get(value);
+    const size =
+      knownSize ?? jsonStringByteLength(value, envelopeLimit - headerBytes);
+    headerBytes +=
+      (headers.length === 0 ? 0 : 1) +
+      jsonValueByteLength([name, null], envelopeLimit - headerBytes) -
+      4 +
+      size;
+    if (headerBytes > envelopeLimit) throw requestEnvelopeError();
+    if (knownSize === undefined) {
+      if (!HEADER_FIELD_VALUE.test(value))
+        throw new ScenarioRequestError(
+          `Request step ${action.step_id}: the ${name} value holds characters a header cannot carry, such as a line break.`,
+        );
+      headerValues.set(value, size);
+    }
+    headers.push([name, value]);
+  };
+  for (const header of action.headers)
+    appendHeader(
+      header.name,
+      `${header.prefix ?? ""}${await headerValue(step, header)}`,
+    );
   const hasContentType = headers.some(
     ([name]) => name.toLowerCase() === "content-type",
   );
   if (action.json !== undefined && !hasContentType)
-    headers.push(["content-type", "application/json"]);
+    appendHeader("content-type", "application/json");
   // A step the boundary abandoned, or whose time ran out while values were read, sends nothing.
   const remaining = deadline === undefined ? undefined : deadline - Date.now();
   if (signal?.aborted === true || (remaining !== undefined && remaining <= 0))
     throw new ScenarioRequestError(
       `Request step ${action.step_id} ran out of time before its request was sent.`,
     );
+  if (action.json !== undefined) admitSavedJson(action.json, secrets);
+  const json =
+    action.json === undefined ? undefined : withSavedJson(action.json, secrets);
   const request = {
     url: target,
     origin: startOrigin,
     method: action.method,
     headers,
-    body: action.json === undefined ? undefined : JSON.stringify(action.json),
+    body: json === undefined ? undefined : JSON.stringify(json),
     deadline,
+    readLimit:
+      action.save.length > 0 ? MAX_SAVED_RESPONSE_CHARACTERS : undefined,
   };
+  if (
+    jsonValueByteLength(
+      {
+        url: target,
+        origin: startOrigin,
+        method: action.method,
+        headers: [],
+        body: request.body ?? null,
+        deadline: deadline ?? null,
+        readLimit: request.readLimit ?? null,
+      },
+      envelopeLimit,
+    ) +
+      headerBytes -
+      2 >
+    envelopeLimit
+  )
+    throw requestEnvelopeError();
   // The browser can queue evaluate behind a busy page. Keep a host-side gate
   // until that evaluation settles, including after the step boundary stops
   // waiting. A queued fetch then observes live cancellation at dispatch.
   const dispatchUrl = new URL(target);
   dispatchUrl.hash = "";
   const matches = (url: URL): boolean => url.href === dispatchUrl.href;
+  const protectResponse = secrets.responseProtector(
+    action.step_id,
+    action.save,
+  );
+  const matchesRequest = (sent: Request): boolean =>
+    sent.method() === request.method &&
+    sent.postData() === (request.body ?? null);
+  let observedRequest: Request | undefined;
+  let ambiguousRequest = false;
+  const watch = (sent: Request): void => {
+    if (sent.url() === dispatchUrl.href && matchesRequest(sent)) {
+      if (observedRequest === undefined) observedRequest = sent;
+      else if (observedRequest !== sent) ambiguousRequest = true;
+      protectResponse?.(sent);
+    }
+  };
   const gate = async (route: Route): Promise => {
     const sent = route.request();
-    if (
-      sent.method() !== request.method ||
-      sent.postData() !== (request.body ?? null)
-    ) {
+    if (!matchesRequest(sent)) {
       await route.fallback();
       return;
     }
@@ -292,32 +416,393 @@ export const performScenarioRequest = async (input: {
       (deadline !== undefined && Date.now() >= deadline)
     )
       await route.abort("aborted");
-    else await route.fallback();
+    else {
+      await route.fallback();
+    }
   };
   await page.route(matches, gate);
+  if (protectResponse !== undefined) page.on("request", watch);
+  let primaryFailure: { readonly cause: unknown } | undefined;
   let response: PageResponse;
   try {
     response = await sendFromPage(page, request);
+    if ("error" in response)
+      throw new ScenarioRequestError(
+        `Request step ${action.step_id} failed in the page: ${response.error}`,
+      );
+    if ("left" in response)
+      throw new ScenarioRequestError(
+        `Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; the page moved before the request was sent.`,
+      );
+    if ("redirected" in response)
+      throw new ScenarioRequestError(
+        `Request step ${action.step_id} was answered with a redirect, which request steps do not follow; a write may already have been applied. The capture records the redirect and, with header values selected, its Location.`,
+      );
+    if (
+      action.expected_status !== undefined &&
+      !action.expected_status.includes(response.status)
+    )
+      throw new ScenarioRequestError(
+        `Request step ${action.step_id} answered ${String(response.status)}; expected ${action.expected_status.join(" or ")}.`,
+      );
+  } catch (cause: unknown) {
+    primaryFailure = { cause };
+    throw cause;
   } finally {
-    await page.unroute(matches, gate);
+    if (protectResponse !== undefined) page.off("request", watch);
+    try {
+      await page.unroute(matches, gate);
+    } catch (cause: unknown) {
+      if (primaryFailure !== undefined)
+        throw new AggregateError(
+          [primaryFailure.cause, cause],
+          `Scenario request failed: ${primaryFailure.cause instanceof Error ? primaryFailure.cause.message : String(primaryFailure.cause)}; route cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`,
+          { cause: primaryFailure.cause },
+        );
+      throw cause;
+    }
+  }
+  if (action.save.length > 0) {
+    saveValues(action.step_id, response.body, action.save, secrets);
+    if (observedRequest !== undefined && !ambiguousRequest)
+      secrets.markResponseSecretsReady(observedRequest);
+  }
+};
+
+/** A URL path segment a URL parser drops or folds into its parent: `.`, `..` or a percent-encoded spelling of one. */
+const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/iu;
+
+/** A path or URL with its `placeholder`s filled, each value percent-encoded; a request path's are spelled as written. A filled path segment a URL path would drop as a dot segment fails the step. */
+export const withSavedPlaceholders = (
+  text: string,
+  secrets: BrowserScenarioSecrets,
+  placeholder: RegExp = SAVED_PLACEHOLDER,
+): string => {
+  const limit = requestSerializationAllowance();
+  let expandedUnits = text.length;
+  if (expandedUnits > limit)
+    throw new ScenarioRequestError(
+      "Saved URL exceeds the current heap/string serialization allowance.",
+    );
+  const encoded = new Map();
+  const fill = (part: string): string =>
+    part.replace(placeholder, (matched, name: string) => {
+      let replacement = encoded.get(name);
+      if (replacement === undefined) {
+        const raw = String(secrets.savedValue(name));
+        // A UTF-16 unit needs at most nine ASCII characters under URI encoding.
+        if (raw.length * 9 > limit)
+          throw new ScenarioRequestError(
+            "Saved URL encoding exceeds the current heap/string serialization allowance.",
+          );
+        replacement = encodeURIComponent(raw);
+        encoded.set(name, replacement);
+      }
+      expandedUnits += replacement.length - matched.length;
+      if (expandedUnits > limit)
+        throw new ScenarioRequestError(
+          "Expanded saved URL exceeds the current heap/string serialization allowance.",
+        );
+      return replacement;
+    });
+  const pathEnd = text.search(/[?#]/u);
+  const path = pathEnd < 0 ? text : text.slice(0, pathEnd);
+  // WHATWG special URLs treat literal backslashes as path separators too.
+  const filledPath = path.replace(/[^/\\]+/gu, (segment) => {
+    const filled = fill(segment);
+    if (filled !== segment && DOT_SEGMENT.test(filled))
+      throw new Error(
+        `A saved value makes the path segment ${JSON.stringify(segment)} a dot segment, which a URL path drops, so the step cannot send it.`,
+      );
+    return filled;
+  });
+  return filledPath + (pathEnd < 0 ? "" : fill(text.slice(pathEnd)));
+};
+
+/** Whether a request a UI step triggered is the one its `save_response` names: method, start origin, and path with `{*}` matching one segment. */
+/** A `save_response` path's segments as a browser spells a URL path, percent-encoded as its URL parser would, `{*}` kept. */
+const pathPattern = (path: string): readonly string[] => {
+  const pathname = path.split("?")[0]!;
+  let wildcard = "~rea-wildcard~";
+  while (pathname.includes(wildcard)) wildcard += "~";
+  const marked = pathname
+    .split(/[\\/]/u)
+    .map((segment) => (segment === "{*}" ? wildcard : segment))
+    .join("/");
+  return new URL(marked, "http://path.invalid").pathname
+    .split("/")
+    .map((segment) => (segment === wildcard ? "{*}" : segment));
+};
+
+const namesResponse = (
+  spec: BrowserScenarioSaveResponse,
+  pattern: readonly string[],
+  startOrigin: string,
+  request: { readonly method: string; readonly url: string },
+): boolean => {
+  if (request.method !== spec.method) return false;
+  const url = URL.parse(request.url);
+  if (url === null || url.origin !== startOrigin) return false;
+  const segments = url.pathname.split("/");
+  return (
+    segments.length === pattern.length &&
+    pattern.every((part, index) => part === "{*}" || part === segments[index])
+  );
+};
+
+/** Settle as the work does, or fail the step once its deadline passes or it is cancelled, so late work saves nothing. */
+const beforeStepEnds = (
+  work: Promise,
+  stepId: string,
+  deadline: number | undefined,
+  signal: AbortSignal | undefined,
+): Promise =>
+  new Promise((resolve, reject) => {
+    let timer: ReturnType | undefined;
+    const settle = (): void => {
+      if (timer !== undefined) clearTimeout(timer);
+      signal?.removeEventListener("abort", end);
+    };
+    const end = (): void => {
+      settle();
+      reject(
+        new ScenarioRequestError(
+          `Step ${stepId} ended before its saved response was read, so nothing was saved.`,
+        ),
+      );
+    };
+    work.catch(() => undefined);
+    const remaining =
+      deadline === undefined ? undefined : deadline - Date.now();
+    if (
+      signal?.aborted === true ||
+      (remaining !== undefined && remaining <= 0)
+    ) {
+      end();
+      return;
+    }
+    if (remaining !== undefined) timer = setTimeout(end, remaining);
+    signal?.addEventListener("abort", end, { once: true });
+    work.then(
+      (value) => {
+        settle();
+        resolve(value);
+      },
+      (cause: unknown) => {
+        settle();
+        reject(cause);
+      },
+    );
+  });
+
+/** A UI step's wait for the response its `save_response` names: `saved` waits and saves, `stop` stops watching requests. */
+export interface SavedResponseWait {
+  readonly saved: () => Promise;
+  readonly stop: () => void;
+}
+
+/**
+ * Observe a new matching response while the UI step waits; matching does not prove action causality.
+ * Requests already running are excluded. Competing requests observed before binding are ambiguous.
+ * The step still requires a 2xx JSON response within its timeout or the page's default timeout.
+ */
+export const awaitSavedResponse = (
+  page: Page,
+  step: {
+    readonly stepId: string;
+    readonly spec: BrowserScenarioSaveResponse;
+    readonly startOrigin: string;
+    readonly timeout: number | undefined;
+    readonly secrets: BrowserScenarioSecrets;
+    readonly signal?: AbortSignal | undefined;
+  },
+): SavedResponseWait => {
+  const { stepId, spec, startOrigin, timeout, secrets, signal } = step;
+  const deadline = timeout === undefined ? undefined : Date.now() + timeout;
+  const pattern = pathPattern(spec.path);
+  const started = new Set();
+  const protectResponse = secrets.responseProtector(stepId, spec.save);
+  const watch = (request: Request): void => {
+    if (
+      namesResponse(spec, pattern, startOrigin, {
+        method: request.method(),
+        url: request.url(),
+      })
+    ) {
+      if (started.size < 2) started.add(request);
+      protectResponse?.(request);
+    }
+  };
+  page.on("request", watch);
+  const waiting = page.waitForResponse(
+    (response) => started.has(response.request()),
+    timeout === undefined ? {} : { timeout },
+  );
+  // The step may fail before it waits; the rejection is reported by the wait, or not at all.
+  waiting.catch(() => undefined);
+  const saved = async (): Promise => {
+    let response: Awaited;
+    try {
+      response = await beforeStepEnds(waiting, stepId, deadline, signal);
+    } catch {
+      throw new ScenarioRequestError(
+        `Step ${stepId}: no ${spec.method} ${spec.path} response arrived before the step's timeout, so nothing was saved.`,
+      );
+    }
+    const assertUnambiguous = (): void => {
+      if (started.size !== 1)
+        throw new ScenarioRequestError(
+          `Step ${stepId}: multiple matching requests make the response ambiguous, so nothing was saved. Narrow the response path or record without competing background requests.`,
+        );
+    };
+    assertUnambiguous();
+    if (response.status() < 200 || response.status() > 299)
+      throw new ScenarioRequestError(
+        `Step ${stepId}: the ${spec.method} ${spec.path} response answered ${String(response.status())}; values are saved only from a 2xx response.`,
+      );
+    const body = await beforeStepEnds(
+      readBoundedResponseBody(response, MAX_SAVED_RESPONSE_BYTES),
+      stepId,
+      deadline,
+      signal,
+    );
+    if (!body.ok)
+      throw new ScenarioRequestError(`Step ${stepId}: ${body.error.message}`);
+    assertUnambiguous();
+    saveValues(stepId, body.value, spec.save, secrets);
+    secrets.markResponseSecretsReady(response.request());
+  };
+  return { saved, stop: () => page.off("request", watch) };
+};
+
+const requestEnvelopeError = (): ScenarioRequestError =>
+  new ScenarioRequestError(
+    "Expanded request envelope exceeds the current heap/string serialization allowance; reduce repeated values or raise the parent Node heap and restart.",
+  );
+
+/** Allow for the expanded tree, stringify, Playwright transport and browser-side copies. */
+const REQUEST_JSON_ALLOCATION_FACTOR = 16;
+const requestSerializationAllowance = (): number =>
+  Math.min(
+    bufferConstants.MAX_STRING_LENGTH,
+    Math.floor(
+      getHeapStatistics().total_available_size / REQUEST_JSON_ALLOCATION_FACTOR,
+    ),
+  );
+
+/** Count serialization expansion without allocating substituted strings or their serialized copies. */
+const admitSavedJson = (
+  value: JsonValue,
+  secrets: BrowserScenarioSecrets,
+): void => {
+  const limit = requestSerializationAllowance();
+  let bytes = 0;
+  const savedSizes = new Map();
+  const charge = (size: number): void => {
+    bytes += size;
+    if (bytes > limit)
+      throw new ScenarioRequestError(
+        `Expanded request JSON exceeds its ${String(limit)}-byte serialization allowance, derived from current V8 heap headroom and the string limit. Reduce payload/repeated saved references, or raise the parent Node heap and restart.`,
+      );
+  };
+  const stringPart = (text: string): void =>
+    charge(jsonStringByteLength(text, limit - bytes + 2) - 2);
+  const visit = (item: JsonValue): void => {
+    if (typeof item === "string") {
+      const first = item.matchAll(SAVED_PLACEHOLDER).next().value;
+      if (first !== undefined && first[0] === item) {
+        const saved = secrets.savedValue(first[1]!);
+        if (typeof saved === "number") {
+          charge(jsonValueByteLength(saved));
+          return;
+        }
+      }
+      charge(2);
+      let offset = 0;
+      for (const match of item.matchAll(SAVED_PLACEHOLDER)) {
+        stringPart(item.slice(offset, match.index));
+        const name = match[1]!;
+        let size = savedSizes.get(name);
+        if (size === undefined) {
+          size =
+            jsonStringByteLength(
+              String(secrets.savedValue(name)),
+              limit - bytes + 2,
+            ) - 2;
+          savedSizes.set(name, size);
+        }
+        charge(size);
+        offset = match.index + match[0].length;
+      }
+      stringPart(item.slice(offset));
+    } else if (Array.isArray(item)) {
+      charge(2 + Math.max(0, item.length - 1));
+      for (const child of item) visit(child);
+    } else if (item !== null && typeof item === "object") {
+      charge(2);
+      let count = 0;
+      for (const key in item) {
+        if (!Object.hasOwn(item, key)) continue;
+        charge(
+          (count++ === 0 ? 0 : 1) +
+            jsonStringByteLength(key, limit - bytes) +
+            1,
+        );
+        visit(item[key]!);
+      }
+    } else charge(jsonValueByteLength(item));
+  };
+  visit(value);
+};
+
+/**
+ * The step's JSON body with its `{saved:name}` placeholders filled: a string that is only a placeholder becomes the
+ * saved value, keeping its JSON type, and a placeholder inside a longer string is filled as text.
+ */
+const withSavedJson = (
+  value: unknown,
+  secrets: BrowserScenarioSecrets,
+): unknown => {
+  if (typeof value === "string") {
+    const [whole] = [...value.matchAll(SAVED_PLACEHOLDER)];
+    if (whole !== undefined && whole[0] === value)
+      return secrets.savedValue(whole[1]!);
+    return value.replace(SAVED_PLACEHOLDER, (_placeholder, name: string) =>
+      String(secrets.savedValue(name)),
+    );
+  }
+  if (Array.isArray(value))
+    return value.map((item) => withSavedJson(item, secrets));
+  if (value !== null && typeof value === "object")
+    return Object.fromEntries(
+      Object.entries(value).map(([key, item]) => [
+        key,
+        withSavedJson(item, secrets),
+      ]),
+    );
+  return value;
+};
+
+/** Save the values a step names from a JSON response, for later steps. */
+const saveValues = (
+  stepId: string,
+  body: string | Uint8Array | undefined,
+  entries: readonly BrowserScenarioSaveEntry[],
+  secrets: BrowserScenarioSecrets,
+): void => {
+  const selected = readResponseValues(body, entries, "fail");
+  if (!selected.ok)
+    throw new ScenarioRequestError(`Step ${stepId}: ${selected.error.message}`);
+  for (const {
+    entry: { name, secret },
+    value,
+  } of selected.value.values) {
+    try {
+      secrets.save(`${stepId}.${name}`, name, value, secret);
+    } catch (cause) {
+      throw new ScenarioRequestError(
+        `Step ${stepId}: ${cause instanceof Error ? cause.message : String(cause)}`,
+      );
+    }
   }
-  if ("error" in response)
-    throw new ScenarioRequestError(
-      `Request step ${action.step_id} failed in the page: ${response.error}`,
-    );
-  if ("left" in response)
-    throw new ScenarioRequestError(
-      `Request step ${action.step_id} runs from a page on the start origin ${startOrigin}; the page moved before the request was sent.`,
-    );
-  if ("redirected" in response)
-    throw new ScenarioRequestError(
-      `Request step ${action.step_id} was answered with a redirect, which request steps do not follow; a write may already have been applied. The capture records the redirect and, with header values selected, its Location.`,
-    );
-  if (
-    action.expected_status !== undefined &&
-    !action.expected_status.includes(response.status)
-  )
-    throw new ScenarioRequestError(
-      `Request step ${action.step_id} answered ${String(response.status)}; expected ${action.expected_status.join(" or ")}.`,
-    );
 };
diff --git a/src/browser/PlaywrightScenarioServiceWorkers.test.ts b/src/browser/PlaywrightScenarioServiceWorkers.test.ts
new file mode 100644
index 0000000000..ef425f6deb
--- /dev/null
+++ b/src/browser/PlaywrightScenarioServiceWorkers.test.ts
@@ -0,0 +1,100 @@
+import { expect, it, vi } from "vitest";
+
+import { startFakeCdpBrowser } from "../../tests/fixtures/fakeCdpBrowser.js";
+import { CdpConnection } from "./CdpConnection.js";
+import { PlaywrightScenarioServiceWorkers } from "./PlaywrightScenarioServiceWorkers.js";
+
+it.each(["duplicate", "malformed duplicates", "missing"])(
+  "releases an opened browser socket before rejecting service-worker target discovery: %s",
+  async (kind) => {
+    const browser = await startFakeCdpBrowser({
+      commandResult: ({ method }) =>
+        method === "Target.getTargets"
+          ? {
+              targetInfos:
+                kind === "missing"
+                  ? []
+                  : [
+                      {
+                        targetId: "target",
+                        type: "page",
+                        ...(kind === "duplicate"
+                          ? { url: "https://allowed.test/" }
+                          : {}),
+                      },
+                      { targetId: "target", type: "page", url: 42 },
+                    ],
+            }
+          : undefined,
+    });
+    const policy = new PlaywrightScenarioServiceWorkers(
+      {
+        frames: () => [],
+        isClosed: () => false,
+        mainFrame: () => {
+          throw new Error("No frame mutation expected");
+        },
+      },
+      browser.browserWebSocketUrl,
+      "target",
+    );
+    try {
+      await expect(policy.initialize()).rejects.toMatchObject({
+        reason:
+          kind === "missing" ? "target_not_found" : "invalid_endpoint_response",
+        ...(kind === "missing"
+          ? {}
+          : { detail: expect.stringContaining("ambiguous") }),
+      });
+      await expect.poll(() => browser.activeConnectionCount()).toBe(0);
+      await policy.close();
+      expect(browser.commands.map(({ method }) => method)).toEqual([
+        "Browser.getVersion",
+        "Target.getTargets",
+      ]);
+    } finally {
+      await policy.close();
+      await browser.close();
+    }
+  },
+);
+
+it("retains ownership and retries socket cleanup after ambiguous service-worker discovery", async () => {
+  const browser = await startFakeCdpBrowser({
+    commandResult: ({ method }) =>
+      method === "Target.getTargets"
+        ? { targetInfos: [{ targetId: "target" }, { targetId: "target" }] }
+        : undefined,
+  });
+  const policy = new PlaywrightScenarioServiceWorkers(
+    {
+      frames: () => [],
+      isClosed: () => false,
+      mainFrame: () => {
+        throw new Error("No frame mutation expected");
+      },
+    },
+    browser.browserWebSocketUrl,
+    "target",
+  );
+  const cleanupFailure = new Error("socket close failed");
+  const close = vi
+    .spyOn(CdpConnection.prototype, "close")
+    .mockRejectedValueOnce(cleanupFailure);
+  try {
+    await expect(policy.initialize()).rejects.toMatchObject({
+      cleanupIncomplete: true,
+    });
+    await policy.close();
+    expect(close).toHaveBeenCalledTimes(2);
+    await expect.poll(() => browser.activeConnectionCount()).toBe(0);
+    expect(browser.commands.map(({ method }) => method)).toEqual([
+      "Browser.getVersion",
+      "Target.getTargets",
+    ]);
+  } finally {
+    close.mockRestore();
+    await policy.close();
+    await browser.close();
+  }
+});
diff --git a/src/browser/PlaywrightScenarioServiceWorkers.ts b/src/browser/PlaywrightScenarioServiceWorkers.ts
index 79ecf54473..43aa200834 100644
--- a/src/browser/PlaywrightScenarioServiceWorkers.ts
+++ b/src/browser/PlaywrightScenarioServiceWorkers.ts
@@ -12,7 +12,11 @@ import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
 import { browserObservationEndpointSchema } from "../domain/browserObservation.js";
 import { BrowserObservationError } from "../domain/browserObservationError.js";
 import { CdpTargetSessionOwner } from "./CdpTargetSessionOwner.js";
-import { withCdpDiscoveryDeadline } from "./CdpEndpoint.js";
+import {
+  withCdpDiscoveryDeadline,
+  type CdpEndpointTarget,
+} from "./CdpEndpoint.js";
+import { selectCdpDiscoveryTarget } from "./CdpDiscoveryTargets.js";
 import {
   attachCdpTargetSession,
   failCdpOpenAfterCleanup,
@@ -81,7 +85,7 @@ export class PlaywrightScenarioServiceWorkers {
   #setup = new AbortController();
 
   constructor(
-    private readonly page: Page,
+    private readonly page: Pick,
     private readonly endpoint: string,
     private readonly targetId: string,
     private readonly commandTimeoutMs: number = selectedCdpCommandTimeoutMs(
@@ -115,22 +119,26 @@ export class PlaywrightScenarioServiceWorkers {
           this.commandTimeoutMs,
         ),
     );
-    const target = opened.discovery.targets.find(
-      ({ id }) => id === this.targetId,
-    );
-    if (target === undefined) {
-      const failure = new BrowserObservationError(
+    let target: CdpEndpointTarget;
+    try {
+      const selected = selectCdpDiscoveryTarget(
+        opened.discovery.targets,
+        this.targetId,
         OPERATION,
-        "target_not_found",
+        opened.discovery.ambiguousTargetIds,
       );
+      if (selected === undefined)
+        throw new BrowserObservationError(OPERATION, "target_not_found");
+      target = selected;
+    } catch (cause: unknown) {
       if (opened.connection !== undefined)
         return await failCdpOpenAfterCleanup(
           opened.connection,
-          failure,
+          cause,
           OPERATION,
           this.#transportOwner,
         );
-      throw failure;
+      throw cause;
     }
     const transport =
       opened.connection ??
diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts
index 1edac4f48d..3acdfc2ba9 100644
--- a/src/browser/PlaywrightScenarioSession.test.ts
+++ b/src/browser/PlaywrightScenarioSession.test.ts
@@ -1705,3 +1705,48 @@ it("keeps declared secrets out of final cleanup and retry failure diagnostics",
     await provider.close();
   }
 });
+
+it("projects both request and route cleanup failures with declared secrets redacted", async () => {
+  const { page } = fixture();
+  const secret = "private-request-diagnostic-value";
+  const route = vi.fn(async () => undefined);
+  const unroute = vi.fn(async () => {
+    throw new Error(`page.unroute: closed ${secret}`);
+  });
+  Object.assign(page, {
+    url: () => "https://app.example.test/",
+    route,
+    unroute,
+  });
+  const input = browserScenarioSchema.parse({
+    ...connectedScenario("allow"),
+    start_url: { url: "https://app.example.test/" },
+    actions: [
+      { step_id: "request", action: "request", method: "POST", path: "/api" },
+    ],
+    secrets: [
+      { secret_id: "credential", environment_variable: "REQUEST_SECRET" },
+    ],
+    capture: { after_each_step: ["url"], at_end: [], events: [] },
+  });
+  const provider = new PlaywrightBrowserScenarioProvider((scenario, options) =>
+    PlaywrightScenarioSession.open(
+      scenario,
+      { REQUEST_SECRET: secret },
+      options,
+    ),
+  );
+  page.evaluate.mockRejectedValue(new Error(`page.evaluate: closed ${secret}`));
+  try {
+    const result = await provider.captureScenario(input, {});
+    if (!result.ok) throw result.error;
+    const step = result.value.steps.find((step) => step.step_id === "request");
+    expect(step).toMatchObject({ status: "failed" });
+    expect(step?.error).toContain("page.evaluate: closed");
+    expect(step?.error).toContain("page.unroute: closed");
+    expect(JSON.stringify(result.value)).not.toContain(secret);
+    expect(unroute).toHaveBeenCalledOnce();
+  } finally {
+    await provider.close();
+  }
+});
diff --git a/src/browser/WebBundleAnalysisWorker.ts b/src/browser/WebBundleAnalysisWorker.ts
index 218beffa48..b88d0af5c1 100644
--- a/src/browser/WebBundleAnalysisWorker.ts
+++ b/src/browser/WebBundleAnalysisWorker.ts
@@ -1,3 +1,4 @@
+import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
 import { WorkerFrameSignal } from "../process/WorkerFrameSignal.js";
 import { createHash, randomUUID } from "node:crypto";
 import { open, rm } from "node:fs/promises";
@@ -280,7 +281,12 @@ export class WebBundleAnalysisWorker {
       error: AnalysisError,
     ): Result =>
       err({
-        error,
+        error: signal?.aborted
+          ? analysisErrorWithCancellation(
+              error,
+              new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
+            )
+          : error,
         completed,
         ...(continuation === undefined ? {} : { continuation }),
       });
@@ -463,13 +469,20 @@ export class WebBundleAnalysisWorker {
         await rm(join(root, `${id}.result`), { force: true });
       }
     } catch (cause) {
-      return failed(
-        this.#cleanupFailure(cause, result.ok ? undefined : result.error),
-      );
+      const previous = result.ok ? undefined : result.error;
+      const primary = signal?.aborted
+        ? analysisErrorWithCancellation(
+            previous,
+            new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
+          )
+        : previous;
+      return failed(this.#cleanupFailure(cause, primary));
     }
-    if (signal?.aborted && result.ok)
+    if (signal?.aborted)
       return failed(
-        new AnalysisCancelledError(OPERATION, { cause: signal.reason }),
+        result.ok
+          ? new AnalysisCancelledError(OPERATION, { cause: signal.reason })
+          : result.error,
       );
     return result.ok ? result : failed(result.error);
   }
diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts
index b5264265a2..d71f3a9a26 100644
--- a/src/browser/WebSourceMapFetcher.ts
+++ b/src/browser/WebSourceMapFetcher.ts
@@ -17,8 +17,7 @@ import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascri
 import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
 import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
 import {
-  isTypeOnlyExportDeclaration,
-  isTypeOnlyImportDeclaration,
+  semanticStaticModuleSource,
   semanticImportEqualsSpecifier,
 } from "../domain/javascript/javascriptModuleDeclaration.js";
 import { stringValue } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
@@ -1234,18 +1233,9 @@ const originalDependency = (
   readonly specifier: string;
   readonly requireCallee?: t.Identifier;
 } | null => {
-  if (
-    t.isImportDeclaration(node) ||
-    t.isExportAllDeclaration(node) ||
-    t.isExportNamedDeclaration(node)
-  )
-    return node.source === null ||
-      node.source === undefined ||
-      (t.isImportDeclaration(node)
-        ? isTypeOnlyImportDeclaration(node)
-        : isTypeOnlyExportDeclaration(node))
-      ? null
-      : { kind: "static_import", specifier: node.source.value };
+  const moduleSource = semanticStaticModuleSource(node);
+  if (moduleSource !== null)
+    return { kind: "static_import", specifier: moduleSource.value };
   if (t.isImportExpression(node)) {
     const specifier = stringValue(node.source);
     return specifier === undefined
diff --git a/src/browser/assets/PublishWebScripts.ts b/src/browser/assets/PublishWebScripts.ts
index 2a56d64d18..e3cd35988b 100644
--- a/src/browser/assets/PublishWebScripts.ts
+++ b/src/browser/assets/PublishWebScripts.ts
@@ -18,6 +18,7 @@ import {
   type WebScriptExportResult,
 } from "../../domain/webScriptExport.js";
 import {
+  isolatedWebScriptPath,
   planWebScriptExport,
   type PlannedWebScript,
 } from "../../domain/webScriptExportPlan.js";
@@ -212,7 +213,7 @@ const publishPlannedScript = async (
       record.content.layout !== "url-path"
     )
       throw cause;
-    const relativePath = isolatedScriptPath(script, index);
+    const relativePath = isolatedWebScriptPath(script.source, index);
     await write(relativePath);
     return {
       ...record,
@@ -226,11 +227,5 @@ const publishPlannedScript = async (
   }
 };
 
-const isolatedScriptPath = (
-  script: PlannedWebScript["script"],
-  index: number,
-): string =>
-  `isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`;
-
 const message = (cause: unknown): string =>
   cause instanceof Error ? cause.message : "Unknown publication failure";
diff --git a/src/browser/history/SazCapture.ts b/src/browser/history/SazCapture.ts
index d36dc51d91..abb7f7caa2 100644
--- a/src/browser/history/SazCapture.ts
+++ b/src/browser/history/SazCapture.ts
@@ -13,6 +13,20 @@ import { decodeSazHttp } from "./SazHttp.js";
 import { decodeSazMetadata } from "./SazMetadata.js";
 import { projectSazRecord, type SazValue } from "./SazProjection.js";
 
+const SESSION_MEMBER_KINDS = {
+  "c.txt": "request",
+  "s.txt": "response",
+  "m.xml": "metadata",
+  "w.txt": "websocket",
+  "g.txt": "grpc",
+} as const;
+type SessionMemberKind =
+  (typeof SESSION_MEMBER_KINDS)[keyof typeof SESSION_MEMBER_KINDS];
+interface SessionMember {
+  readonly entry: ArtifactEntry;
+  readonly kind: SessionMemberKind;
+}
+
 /** Decode native SAZ sessions without extracting files, running Fiddler or converting to HAR. */
 export const decodeSazCapture = async (
   snapshotPath: string,
@@ -24,7 +38,7 @@ export const decodeSazCapture = async (
     WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
   );
   try {
-    const groups = new Map();
+    const groups = new Map();
     const inventory: SazValue[] = [];
     const paths = new Set();
     let declaredBytes = 0;
@@ -61,12 +75,19 @@ export const decodeSazCapture = async (
         bytes: entry.declaredSize,
       });
       if (entry.kind !== "file") continue;
-      const match = /^raw\/(\d+)_(c\.txt|s\.txt|m\.xml|w\.txt|g\.txt)$/.exec(
-        entry.path,
-      );
-      if (match?.[1] !== undefined) {
+      const match = /^raw\/(\d+)_(.+)$/.exec(entry.path);
+      if (
+        match?.[1] !== undefined &&
+        match[2] !== undefined &&
+        Object.hasOwn(SESSION_MEMBER_KINDS, match[2])
+      ) {
         const group = groups.get(match[1]) ?? [];
-        group.push(entry);
+        group.push({
+          entry,
+          kind: SESSION_MEMBER_KINDS[
+            match[2] as keyof typeof SESSION_MEMBER_KINDS
+          ],
+        });
         groups.set(match[1], group);
       } else if (entry.path.startsWith("raw/")) {
         throw new CaptureFormatError(
@@ -91,7 +112,7 @@ export const decodeSazCapture = async (
         grpc: null,
       };
       const limitations: string[] = [];
-      for (const entry of entries) {
+      for (const { entry, kind } of entries) {
         const stream = await reader.open(entry);
         const chunks: Buffer[] = [];
         let length = 0;
@@ -115,29 +136,24 @@ export const decodeSazCapture = async (
             "SAZ member does not match its declared size.",
           );
         const bytes = Buffer.concat(chunks, length);
-        const http = entry.path.endsWith("_c.txt")
-          ? "request"
-          : entry.path.endsWith("_s.txt")
-            ? "response"
-            : null;
-        if (http !== null) {
+        if (kind === "request" || kind === "response") {
           // Fiddler stores a session without this message as a zero-byte member.
           if (length === 0)
             limitations.push(
-              `The stored ${http} member is empty (zero bytes); the ${http} is reported as null.`,
+              `The stored ${kind} member is empty (zero bytes); the ${kind} is reported as null.`,
             );
           else
-            source[http] = {
+            source[kind] = {
               member: entry.path,
-              ...decodeSazHttp(bytes, http === "request"),
+              ...decodeSazHttp(bytes, kind === "request"),
             };
-        } else if (entry.path.endsWith("_m.xml"))
+        } else if (kind === "metadata")
           source.metadata = {
             member: entry.path,
             document: decodeSazMetadata(bytes),
           };
         else
-          source[entry.path.endsWith("_w.txt") ? "websocket" : "grpc"] = {
+          source[kind] = {
             member: entry.path,
             messages: bytes,
           };
@@ -146,7 +162,7 @@ export const decodeSazCapture = async (
         ordinal: records.length,
         location: {
           kind: "archive-members",
-          members: entries.map((entry) => entry.path),
+          members: entries.map(({ entry }) => entry.path),
         },
         ...projectSazRecord(source, sensitiveValues),
         limitations,
diff --git a/src/browser/network/PlaywrightNetworkContent.test.ts b/src/browser/network/PlaywrightNetworkContent.test.ts
index 6887d49ae4..aba688b179 100644
--- a/src/browser/network/PlaywrightNetworkContent.test.ts
+++ b/src/browser/network/PlaywrightNetworkContent.test.ts
@@ -1,4 +1,4 @@
-import { expect, it } from "vitest";
+import { expect, it, vi } from "vitest";
 import { browserScenarioSchema } from "../../domain/browserScenario.js";
 import { resolveScenarioSecrets } from "../../../tests/fixtures/browserScenarioSecrets.js";
 import {
@@ -76,3 +76,88 @@ it("charges discarded credential header values only for their retained projectio
   });
   expect(await read("X-Retained")).toMatchObject({ state: "unavailable" });
 });
+
+it("redacts later JSON spellings of saved secrets without reserializing unmarked facts", async () => {
+  const held = resolveScenarioSecrets(scenario, { SECRET: "declared-value" });
+  held.save("login.token", "token", "source-token", true);
+  held.save("login.code", "code", 1000, true);
+  const body = '{ "token":"\\u0073ource-token", "code":1e3, "unknown":1e-400 }';
+  const captured = await captureNetworkBody(
+    async () => Buffer.from(body),
+    "response",
+    {
+      headers: () => ({
+        "content-type": "application/problem+json; charset=utf-8",
+      }),
+      headersArray: async () => [],
+    },
+    held,
+    { ...options, maxBodyBytes: 4096 },
+  );
+  if (captured.state !== "captured") throw new Error("Expected captured JSON");
+  const text = Buffer.from(captured.content, "base64").toString();
+  expect(text).not.toMatch(/0073|source-token|1e3/u);
+  expect(text).toContain('"unknown":1e-400');
+  expect(text).toContain('{ "token":');
+  expect(JSON.parse(text)).toMatchObject({
+    token: "[REDACTED:page:login.token]",
+    code: "[REDACTED:page:login.code]",
+  });
+});
+
+it("keeps an empty JSON-labelled response as observed empty bytes", async () => {
+  const captured = await captureNetworkBody(
+    async () => Buffer.alloc(0),
+    "response",
+    {
+      headers: () => ({ "content-type": "application/json" }),
+      headersArray: async () => [],
+    },
+    secrets,
+    options,
+  );
+  expect(captured).toMatchObject({ state: "captured", bytes: 0, content: "" });
+});
+
+it("checks the JSON decoding budget before invoking its decoder", async () => {
+  const held = resolveScenarioSecrets(scenario, { SECRET: "declared-value" });
+  const decode = vi.spyOn(held, "redactJsonBody");
+  const captured = await captureNetworkBody(
+    async () => Buffer.from('{"value":"untouched"}'),
+    "response",
+    {
+      headers: () => ({ "content-type": "application/json" }),
+      headersArray: async () => [],
+    },
+    held,
+    { ...options, maxBodyBytes: 4096, maxBodyAllocationBytes: 1 },
+  );
+  expect(captured).toMatchObject({
+    state: "unavailable",
+    reason: "body-allocation-limit",
+  });
+  expect(decode).not.toHaveBeenCalled();
+});
+
+it.each(['{"token":"private-token-729d"}', 'A","B'])(
+  "withholds JSON when an explicitly declared secret spans tokens: %s",
+  async (secret) => {
+    const held = resolveScenarioSecrets(scenario, { SECRET: secret });
+    const body = secret.startsWith("{") ? secret : '{"x":"A","B":"C"}';
+    const captured = await captureNetworkBody(
+      async () => Buffer.from(body),
+      "request",
+      {
+        headers: () => ({ "content-type": "application/json" }),
+        headersArray: async () => [],
+      },
+      held,
+      { ...options, maxBodyBytes: 4096 },
+    );
+    expect(captured).toMatchObject({
+      state: "unavailable",
+      reason: "body-read-failed",
+    });
+    expect(JSON.stringify(captured)).not.toContain(secret);
+  },
+);
diff --git a/src/browser/network/PlaywrightNetworkContent.ts b/src/browser/network/PlaywrightNetworkContent.ts
index 896e5c325e..216c251fd3 100644
--- a/src/browser/network/PlaywrightNetworkContent.ts
+++ b/src/browser/network/PlaywrightNetworkContent.ts
@@ -1,4 +1,9 @@
+import {
+  isJson,
+  mediaTypeEssence,
+} from "../../domain/analysisView/recordedMediaType.js";
 import { createHash } from "node:crypto";
+import { safeParseJson } from "../../domain/safeJson.js";
 
 import {
   AnalysisCancelledError,
@@ -12,7 +17,10 @@ import type {
   BrowserScenarioEventAdmission,
   BrowserScenarioEventBudgetReason,
 } from "../../domain/browserScenarioCaptureValues.js";
-import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
+import type {
+  BrowserScenarioSecrets,
+  ScenarioRedactionScope,
+} from "../BrowserScenarioSecrets.js";
 import { withPlaywrightExecutionBoundary } from "../PlaywrightExecutionBoundary.js";
 
 /** The producer seam for ordered, duplicate-preserving HTTP headers. */
@@ -23,6 +31,7 @@ export interface NetworkHeadersSource {
 
 /** A bounded read cannot delay browser cleanup indefinitely. */
 export interface NetworkReadOptions {
+  readonly redactionScope?: ScenarioRedactionScope;
   readonly timeoutMs: number;
   readonly maxBodyBytes: number;
   readonly maxBodyAllocationBytes: number;
@@ -42,7 +51,7 @@ export type NetworkBodyPreflight = (input: {
   readonly observedBytes: number;
   readonly allocationBytes: number;
   readonly mediaType: string | undefined;
-}) => BrowserScenarioEventAdmission;
+}) => BrowserScenarioEventAdmission | Promise;
 
 const credentialHeaders = new Set([
   "authorization",
@@ -55,14 +64,15 @@ const errorMessage = (
   cause: unknown,
   secrets: BrowserScenarioSecrets,
   maxMetadataAllocationBytes: number,
+  scope: ScenarioRedactionScope = "current",
 ): string => {
   const message =
     cause instanceof Error && cause.message !== ""
       ? cause.message
       : "Network read failed";
-  return secrets.redactionAllocationCost(message.length) <=
+  return secrets.redactionAllocationCost(message.length, scope) <=
     maxMetadataAllocationBytes
-    ? secrets.redact(message)
+    ? secrets.redact(message, scope)
     : "Network read failed; diagnostic exceeds the allocation budget.";
 };
 
@@ -88,16 +98,19 @@ export const captureNetworkHeaders = async (
       cost +=
         secrets.redactionAllocationCost(
           name.length + (credential ? 0 : value.length),
+          options.redactionScope,
         ) + 128;
       if (cost > options.maxHeaderAllocationBytes)
         return {
           state: "unavailable",
           message: `Headers exceed the ${String(options.maxHeaderAllocationBytes)}-byte allocation budget.`,
         };
-      const safeName = secrets.redact(name);
+      const safeName = secrets.redact(name, options.redactionScope);
       const safeValue = credential
         ? null
-        : secrets.redactBytes(Buffer.from(value)).toString("utf8");
+        : secrets
+            .redactBytes(Buffer.from(value), options.redactionScope)
+            .toString("utf8");
       items.push({
         name: safeName,
         value: safeValue,
@@ -108,7 +121,12 @@ export const captureNetworkHeaders = async (
   } catch (cause: unknown) {
     return {
       state: "unavailable",
-      message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
+      message: errorMessage(
+        cause,
+        secrets,
+        options.maxMetadataAllocationBytes,
+        options.redactionScope,
+      ),
     };
   }
 };
@@ -121,6 +139,7 @@ export const captureNetworkBody = async (
   secrets: BrowserScenarioSecrets,
   options: NetworkReadOptions,
   preflight?: NetworkBodyPreflight,
+  protection?: { readonly request: object; readonly retainBody: boolean },
 ): Promise => {
   try {
     const observed = await withPlaywrightExecutionBoundary(
@@ -140,8 +159,27 @@ export const captureNetworkBody = async (
         limit_bytes: maxBodyBytes,
         observed_bytes: observed.length,
       };
-    const bodyAllocationCost = secrets.redactionAllocationCost(observed.length);
-    const base64AllocationCost = Math.ceil(observed.length / 3) * 4 * 8 + 2;
+    let sourceBody = observed;
+    if (protection !== undefined) {
+      sourceBody = secrets.admitResponseSecrets(
+        protection.request,
+        observed,
+        protection.retainBody
+          ? {
+              maxBytes: options.maxBodyBytes,
+              maxAllocationBytes: options.maxBodyAllocationBytes,
+            }
+          : undefined,
+      );
+      if (!protection.retainBody) return { state: "not_requested" };
+    }
+    const contentType = source.headers()["content-type"];
+    const bodyAllocationCost = secrets.redactionAllocationCost(
+      Math.max(observed.length, sourceBody.length),
+      options.redactionScope,
+    );
+    const base64AllocationCost =
+      Math.ceil(Math.max(observed.length, sourceBody.length) / 3) * 4 * 8 + 2;
     const maxBodyAllocationBytes = options.maxBodyAllocationBytes;
     const observedBodyAllocationBytes = Math.max(
       bodyAllocationCost,
@@ -156,11 +194,12 @@ export const captureNetworkBody = async (
         limit_allocation_bytes: maxBodyAllocationBytes,
         observed_allocation_bytes: observedBodyAllocationBytes,
       };
-    const contentType = source.headers()["content-type"];
     if (
       contentType !== undefined &&
-      secrets.redactionAllocationCost(contentType.length) >
-        options.maxHeaderAllocationBytes
+      secrets.redactionAllocationCost(
+        contentType.length,
+        options.redactionScope,
+      ) > options.maxHeaderAllocationBytes
     )
       return {
         state: "unavailable",
@@ -170,16 +209,44 @@ export const captureNetworkBody = async (
         limit_allocation_bytes: options.maxHeaderAllocationBytes,
         observed_allocation_bytes: secrets.redactionAllocationCost(
           contentType.length,
+          options.redactionScope,
         ),
       };
-    const admission = preflight?.({
+    const jsonBody =
+      protection !== undefined ||
+      (contentType !== undefined && isJson(mediaTypeEssence(contentType)));
+    const admission = await preflight?.({
       observedBytes: observed.length,
       allocationBytes: observedBodyAllocationBytes,
       mediaType: contentType,
     });
     if (admission?.status === "omitted")
       return { state: "omitted", reason: admission.reason };
-    const retained = secrets.redactBytes(observed);
+    if (protection === undefined && jsonBody)
+      sourceBody = secrets.redactJsonBody(
+        observed,
+        {
+          maxBytes: options.maxBodyBytes,
+          maxAllocationBytes: options.maxBodyAllocationBytes,
+        },
+        options.redactionScope,
+      );
+    const retained = secrets.redactBytes(sourceBody, options.redactionScope);
+    if (
+      jsonBody &&
+      !retained.equals(sourceBody) &&
+      !safeParseJson(
+        new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
+          retained,
+        ),
+      ).ok
+    )
+      return {
+        state: "unavailable",
+        reason: "body-read-failed",
+        message:
+          "Whole-body secret matching changed JSON syntax; the body was withheld.",
+      };
     if (retained.length > maxBodyBytes)
       return {
         state: "unavailable",
@@ -202,7 +269,9 @@ export const captureNetworkBody = async (
       media_type:
         contentType === undefined
           ? null
-          : secrets.redactBytes(Buffer.from(contentType)).toString("utf8"),
+          : secrets
+              .redactBytes(Buffer.from(contentType), options.redactionScope)
+              .toString("utf8"),
       redacted: !retained.equals(observed),
     };
   } catch (cause: unknown) {
@@ -214,7 +283,12 @@ export const captureNetworkBody = async (
           : cause instanceof AnalysisTimeoutError
             ? "body-read-timeout"
             : "body-read-failed",
-      message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
+      message: errorMessage(
+        cause,
+        secrets,
+        options.maxMetadataAllocationBytes,
+        options.redactionScope,
+      ),
     };
   }
 };
diff --git a/src/browser/network/PlaywrightScenarioNetwork.test.ts b/src/browser/network/PlaywrightScenarioNetwork.test.ts
index d74a9a14e5..c78161bd0f 100644
--- a/src/browser/network/PlaywrightScenarioNetwork.test.ts
+++ b/src/browser/network/PlaywrightScenarioNetwork.test.ts
@@ -18,6 +18,7 @@ class RequestFixture implements ScenarioNetworkRequest {
   headerReads = 0;
   bodyReads = 0;
   predecessor: ScenarioNetworkRequest | null = null;
+  responseSize = 64;
   constructor(readonly marker: string) {}
   method() {
     return "POST";
@@ -28,6 +29,9 @@ class RequestFixture implements ScenarioNetworkRequest {
   resourceType() {
     return "fetch";
   }
+  async sizes() {
+    return { responseBodySize: this.responseSize };
+  }
   headers(): Record {
     return {
       "content-type": "text/plain",
@@ -72,7 +76,7 @@ class ResponseFixture implements ScenarioNetworkResponse {
   status() {
     return 200;
   }
-  headers() {
+  headers(): Record {
     return {
       "content-type": "application/octet-stream",
       "set-cookie": "session=transport-secret",
@@ -170,6 +174,185 @@ const bodyText = (event: BrowserScenarioEvent) => {
 
 afterEach(() => vi.useRealTimers());
 
+it.each([true, false])(
+  "protects source body and headers before the binding is saved (body selected: %s)",
+  async (responseBody) => {
+    const { collector, events, secrets } = harness({
+      request_body: false,
+      response_body: responseBody,
+      header_values: true,
+    });
+    const request = new RequestFixture("marked");
+    const secret = "source-token-729d";
+    const response = new ResponseFixture(
+      request,
+      JSON.stringify({ token: secret, public: "kept" }),
+    );
+    vi.spyOn(response, "headers").mockReturnValue({
+      "content-type": "application/json",
+      "x-derived-token": secret,
+    });
+    vi.spyOn(response, "headersArray").mockResolvedValue([
+      { name: "X-Derived-Token", value: secret },
+    ]);
+    secrets.responseProtector("login", [
+      { name: "token", json_pointer: "/token", secret: true },
+    ])?.(request);
+    collector.request(request);
+    collector.response(response);
+    collector.finished(request);
+    await collector.finish();
+    const content = events.find(
+      (event) => event.kind === "network-content" && event.phase === "response",
+    );
+    if (content?.kind !== "network-content")
+      throw new Error("Missing response content");
+    expect(JSON.stringify(content.headers)).not.toContain(secret);
+    expect(content.headers.state).toBe("captured");
+    if (responseBody)
+      expect(JSON.parse(bodyText(content))).toEqual({
+        token: "[REDACTED:page:login.token]",
+        public: "kept",
+      });
+    else expect(content.body.state).toBe("not_requested");
+    expect(() => secrets.savedValue("token")).toThrow("No earlier step saved");
+    secrets.save("login.token", "token", secret, true);
+    expect(secrets.redact(secret)).toBe("[REDACTED:page:login.token]");
+    expect(response.bodyReads).toBe(1);
+  },
+);
+
+it.each([
+  ['{"token":"\\u0073ecret-token"}', "/token"],
+  ['{"token":"secret\\/token"}', "/token"],
+  ['{"token":1e3}', "/token"],
+  ['{"token":1000.0}', "/token"],
+  ['{"items":[{"a/b":"\\u0073ecret-token"}]}', "/items/0/a~1b"],
+  ['{"token":"shadowed","token":"\\u0073ecret-token"}', "/token"],
+])(
+  "redacts the selected source JSON token structurally: %s",
+  async (body, pointer) => {
+    const { collector, events, secrets } = harness({
+      request_body: false,
+      response_body: true,
+      header_values: false,
+    });
+    const request = new RequestFixture("marked");
+    request.responseSize = Buffer.byteLength(body);
+    secrets.responseProtector("login", [
+      { name: "token", json_pointer: pointer, secret: true },
+    ])?.(request);
+    collector.request(request);
+    collector.response(new ResponseFixture(request, body));
+    collector.finished(request);
+    await collector.finish();
+    const content = events.find(
+      (event) => event.kind === "network-content" && event.phase === "response",
+    );
+    expect(content).toBeDefined();
+    const text = bodyText(content!);
+    expect(text).not.toMatch(/secret|0073|shadowed|1e3|1000/u);
+    expect(JSON.parse(text)).toBeDefined();
+    expect(text).toContain("[REDACTED:page:login.token]");
+  },
+);
+
+it("preserves unselected source spellings while redacting escaped aliases", async () => {
+  const { collector, events, secrets } = harness({
+    request_body: false,
+    response_body: true,
+    header_values: false,
+  });
+  const request = new RequestFixture("marked");
+  const body =
+    '{ "token":"secret-token", "mirror":"\\u0073ecret-token", "unknown":1e-400 }';
+  request.responseSize = Buffer.byteLength(body);
+  secrets.responseProtector("login", [
+    { name: "token", json_pointer: "/token", secret: true },
+  ])?.(request);
+  collector.request(request);
+  collector.response(new ResponseFixture(request, body));
+  collector.finished(request);
+  await collector.finish();
+  const content = events.find(
+    (event) => event.kind === "network-content" && event.phase === "response",
+  );
+  const text = bodyText(content!);
+  expect(text).toContain('"unknown":1e-400');
+  expect(text).toContain('{ "token":');
+  expect(text).not.toMatch(/secret|0073/u);
+  expect(JSON.parse(text).mirror).toBe("[REDACTED:page:login.token]");
+});
+
+it("associates sensitive fields by request identity while keeping same-URL unmarked values", async () => {
+  const { collector, events, secrets } = harness({
+    request_body: false,
+    response_body: true,
+    header_values: false,
+  });
+  const marked = new RequestFixture("marked");
+  const unmarked = new RequestFixture("unmarked");
+  secrets.responseProtector("login", [
+    { name: "token", json_pointer: "/token", secret: true },
+  ])?.(marked);
+  for (const [request, token] of [
+    [marked, "private-token-729d"],
+    [unmarked, "public-unmarked-token"],
+  ] as const) {
+    collector.request(request);
+    collector.response(new ResponseFixture(request, JSON.stringify({ token })));
+    collector.finished(request);
+  }
+  await collector.finish();
+  const bodies = events
+    .filter(
+      (event) => event.kind === "network-content" && event.phase === "response",
+    )
+    .map((event) => JSON.parse(bodyText(event)));
+  expect(bodies).toEqual(
+    expect.arrayContaining([
+      { token: "[REDACTED:page:login.token]" },
+      { token: "public-unmarked-token" },
+    ]),
+  );
+});
+
+it.each(["gzip", "cache"])(
+  "withholds marked response content when decoded bounds are unknown: %s",
+  async (kind) => {
+    const { collector, events, secrets } = harness();
+    const request = new RequestFixture("marked");
+    if (kind === "cache") request.responseSize = 0;
+    const secret = "private-source-token";
+    const response = new ResponseFixture(
+      request,
+      JSON.stringify({ token: secret }),
+    );
+    if (kind === "gzip")
+      vi.spyOn(response, "headers").mockReturnValue({
+        "content-type": "application/json",
+        "content-encoding": "gzip",
+      });
+    secrets.responseProtector("login", [
+      { name: "token", json_pointer: "/token", secret: true },
+    ])?.(request);
+    collector.request(request);
+    collector.response(response);
+    collector.finished(request);
+    await collector.finish();
+    const content = events.find(
+      (event) => event.kind === "network-content" && event.phase === "response",
+    );
+    if (content?.kind !== "network-content")
+      throw new Error("Missing response content");
+    expect(content.body.state).toBe("unavailable");
+    expect(content.headers.state).toBe("unavailable");
+    expect(response.bodyReads).toBe(0);
+    expect(response.headerReads).toBe(0);
+    expect(JSON.stringify(events)).not.toContain(secret);
+  },
+);
+
 it("keeps observed completion independent of rejected completion metadata", async () => {
   const { secrets, limits } = harness();
   let omit = false;
@@ -994,3 +1177,38 @@ it("stamps when each request was observed, and the browser's own start and durat
   expect(failure).not.toHaveProperty("started_at");
   expect(failure).not.toHaveProperty("elapsed_ms");
 });
+
+it.each([
+  { name: "structural expansion", body: '{"token":1}', limit: 16 },
+  {
+    name: "shadowed container",
+    body: '{"token":{},"token":"source-private"}',
+    limit: 2048,
+  },
+])("withholds marked content for $name", async ({ body, limit }) => {
+  const { collector, events, secrets } = harness(
+    selection,
+    undefined,
+    "declared-private",
+    { max_network_body_bytes: limit },
+  );
+  const request = new RequestFixture("marked");
+  request.responseSize = Buffer.byteLength(body);
+  secrets.responseProtector("login", [
+    { name: "token", json_pointer: "/token", secret: true },
+  ])?.(request);
+  const response = new ResponseFixture(request, body);
+  collector.request(request);
+  collector.response(response);
+  collector.finished(request);
+  await collector.finish();
+  expect(events).toContainEqual(
+    expect.objectContaining({
+      kind: "network-content",
+      phase: "response",
+      body: expect.objectContaining({ state: "unavailable" }),
+      headers: expect.objectContaining({ state: "unavailable" }),
+    }),
+  );
+  expect(response.bodyReads).toBe(1);
+});
diff --git a/src/browser/network/PlaywrightScenarioNetwork.ts b/src/browser/network/PlaywrightScenarioNetwork.ts
index e837ed4903..315f5f4a4d 100644
--- a/src/browser/network/PlaywrightScenarioNetwork.ts
+++ b/src/browser/network/PlaywrightScenarioNetwork.ts
@@ -10,6 +10,10 @@ import type {
   BrowserScenarioEvent,
 } from "../../domain/browserScenarioCaptureValues.js";
 import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
+import {
+  readBoundedResponseBody,
+  MAX_SAVED_RESPONSE_BYTES,
+} from "../BrowserScenarioResponseValues.js";
 import {
   captureNetworkBody,
   captureNetworkHeaders,
@@ -28,6 +32,7 @@ export interface ScenarioNetworkRequest extends NetworkHeadersSource {
   failure(): { errorText: string } | null;
   /** Browser resource timing: `startTime` in epoch milliseconds (0 until known), `responseEnd` after it (-1 when unknown). */
   timing?(): { readonly startTime: number; readonly responseEnd: number };
+  sizes?(): Promise<{ readonly responseBodySize: number }>;
 }
 
 /** The browser's own start and duration for a finished or failed request, as far as it reported them. */
@@ -198,33 +203,12 @@ export class PlaywrightScenarioNetwork {
       this.options.selection.request_body ||
       this.options.selection.header_values
     )
-      this.collect(
+      this.collect({
         transaction,
         sequence,
-        "request",
-        request,
-        (headers) =>
-          this.options.selection.request_body
-            ? captureNetworkBody(
-                async () => request.postDataBuffer(),
-                "request",
-                request,
-                this.options.secrets,
-                this.readOptions,
-                (body) =>
-                  this.options.admitContentBody({
-                    transactionId: transaction.id,
-                    sourceEventSequence: sequence,
-                    phase: "request",
-                    headers,
-                    observedBytes: body.observedBytes,
-                    allocationBytes: body.allocationBytes,
-                    mediaType: body.mediaType,
-                  }),
-              )
-            : Promise.resolve({ state: "not_requested" }),
-        this.options.selection.header_values,
-      );
+        phase: "request",
+        source: request,
+      });
   }
 
   /** Observe response metadata without starting a potentially unfinished body read. */
@@ -411,6 +395,22 @@ export class PlaywrightScenarioNetwork {
     return [...this.gaps];
   }
 
+  /** Source identity and readiness for the retained-event owner's final privacy pass. */
+  sourcePrivacy(): ReadonlyMap {
+    return new Map(
+      [...this.transactions].flatMap(([request, transaction]) =>
+        this.options.secrets.hasResponseProtection(request)
+          ? [
+              [
+                transaction.id,
+                this.options.secrets.responseSecretsReady(request),
+              ] as const,
+            ]
+          : [],
+      ),
+    );
+  }
+
   omittedTransactionRange(): {
     readonly first_transaction_id: string;
     readonly last_transaction_id: string;
@@ -607,62 +607,112 @@ export class PlaywrightScenarioNetwork {
     )
       return;
     transaction.responseScheduled = true;
-    this.collect(
+    this.collect({
       transaction,
-      response.sequence,
-      "response",
-      response.source,
-      (headers) =>
-        this.options.selection.response_body
-          ? unavailable === undefined
-            ? captureNetworkBody(
-                () => response.source.body(),
-                "response",
-                response.source,
-                this.options.secrets,
-                this.readOptions,
-                (body) =>
-                  this.options.admitContentBody({
-                    transactionId: transaction.id,
-                    sourceEventSequence: response.sequence,
-                    phase: "response",
-                    headers,
-                    observedBytes: body.observedBytes,
-                    allocationBytes: body.allocationBytes,
-                    mediaType: body.mediaType,
-                  }),
-              )
-            : Promise.resolve(unavailable)
-          : Promise.resolve({ state: "not_requested" }),
-      this.options.selection.header_values,
-    );
+      sequence: response.sequence,
+      phase: "response",
+      source: response.source,
+      ...(unavailable === undefined ? {} : { unavailable }),
+    });
   }
 
   private collect(
-    transaction: Transaction,
-    sequence: number,
-    phase: "request" | "response",
-    source: NetworkHeadersSource,
-    body: (headers: BrowserNetworkHeaders) => Promise,
-    includeHeaders: boolean,
+    input: {
+      readonly transaction: Transaction;
+      readonly sequence: number;
+    } & (
+      | { readonly phase: "request"; readonly source: ScenarioNetworkRequest }
+      | {
+          readonly phase: "response";
+          readonly source: ScenarioNetworkResponse;
+          readonly unavailable?: BrowserNetworkBody;
+        }
+    ),
   ): void {
+    const { transaction, sequence, phase, source } = input;
     const collect = async (): Promise => {
-      const retainedHeaders = includeHeaders
-        ? await captureNetworkHeaders(
-            source,
-            this.options.secrets,
-            this.readOptions,
-          )
-        : { state: "not_requested" as const };
-      const retainedBody = await body(retainedHeaders);
-      if (retainedBody.state === "omitted") {
+      const includeBody =
+        phase === "request"
+          ? this.options.selection.request_body
+          : this.options.selection.response_body;
+      const includeHeaders = this.options.selection.header_values;
+      const request =
+        input.phase === "response" ? input.source.request() : undefined;
+      const protection =
+        request !== undefined &&
+        this.options.secrets.hasResponseProtection(request)
+          ? { request, retainBody: includeBody }
+          : undefined;
+      const protectedResponse = protection !== undefined;
+      const privacyOnly = protectedResponse && !includeBody && includeHeaders;
+      const readOptions = privacyOnly
+        ? { ...this.readOptions, maxBodyBytes: MAX_SAVED_RESPONSE_BYTES }
+        : this.readOptions;
+      let headers: Promise | undefined;
+      const getHeaders = (): Promise =>
+        (headers ??= includeHeaders
+          ? captureNetworkHeaders(source, this.options.secrets, readOptions)
+          : Promise.resolve({ state: "not_requested" }));
+      // Existing reads keep their order. Marked responses establish secret
+      // spellings before either their headers or their body can be retained.
+      if (!protectedResponse && includeHeaders) await getHeaders();
+      const unavailable =
+        input.phase === "response" ? input.unavailable : undefined;
+      const observedBody: NetworkBodyCapture =
+        unavailable ??
+        (includeBody || privacyOnly
+          ? await captureNetworkBody(
+              async () => {
+                if (input.phase === "request")
+                  return input.source.postDataBuffer();
+                if (!protectedResponse) return input.source.body();
+                const bounded = await readBoundedResponseBody(
+                  input.source,
+                  Math.min(readOptions.maxBodyBytes, MAX_SAVED_RESPONSE_BYTES),
+                );
+                if (!bounded.ok) throw new Error(bounded.error.message);
+                return bounded.value;
+              },
+              phase,
+              source,
+              this.options.secrets,
+              readOptions,
+              async (body) =>
+                this.options.admitContentBody({
+                  transactionId: transaction.id,
+                  sourceEventSequence: sequence,
+                  phase,
+                  headers: await getHeaders(),
+                  observedBytes: body.observedBytes,
+                  allocationBytes: body.allocationBytes,
+                  mediaType: body.mediaType,
+                }),
+              protection,
+            )
+          : { state: "not_requested" });
+      if (observedBody.state === "omitted") {
         this.options.omitNetwork(
           transaction.id,
           "network-content",
-          retainedBody.reason,
+          observedBody.reason,
         );
         return;
       }
+      const protectedReadFailed =
+        protectedResponse &&
+        (observedBody.state === "unavailable" ||
+          observedBody.state === "not_exposed");
+      const retainedHeaders: BrowserNetworkHeaders =
+        protectedReadFailed && includeHeaders
+          ? {
+              state: "unavailable",
+              message:
+                "Response header values were withheld because marked source values could not be read safely.",
+            }
+          : await getHeaders();
+      const retainedBody = includeBody
+        ? observedBody
+        : { state: "not_requested" as const };
       if (
         retainedHeaders.state === "unavailable" ||
         retainedBody.state === "unavailable" ||
diff --git a/src/cli.ts b/src/cli.ts
index 0395f4fbf0..57a9244b36 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -20,7 +20,7 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman
 import { registerFridaCommands } from "./cli/fridaCommands.js";
 import type { FridaInstrumentationPort } from "./application/frida/FridaInstrumentationPort.js";
 import { Cli } from "incur";
-import { normalizeCliArguments } from "./cliOutput.js";
+import { normalizeCliArguments } from "./cliBuiltinArguments.js";
 
 import { createLogger } from "./logger.js";
 import { parseLogLevel } from "./config/processStart.js";
diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts
index 6b8bfff58e..8e13341bab 100644
--- a/src/cli/adbCommands.ts
+++ b/src/cli/adbCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { z } from "incur";
 import { AnalysisInputError } from "../domain/analysisErrorCore.js";
 import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
@@ -33,7 +34,7 @@ export const registerAdbCommands = (
           "adb",
           operation,
           signal,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
     );
 
diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts
index 5130a3b884..718ed7d9df 100644
--- a/src/cli/analysisEvidenceDelivery.ts
+++ b/src/cli/analysisEvidenceDelivery.ts
@@ -18,6 +18,7 @@ import { inspectAnalysisViewValidated } from "../application/analysisView/Analys
 import { localPathStringSchema } from "../domain/localPath.js";
 import { absoluteCallerPath } from "../filesystem/callerPath.js";
 import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
 import type { ZodSafeParseResult } from "zod";
 
@@ -33,13 +34,6 @@ export const analysisEvidenceDeliveryOptions = {
     ),
 };
 
-const isEvidence = (value: JsonValue): value is Evidence =>
-  typeof value === "object" &&
-  value !== null &&
-  !Array.isArray(value) &&
-  "evidence_id" in value &&
-  "normalized_result" in value;
-
 interface CliEvidenceDelivery {
   readonly view?: AnalysisViewRequest;
   readonly providerId: string;
@@ -143,7 +137,7 @@ export const deliverCliAnalysisEvidence = async (
       signal,
       undefined,
       { redactFailure },
-    );
+    ).then(projectCliAnalysisResult);
   const complete = await finishAnalysisCommand(
     run,
     service,
@@ -171,7 +165,7 @@ export const deliverCliAnalysisEvidence = async (
 
 /** Publish already finalized CLI analysis without opening a second provider/session. */
 export const publishCliAnalysisEvidence = async (
-  value: JsonValue,
+  result: Result,
   input: CliEvidenceDelivery,
 ): Promise => {
   const { operation, signal, detail, evidenceOutput } = input;
@@ -179,8 +173,8 @@ export const publishCliAnalysisEvidence = async (
     projectAnalysisError(
       redactExplicitFailure(error, input.sensitiveValues ?? []),
     );
-  if (!isEvidence(value)) return value;
-  const complete = parseEvidence(value);
+  if (!result.ok) return result.error;
+  const complete = parseEvidence(result.value);
   const inputError = cliEvidenceDeliveryInputError(input);
   if (inputError !== undefined) {
     inputError.retainPartialObservation(complete);
diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts
index eacaf09705..7ac18af53f 100644
--- a/src/cli/analysisViewCommands.ts
+++ b/src/cli/analysisViewCommands.ts
@@ -3,8 +3,7 @@ import { z } from "incur";
 import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js";
 import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
 import { ANALYSIS_VIEW_PROVIDER } from "../domain/investigationProviders.js";
-import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js";
-import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js";
+import { inspectEvidenceBundleCommand } from "../application/EvidenceBundleCommands.js";
 import {
   getEvidenceBundleInputSchema,
   importEvidenceBundleInputSchema,
@@ -36,46 +35,41 @@ export const registerAnalysisViewCommands = (
         ),
     }),
     run: ({ args }) =>
-      logCliCommand(logger, CLI_COMMANDS.inspectEvidenceBundle, async () => {
-        const input = await parseCliJsonInput(
-          args.inputJson,
-          CLI_COMMANDS.inspectEvidenceBundle,
-        );
-        if (!input.ok) return input.error;
-        const schema = getEvidenceBundleInputSchema.safeExtend({
-          path: importEvidenceBundleInputSchema.shape.path,
-        });
-        const parsed = schema.safeParse(input.value);
-        if (!parsed.success)
-          return {
-            error: "Evidence bundle inspection failed",
-            ...projectAnalysisError(
-              analysisInputErrorFromIssues(
-                CLI_COMMANDS.inspectEvidenceBundle,
-                parsed.error.issues,
-                input.value,
-              ),
-            ),
-          };
-        const loaded = await readEvidenceBundle(
-          parsed.data.path,
-          undefined,
-          undefined,
-          "get_evidence_bundle",
-        );
-        if (!loaded.ok)
-          return {
-            error: "Evidence bundle inspection failed",
-            ...projectAnalysisError(loaded.error),
-          };
-        const result = inspectEvidenceBundle(loaded.value, parsed.data);
-        return result.ok
-          ? result.value
-          : {
+      withCommandCancellation((signal) =>
+        logCliCommand(logger, CLI_COMMANDS.inspectEvidenceBundle, async () => {
+          const input = await parseCliJsonInput(
+            args.inputJson,
+            CLI_COMMANDS.inspectEvidenceBundle,
+            signal,
+          );
+          if (!input.ok) return input.error;
+          const schema = getEvidenceBundleInputSchema.safeExtend({
+            path: importEvidenceBundleInputSchema.shape.path,
+          });
+          const parsed = schema.safeParse(input.value);
+          if (!parsed.success)
+            return {
               error: "Evidence bundle inspection failed",
-              ...projectAnalysisError(result.error),
+              ...projectAnalysisError(
+                analysisInputErrorFromIssues(
+                  CLI_COMMANDS.inspectEvidenceBundle,
+                  parsed.error.issues,
+                  input.value,
+                ),
+              ),
             };
-      }),
+          const result = await inspectEvidenceBundleCommand(
+            parsed.data,
+            signal,
+          );
+          return result.ok
+            ? result.value
+            : {
+                error: "Evidence bundle inspection failed",
+                ...projectAnalysisError(result.error),
+              };
+        }),
+      ),
   });
   cli.command(CLI_COMMANDS.inspectAnalysisView, {
     description:
@@ -119,9 +113,11 @@ export const registerAnalysisViewCommands = (
           );
           // Preserve the inline command's existing error label without copying
           // the authenticated completed Evidence or its partial observation.
-          return bundlePath === undefined && "code" in result
-            ? { error: "Application workflow failed", ...result }
-            : result;
+          return result.ok
+            ? result.value
+            : bundlePath === undefined
+              ? { error: "Application workflow failed", ...result.error }
+              : result.error;
         }),
       ),
   });
diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts
index 6f0c3152a9..f2046b0575 100644
--- a/src/cli/androidCommands.ts
+++ b/src/cli/androidCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { z } from "incur";
 import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
 import { createAndroidAnalysisProvider } from "../composition/android.js";
@@ -8,6 +9,13 @@ import type { Logger } from "pino";
 import type { CliInstance } from "./types.js";
 import { withCommandCancellation } from "./commandCancellation.js";
 import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
+import {
+  analysisEvidenceDeliveryOptions,
+  deliverCliAnalysisEvidence,
+  parseCliAnalysisEvidenceDelivery,
+} from "./analysisEvidenceDelivery.js";
+import { androidPackageDeliverySchema } from "../domain/android/androidPackageQuery.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
 
 /** Expose the same explicit APK requests through one-shot CLI commands. */
 export const registerAndroidCommands = (
@@ -26,7 +34,7 @@ export const registerAndroidCommands = (
           "jadx",
           operation,
           signal,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
     );
   const path = z.string().describe("Local standalone APK path");
@@ -35,11 +43,38 @@ export const registerAndroidCommands = (
     description:
       "Inspect APK declarations and decoded manifest without executing it",
     args: z.object({ path }),
-    run: ({ args }) =>
-      execute(
-        CLI_COMMANDS.inspectAndroidPackage,
-        "inspect_android_package",
-        args,
+    options: z.object({
+      ...analysisEvidenceDeliveryOptions,
+      view: z
+        .string()
+        .optional()
+        .describe(
+          "JSON android manifest/components/component-summary view; requires --detail summary and --evidence_output",
+        ),
+    }),
+    run: ({ args, options }) =>
+      withCommandCancellation((signal) =>
+        logCliCommand(logger, CLI_COMMANDS.inspectAndroidPackage, async () => {
+          const delivery = parseCliAnalysisEvidenceDelivery(
+            "inspect_android_package",
+            options,
+            androidPackageDeliverySchema,
+          );
+          if (!delivery.ok) return projectAnalysisError(delivery.error);
+          return deliverCliAnalysisEvidence({
+            run: () =>
+              service.execute("inspect_android_package", args, { signal }),
+            service: { close: () => provider.close() },
+            providerId: "jadx",
+            operation: "inspect_android_package",
+            signal,
+            detail: delivery.value.detail,
+            evidenceOutput: delivery.value.evidenceOutput,
+            ...(delivery.value.view === undefined
+              ? {}
+              : { view: delivery.value.view }),
+          });
+        }),
       ),
   });
   cli.command(CLI_COMMANDS.searchAndroidClasses, {
diff --git a/src/cli/apktoolCommands.ts b/src/cli/apktoolCommands.ts
index 26eb0c885b..f795b24640 100644
--- a/src/cli/apktoolCommands.ts
+++ b/src/cli/apktoolCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { z } from "incur";
 import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
 import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
@@ -32,7 +33,7 @@ export const registerApktoolCommands = (
           "apktool",
           operation,
           signal,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
     );
 
diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts
index 565a52b9d7..2434133774 100644
--- a/src/cli/artifactCommands.ts
+++ b/src/cli/artifactCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import {
   analysisEvidenceDeliveryOptions,
   cliEvidenceDeliveryInputError,
@@ -63,7 +64,7 @@ export const registerArtifactCommands = (
             ...(options.root === undefined ? {} : { root: options.root }),
           },
           logger,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
   });
 };
@@ -103,7 +104,7 @@ const registerDylibResolutionCommand = (
               : { architecture: options.architecture }),
           },
           logger,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
   });
 };
@@ -138,7 +139,7 @@ const registerAssetCatalogCommand = (
           "inspect_asset_catalog",
           { offset: options.offset, limit: options.limit },
           logger,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
   });
 };
@@ -188,7 +189,7 @@ const registerInterfaceBuilderCommand = (
             max_connections: options.maxConnections,
           },
           logger,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
   });
 };
@@ -220,7 +221,7 @@ const registerExtractionCommand = (
             output_root: createArtifactExtractionDestination(),
           },
           logger,
-        ),
+        ).then(projectCliAnalysisResult),
       ),
   });
 };
diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts
index cb25ec4fe2..5ec804f21b 100644
--- a/src/cli/binaryDiagnosticsCommands.ts
+++ b/src/cli/binaryDiagnosticsCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { inspectDevicetreeInputSchema } from "../domain/firmware/devicetree.js";
 import { inspectPeLoadConfigInputSchema } from "../domain/native/peLoadConfig.js";
 import { inspectByteEntropyInputSchema } from "../domain/native/byteEntropy.js";
@@ -76,7 +77,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-elf-notes",
               "inspect_elf_notes",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
@@ -120,7 +121,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-apple-ips",
               "inspect_apple_ips",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
@@ -157,7 +158,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-kernel-module",
               "inspect_kernel_module",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
@@ -192,7 +193,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-pe-load-config",
               "inspect_pe_load_config",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
@@ -230,7 +231,7 @@ export const registerBinaryDiagnosticsCommands = (
             "rea-byte-entropy",
             "inspect_byte_entropy",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
@@ -265,7 +266,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-pe-certificates",
               "inspect_pe_certificates",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
@@ -300,7 +301,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-pe-imports",
               "inspect_pe_imports",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
@@ -335,7 +336,7 @@ export const registerBinaryDiagnosticsCommands = (
               "rea-pe-resources",
               "inspect_pe_resources",
               signal,
-            ),
+            ).then(projectCliAnalysisResult),
         );
       }),
   });
diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts
index f54fa2e783..227822f270 100644
--- a/src/cli/browserAdvancedCommands.ts
+++ b/src/cli/browserAdvancedCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { Cli, z } from "incur";
 
 import { browserCliError } from "./browserCliError.js";
@@ -96,7 +97,7 @@ const registerWebMcp = (
             provider.identity().id,
             "discover_webmcp_tools",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
@@ -207,7 +208,7 @@ const registerScreenshot = (
             provider.identity().id,
             "capture_web_screenshot",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts
index 5a967c5134..23652bf53a 100644
--- a/src/cli/browserCommands.ts
+++ b/src/cli/browserCommands.ts
@@ -1,8 +1,10 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
+import { webBundleDeliveryOptions } from "./javascriptRouteDelivery.js";
 import {
-  parseJavaScriptRouteDelivery,
-  webBundleDeliveryOptions,
-} from "./javascriptRouteDelivery.js";
-import { deliverCliAnalysisEvidence } from "./analysisEvidenceDelivery.js";
+  deliverCliAnalysisEvidence,
+  parseCliAnalysisEvidenceDelivery,
+} from "./analysisEvidenceDelivery.js";
+import { webRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js";
 import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
 import { Cli, z } from "incur";
 
@@ -86,7 +88,7 @@ const registerTargetList = (
             provider.identity().id,
             "list_browser_targets",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
@@ -143,7 +145,7 @@ const registerPageInspection = (
             provider.identity().id,
             "inspect_web_page",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
@@ -180,9 +182,10 @@ const registerBundleAnalysis = (
     run: ({ args, options }) =>
       withCommandCancellation((signal) =>
         logCliCommand(logger, "analyze-web-bundle", async () => {
-          const delivery = parseJavaScriptRouteDelivery(
+          const delivery = parseCliAnalysisEvidenceDelivery(
             "analyze_web_bundle",
             options,
+            webRoutesDeliverySchema,
           );
           if (!delivery.ok) return projectAnalysisError(delivery.error);
           const provider = createBrowserObservationProvider();
@@ -281,7 +284,7 @@ const registerObservationSession = (
             provider.identity().id,
             "observe_web_session",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts
index 8ad700a415..bfd0324786 100644
--- a/src/cli/captureCommands.ts
+++ b/src/cli/captureCommands.ts
@@ -1,3 +1,4 @@
+import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
 import { inspectPacketFieldsInputSchema } from "../domain/captures/packetFields.js";
 import { z } from "incur";
 import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
@@ -62,7 +63,7 @@ export const registerCaptureCommands = (
             "tshark-capture",
             "inspect_packet_fields",
             signal,
-          ),
+          ).then(projectCliAnalysisResult),
         ),
       ),
   });
@@ -117,7 +118,7 @@ export const registerCaptureCommands = (
             "tshark-capture",
             "inspect_packet_capture",
             signal,
-          );
+          ).then(projectCliAnalysisResult);
         }),
       ),
   });
diff --git a/src/cli/cliOutputFormatter.ts b/src/cli/cliOutputFormatter.ts
index 2854c72286..cb05a6d822 100644
--- a/src/cli/cliOutputFormatter.ts
+++ b/src/cli/cliOutputFormatter.ts
@@ -66,6 +66,19 @@ const escapeTableCell = (value: string): string =>
     .replace(/[\\|`*[\]~]|_+/g, escapeInlineSyntax)
     .replace(/\r\n|\r|\n/g, "
"); +/** Heading and paragraph facts are literal data, including block syntax. */ +const escapeMarkdownText = (value: string): string => + escapeTableCell(value) + .replace(/#/g, "\\#") + .replace(/^(\s*)([-+=])/, "$1\\$2") + .replace(/^(\s*\d{1,9})([.)])(?=\s)/, "$1\\$2") + .replace(/^[ \t]+/, (indent) => + indent.replace(/ /g, " ").replace(/\t/g, " "), + ); + +const markdownHeading = (path: readonly string[]): string => + `## ${escapeMarkdownText(path.join("."))}\n\n`; + const renderTable = ( headers: readonly string[], rows: readonly string[][], @@ -138,13 +151,13 @@ const formatMarkdown = ( ): string => { if (value == null && path.length === 0) return ""; if (isScalar(value)) { - if (path.length === 0) return String(value); - return `## ${path.join(".")}\n\n${String(value)}`; + if (path.length === 0) return escapeMarkdownText(String(value)); + return `${markdownHeading(path)}${escapeMarkdownText(String(value))}`; } if (Array.isArray(value)) { if (isObjectArray(value)) { const table = formatObjectTable(value); - return path.length === 0 ? table : `## ${path.join(".")}\n\n${table}`; + return path.length === 0 ? table : `${markdownHeading(path)}${table}`; } return formatMarkdown(markdownCell(value), path); } @@ -163,8 +176,9 @@ const formatMarkdown = ( return entries .map(([key, child]) => { const childPath = [...path, key]; - const heading = `## ${childPath.join(".")}\n\n`; - if (isScalar(child)) return `${heading}${String(child)}`; + const heading = markdownHeading(childPath); + if (isScalar(child)) + return `${heading}${escapeMarkdownText(String(child))}`; if (isObjectArray(child)) return `${heading}${formatObjectTable(child)}`; if ( typeof child === "object" && @@ -176,7 +190,7 @@ const formatMarkdown = ( ? `${heading}${formatKeyValueTable(nested)}` : formatMarkdown(nested, childPath); } - return `${heading}${markdownCell(child)}`; + return `${heading}${escapeMarkdownText(markdownCell(child))}`; }) .join("\n\n"); }; diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index e0ada1b8d8..c98712702e 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -9,7 +9,11 @@ import { cliOperationFailureIdentity, cliOperationFailurePayload, } from "../cliLogging.js"; -import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { err, ok, type Result } from "../domain/result.js"; import { sliceTextByTokenWindow } from "./tokenWindow.js"; import { projectAnalysisError, @@ -301,11 +305,43 @@ export const createCliResultOutput = ( !options.tokenCount ) return false; - const selected: unknown = - operationFailure ?? - (filterPaths === undefined - ? value - : applyCliOutputFilter(value, filterPaths)); + const selection = + operationFailure !== undefined || filterPaths === undefined + ? ok(operationFailure ?? value) + : applyCliOutputFilter(value, filterPaths); + if (!selection.ok) { + error = projectAnalysisError( + new AnalysisInputError( + metadata.command, + { + userMessage: selection.error, + remediationAction: + "Select array indices with slices, or request array properties in a separate --filter-output invocation.", + }, + [ + { + path: ["filter-output"], + reason: "invalid_value", + message: selection.error, + }, + ], + ), + ); + const document = options.fullOutput + ? { + ok: false, + error, + meta: { command: metadata.command, duration: metadata.duration }, + } + : error; + handled = true; + await writeOutputText( + destination, + `${formatCliOutput(format === "jsonl" ? jsonLinesErrorRecord(document) : document, format)}\n`, + ); + return true; + } + const selected = selection.value; const filtered = (format === "json" || format === "jsonl") && ownsFilteredOutput && @@ -550,10 +586,33 @@ type FilterPaths = ReturnType; type FilterPath = FilterPaths[number]; /** Apply Incur's parsed path grammar while merging sibling array selections. */ -const applyCliOutputFilter = (value: unknown, paths: FilterPaths): unknown => { - if (paths.length === 0) return value; - if (Array.isArray(value)) - return value.map((item) => applyCliOutputFilter(item, paths)); +type FilterProjection = Result; + +const applyCliOutputFilter = ( + value: unknown, + paths: FilterPaths, +): FilterProjection => { + if (paths.length === 0) return ok(value); + if (Array.isArray(value)) { + if (paths.some((path) => path[0] !== undefined && "start" in path[0])) + return projectArraySlices( + value, + paths.flatMap((path) => { + const first = path[0]; + if (first === undefined || "start" in first) return [path]; + // Root keys retain Incur's implicit per-record traversal, including + // numeric keys. Only nested array keys address array positions. + return expandImplicitArrayPath(value, path); + }), + ); + const selected: unknown[] = []; + for (const item of value) { + const projection = applyCliOutputFilter(item, paths); + if (!projection.ok) return projection; + selected.push(projection.value); + } + return ok(selected); + } if ( paths.length === 1 && paths[0]?.length === 1 && @@ -566,18 +625,45 @@ const applyCliOutputFilter = (value: unknown, paths: FilterPaths): unknown => { value === null || !Object.hasOwn(value, key) ) - return undefined; + return ok(undefined); const selected = (value as Record)[key]; - return typeof selected !== "object" || selected === null - ? selected - : { [key]: selected }; + return ok( + typeof selected !== "object" || selected === null + ? selected + : { [key]: selected }, + ); } return projectObjectPaths(value, paths); }; +/** Preserve implicit traversal through every root-array layer before merging slices. */ +const expandImplicitArrayPath = ( + value: unknown, + path: FilterPath, +): FilterPaths => { + if (Array.isArray(value)) + return value.flatMap((item, index) => + expandImplicitArrayPath(item, path).map((tail) => [ + { start: index, end: index + 1 }, + ...tail, + ]), + ); + const first = path[0]; + return typeof value === "object" && + value !== null && + first !== undefined && + "key" in first && + Object.hasOwn(value, first.key) + ? [path] + : []; +}; + /** Build a fresh object projection; a path ending at a key selects its subtree. */ -const projectObjectPaths = (value: unknown, paths: FilterPaths): unknown => { - if (typeof value !== "object" || value === null) return {}; +const projectObjectPaths = ( + value: unknown, + paths: FilterPaths, +): FilterProjection => { + if (typeof value !== "object" || value === null) return ok({}); const byKey = new Map(); for (const path of paths) { const [segment, ...tail] = path; @@ -586,60 +672,86 @@ const projectObjectPaths = (value: unknown, paths: FilterPaths): unknown => { siblings.push(tail); byKey.set(segment.key, siblings); } - const result = new Map(); for (const key of [...byKey.keys()].sort()) { const siblings = byKey.get(key)!; if (!Object.hasOwn(value, key)) continue; const child = (value as Record)[key]; if (child === undefined) continue; - // Selecting a parent retains its complete original subtree and dominates - // any narrower selections below it, independent of path order. if (siblings.some((path) => path.length === 0)) { result.set(key, child); continue; } - const slicePaths = siblings.filter( - (path) => path[0] !== undefined && "start" in path[0], - ); - if (slicePaths.length > 0 && Array.isArray(child)) { - result.set(key, projectArraySlices(child, slicePaths)); - continue; - } - const objectPaths = siblings.filter( - (path) => path[0] !== undefined && "key" in path[0], - ); - if (objectPaths.length > 0 && typeof child === "object" && child !== null) - result.set(key, projectObjectPaths(child, objectPaths)); + const projection = projectChildPaths(child, siblings); + if (!projection.ok) return projection; + if (projection.value !== undefined) result.set(key, projection.value); } - return Object.fromEntries(result); + return ok(Object.fromEntries(result)); }; -/** Union sliced rows by their original index, then emit them in source order. */ +/** Keep key-only array projections compatible; mixed projections use source indices. */ +const projectChildPaths = ( + value: unknown, + paths: FilterPaths, +): FilterProjection => { + if (typeof value !== "object" || value === null) return ok(undefined); + return Array.isArray(value) && + paths.some((path) => path[0] !== undefined && "start" in path[0]) + ? projectArraySlices(value, paths) + : projectObjectPaths(value, paths); +}; + +/** Union sliced rows and explicit array indices, then emit them in source order. */ const projectArraySlices = ( source: readonly unknown[], paths: readonly FilterPath[], -): unknown[] => { +): FilterProjection => { const selected = new Map(); + const select = (index: number, tail: FilterPath): void => { + const siblings = selected.get(index) ?? []; + siblings.push(tail); + selected.set(index, siblings); + }; for (const path of paths) { - const [slice, ...tail] = path; - if (slice === undefined || !("start" in slice)) continue; - const start = normalizeSliceIndex(slice.start, source.length); - const end = normalizeSliceIndex(slice.end, source.length); - for (let index = start; index < end; index += 1) { - const siblings = selected.get(index) ?? []; - siblings.push(tail); - selected.set(index, siblings); + const [segment, ...tail] = path; + if (segment === undefined) continue; + if ("key" in segment) { + // Canonical own array indices share the same identity as slice positions. + // Missing properties contribute nothing, as in key-only projections. + if (!Object.hasOwn(source, segment.key)) continue; + const index = Number(segment.key); + if ( + !Number.isInteger(index) || + index < 0 || + index >= 0xffff_ffff || + String(index) !== segment.key + ) + return err( + `The --filter-output selection mixes array slices with the non-index property ${JSON.stringify(segment.key)}. These selections cannot share an array result.`, + ); + select(index, tail); + continue; } + const start = normalizeSliceIndex(segment.start, source.length); + const end = normalizeSliceIndex(segment.end, source.length); + for (let index = start; index < end; index += 1) select(index, tail); } - return [...selected.keys()] - .sort((left, right) => left - right) - .map((index) => { - const siblings = selected.get(index)!; - const item = source[index]; - if (siblings.some((path) => path.length === 0)) return item; - return projectObjectPaths(item, siblings); - }); + const result: unknown[] = []; + for (const index of [...selected.keys()].sort( + (left, right) => left - right, + )) { + const siblings = selected.get(index)!; + const item = source[index]; + if (siblings.some((path) => path.length === 0)) { + result.push(item === undefined ? null : item); + continue; + } + const projection = projectChildPaths(item, siblings); + if (!projection.ok) return projection; + // Slices have historically retained selected scalar rows as empty objects. + result.push(projection.value ?? {}); + } + return ok(result); }; const normalizeSliceIndex = (index: number, length: number): number => { diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index d88a9b9828..e90a6e59ca 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { realpath, stat } from "node:fs/promises"; import { findAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; @@ -166,7 +167,7 @@ const registerOverviewCommands = ( "binary_overview", {}, directAnalysisOptionsFromCli(logger, options), - ); + ).then(projectCliAnalysisResult); }); }, }); @@ -183,7 +184,7 @@ const registerOverviewCommands = ( "binary_overview", {}, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -244,7 +245,7 @@ const registerDecompileCommand = ( ), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -321,7 +322,7 @@ const registerXrefsCommand = ( ), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -371,7 +372,7 @@ const registerTraceCommand = ( case_sensitive: options.caseSensitive, }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -475,7 +476,7 @@ const registerInstructionsCommand = ( ), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -536,7 +537,7 @@ const registerSearchCommand = ( case_sensitive: options.caseSensitive, }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index fdec5c415e..fe752a9922 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js"; import { z } from "incur"; import { @@ -68,7 +69,7 @@ export const registerCoreNativeCommands = ( ...(options.cursor === undefined ? {} : { cursor: options.cursor }), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); cli.command(CLI_COMMANDS.inspectNativeLoadImage, { @@ -123,7 +124,7 @@ export const registerCoreNativeCommands = ( "read_bytes", { address: args.address, length: options.length }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); cli.command(CLI_COMMANDS.addressToFileOffset, { @@ -142,7 +143,7 @@ export const registerCoreNativeCommands = ( "address_to_file_offset", { address: args.address }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); cli.command(CLI_COMMANDS.traceNativeValues, { @@ -220,7 +221,7 @@ export const registerCoreNativeCommands = ( limit: options.limit, }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); cli.command(CLI_COMMANDS.inspectNativeDataType, { @@ -245,7 +246,7 @@ export const registerCoreNativeCommands = ( : { address: options.address }), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); for (const [command, operation] of [ @@ -271,7 +272,7 @@ export const registerCoreNativeCommands = ( operation, { address: args.address }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); } @@ -312,7 +313,7 @@ const registerNativeDispatchMetadataCommand = ( : { max_input_bytes: options.maxInputBytes }), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -370,7 +371,7 @@ const registerNativeUiActionCommand = ( max_edges: options.maxEdges, }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -413,7 +414,7 @@ const registerNativeApiCommand = ( ), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -459,7 +460,7 @@ const registerAnnotationCommand = ( : { inline_comment: options["inline-comment"] }), }, directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts index b80461b145..40457eaa05 100644 --- a/src/cli/cutterCommands.ts +++ b/src/cli/cutterCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import type { Logger } from "pino"; @@ -33,7 +34,7 @@ export const registerCutterCommands = ( "cutter", "list_cutter_sessions", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); @@ -80,7 +81,7 @@ export const registerCutterCommands = ( "cutter", "cutter_command", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); diff --git a/src/cli/decompileDirectoryCommand.ts b/src/cli/decompileDirectoryCommand.ts index 4e049512b2..378f7f56e1 100644 --- a/src/cli/decompileDirectoryCommand.ts +++ b/src/cli/decompileDirectoryCommand.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; @@ -87,7 +88,7 @@ export const registerDecompileDirectoryCommand = ( "decompile_all", decompileAllArguments(options), directAnalysisOptionsFromCli(logger, options), - ), + ).then(projectCliAnalysisResult), ), }); }; diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 804d2da92e..c066ccea5e 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -1,4 +1,6 @@ -import { parseJavaScriptRouteDelivery } from "./javascriptRouteDelivery.js"; +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js"; +import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js"; import { isAbsolute, resolve, sep } from "node:path"; import { Cli, z } from "incur"; @@ -77,7 +79,7 @@ const registerElectronFuseCommand = ( service.provider.identity.id, "inspect_electron_fuses", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); @@ -157,9 +159,9 @@ const registerElectronActiveCommand = ( "capture_electron_scenario", signal, ); - return "code" in result - ? { error: "Electron analysis failed", ...result } - : result; + return result.ok + ? result.value + : { error: "Electron analysis failed", ...result.error }; }, ), ), @@ -248,7 +250,7 @@ const registerElectronTargetList = ( provider.identity().id, "list_electron_targets", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); @@ -297,7 +299,7 @@ const registerElectronPageInspection = ( provider.identity().id, "inspect_electron_page", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); @@ -321,9 +323,10 @@ const registerJavaScriptApplicationCommand = ( logger, CLI_COMMANDS.analyzeJavaScriptApplication, async () => { - const delivery = parseJavaScriptRouteDelivery( + const delivery = parseCliAnalysisEvidenceDelivery( "analyze_javascript_application", options, + javaScriptRoutesDeliverySchema, ); if (!delivery.ok) return projectAnalysisError(delivery.error); const sourcePaths = await cliJavaScriptSourcePaths( diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index e2b0aefbaf..d893cd0b61 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -330,12 +330,6 @@ const conflictingRules = (): JsonValue => ]), ); -const isEvidenceValue = (value: unknown): boolean => - typeof value === "object" && - value !== null && - "evidence_id" in value && - "normalized_result" in value; - /** Inspect a HAR as inspect-web-network-capture does and serialize the Evidence as it prints it; a refusal is the command's output. */ const inspectBuildHar = async ( capturePath: string, @@ -345,7 +339,6 @@ const inspectBuildHar = async ( ): Promise> => { const { createWebNetworkCaptureService } = await import("../composition/webNetworkCaptures.js"); - const { parseEvidence } = await import("../domain/evidence.js"); const { environmentExportEvidenceFile } = await import("../application/environment/environmentExport.js"); const { ENVIRONMENT_BUILD_OPERATION } = @@ -368,15 +361,15 @@ const inspectBuildHar = async ( "inspect_web_network_capture", signal, ); - if (!isEvidenceValue(inspected)) + if (!inspected.ok) return err({ error: "Application workflow failed", - ...(inspected as Record), + ...inspected.error, }); const file = await environmentExportEvidenceFile( "evidence", capturePath, - parseEvidence(inspected), + inspected.value, ENVIRONMENT_BUILD_OPERATION, ); return file.ok ? ok(file.value) : err(cliError(file.error)); diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index e57970bf38..8cf53caa06 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { evmInterfaceModule } from "../composition/evm.js"; import { logCliCommand } from "../cliLogging.js"; @@ -46,7 +47,7 @@ export const registerEvmCommands = ( "evmole-interface", "inspect_evm_interface", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cli/finishAnalysisCommand.ts b/src/cli/finishAnalysisCommand.ts index 5c8882907c..74ca59058f 100644 --- a/src/cli/finishAnalysisCommand.ts +++ b/src/cli/finishAnalysisCommand.ts @@ -5,10 +5,13 @@ import { } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "../domain/analysisErrorProjection.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import type { Result } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js"; /** Finish a one-shot CLI operation while retaining primary results through close. */ @@ -25,8 +28,12 @@ export const finishAnalysisCommand = async < readonly failureProjection?: "initial" | "final"; /** Apply invocation-specific exclusions after all owned cleanup is composed. */ readonly redactFailure?: (error: AnalysisError) => AnalysisError; + /** Validate producer-attested completed output against this command's exact result contract. */ + readonly recoverCompletedObservation?: ( + observation: AnalysisPartialObservation, + ) => Value | undefined; } = {}, -): Promise> => { +): Promise>> => { const projectFailure = (error: AnalysisError) => projectAnalysisError(options.redactFailure?.(error) ?? error); let result: Result; @@ -48,6 +55,14 @@ export const finishAnalysisCommand = async < !result.ok && options.failureProjection === "initial" ? projectFailure(result.error) : undefined; + const initialDiagnosticFailure = + initialFailure !== undefined && + !result.ok && + result.error.capturedOutput !== undefined + ? projectAnalysisErrorWithoutCapturedOutput( + options.redactFailure?.(result.error) ?? result.error, + ) + : initialFailure; try { // Resource owners may resolve their retained errors during close. This // adapter cannot infer resolution of a producer's earlier cleanup failure. @@ -74,7 +89,35 @@ export const finishAnalysisCommand = async < } } await dispatchQueuedInterrupts(); - if (initialFailure !== undefined) return initialFailure; + if (initialFailure !== undefined && cleanup === undefined) + return err(initialFailure); + if ( + initialFailure === undefined && + !result.ok && + result.error instanceof ProviderCleanupError && + !result.error.cleanupIncomplete && + result.error.cleanupPrimary === undefined && + result.error.completedObservation !== undefined + ) { + const completed = options.recoverCompletedObservation?.( + result.error.completedObservation, + ); + if (completed !== undefined) { + const recoveredCleanup = result.error; + result = signal.aborted + ? { + ok: false, + error: new AnalysisCancelledError(canonicalOperation, { + cause: signal.reason, + partialObservation: completed, + diagnostics: { + cleanup_history: projectFailure(recoveredCleanup), + }, + }), + } + : { ok: true, value: completed }; + } + } if (result.ok && signal.aborted) result = { ok: false, @@ -96,5 +139,25 @@ export const finishAnalysisCommand = async < ).bindCleanupDependencies([result.error, cleanup]), }; } - return result.ok ? result.value : projectFailure(result.error); + if (result.ok) return ok(result.value); + const failure = projectFailure(result.error); + const diagnostics = failure.details?.diagnostics; + return err( + initialFailure === undefined || cleanup === undefined + ? failure + : { + ...failure, + details: { + ...failure.details, + diagnostics: { + ...(typeof diagnostics === "object" && + diagnostics !== null && + !Array.isArray(diagnostics) + ? diagnostics + : {}), + primary_error: initialDiagnosticFailure ?? initialFailure, + }, + }, + }, + ); }; diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 5b0e274cfc..0c0a828ee6 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; @@ -36,7 +37,7 @@ export const registerFirmwareCommands = ( operation === "inspect_firmware_regions" ? "binwalk" : "unblob", operation, signal, - ), + ).then(projectCliAnalysisResult), ), ); const path = localPathStringSchema.describe( diff --git a/src/cli/flutterCommands.ts b/src/cli/flutterCommands.ts index 4879d1cea8..beb7c46447 100644 --- a/src/cli/flutterCommands.ts +++ b/src/cli/flutterCommands.ts @@ -1,3 +1,5 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import { evidenceSchema } from "../domain/evidence.js"; import { z } from "incur"; import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; @@ -80,7 +82,14 @@ export const registerFlutterCommands = ( "flutter", spec.operation, signal, - ), + undefined, + { + recoverCompletedObservation: (observation) => { + const parsed = evidenceSchema.safeParse(observation); + return parsed.success ? parsed.data : undefined; + }, + }, + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index bb35af72f3..b74f185fc9 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js"; @@ -96,7 +97,7 @@ export const registerFridaCommands = ( "frida", "list_frida_devices", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); @@ -135,7 +136,7 @@ export const registerFridaCommands = ( "frida", "list_frida_processes", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); @@ -284,7 +285,7 @@ export const registerFridaCommands = ( "frida", "instrument_with_frida", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index df2b80e813..88f482fe80 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -134,10 +134,14 @@ export const runCliJavaScriptApplicationAnalysis = async ( ), ); } - const value = result.ok ? result.value : cliError(result.error); + const finalized = result.ok + ? result + : { ok: false as const, error: cliError(result.error) }; return delivery === undefined - ? value - : publishCliAnalysisEvidence(value, { + ? finalized.ok + ? finalized.value + : finalized.error + : publishCliAnalysisEvidence(finalized, { ...delivery, providerId: JAVASCRIPT_APPLICATION_PROVIDER.id, operation: "analyze_javascript_application", diff --git a/src/cli/javascriptRecoveryCommands.ts b/src/cli/javascriptRecoveryCommands.ts index b7263519b1..da59e8e50b 100644 --- a/src/cli/javascriptRecoveryCommands.ts +++ b/src/cli/javascriptRecoveryCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { javascriptRecoveryInputSchema } from "../domain/javascript/javascriptRecovery.js"; import { z } from "incur"; import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptRecoveryService.js"; @@ -65,7 +66,7 @@ export const registerJavaScriptRecoveryCommands = ( "wakaru", "recover_javascript_sources", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cli/javascriptRouteDelivery.ts b/src/cli/javascriptRouteDelivery.ts index 05e07f5f14..03262d90e6 100644 --- a/src/cli/javascriptRouteDelivery.ts +++ b/src/cli/javascriptRouteDelivery.ts @@ -1,12 +1,5 @@ import { z } from "incur"; -import { - javaScriptRoutesDeliverySchema, - webRoutesDeliverySchema, -} from "../domain/analysisView/javascriptRoutesView.js"; -import { - analysisEvidenceDeliveryOptions, - parseCliAnalysisEvidenceDelivery, -} from "./analysisEvidenceDelivery.js"; +import { analysisEvidenceDeliveryOptions } from "./analysisEvidenceDelivery.js"; export const javascriptRouteDeliveryOptions = { ...analysisEvidenceDeliveryOptions, @@ -24,19 +17,3 @@ export const webBundleDeliveryOptions = { "JSON web-bundle (endpoints, inferences, summary) or javascript-routes view; requires --detail summary and --evidence_output", ), }; - -/** Validate delivery before opening artifacts or attaching a browser. */ -export const parseJavaScriptRouteDelivery = ( - operation: string, - options: { - detail: "complete" | "summary"; - evidence_output?: string | undefined; - view?: string | undefined; - }, -) => { - const schema = - operation === "analyze_web_bundle" - ? webRoutesDeliverySchema - : javaScriptRoutesDeliverySchema; - return parseCliAnalysisEvidenceDelivery(operation, options, schema); -}; diff --git a/src/cli/javascriptRuntimeCommands.ts b/src/cli/javascriptRuntimeCommands.ts index 19260c444d..0e542057d3 100644 --- a/src/cli/javascriptRuntimeCommands.ts +++ b/src/cli/javascriptRuntimeCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { Cli, z } from "incur"; import { @@ -75,7 +76,7 @@ export const registerJavaScriptRuntimeObservationCommands = ( "list_javascript_runtime_targets", signal, "V8 Inspector resources", - ); + ).then(projectCliAnalysisResult); }, ), ), @@ -123,7 +124,7 @@ export const registerJavaScriptRuntimeObservationCommands = ( "observe_javascript_runtime", signal, "V8 Inspector resources", - ); + ).then(projectCliAnalysisResult); }, ), ), diff --git a/src/cli/jebCommands.ts b/src/cli/jebCommands.ts index 0aab2617a7..888e0493b2 100644 --- a/src/cli/jebCommands.ts +++ b/src/cli/jebCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js"; import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js"; @@ -28,7 +29,7 @@ export const registerJebCommands = ( "jeb", operation, signal, - ), + ).then(projectCliAnalysisResult), ), ); diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index ca9f2b2917..79f34a7c3a 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { compareManagedMemberPaths } from "../application/managed/ManagedMemberComparisonService.js"; @@ -44,7 +45,12 @@ const registerInspectManagedArtifact = ( }), run: ({ args }) => logCliCommand(logger, "inspect-managed-artifact", () => - runProviderAnalysis(args.path, "inspect_managed_artifact", {}, logger), + runProviderAnalysis( + args.path, + "inspect_managed_artifact", + {}, + logger, + ).then(projectCliAnalysisResult), ), }); }; @@ -90,7 +96,7 @@ const registerInspectManagedMembers = ( ? {} : { evidenceBundlePath: options.evidenceBundle }), }, - ), + ).then(projectCliAnalysisResult), ), ), }); @@ -114,7 +120,7 @@ const registerInspectManagedNativeBoundaries = ( "inspect_managed_native_boundaries", {}, logger, - ), + ).then(projectCliAnalysisResult), ), }); }; @@ -152,9 +158,9 @@ const registerCompareManagedMembers = ( signal, "Managed member comparison resources", ); - return "evidence_id" in result - ? result - : { error: "Managed member comparison failed", ...result }; + return result.ok + ? result.value + : { error: "Managed member comparison failed", ...result.error }; }), ), }); diff --git a/src/cli/nativeCallCommands.ts b/src/cli/nativeCallCommands.ts index e518c7afcc..f8d5ee96ba 100644 --- a/src/cli/nativeCallCommands.ts +++ b/src/cli/nativeCallCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; @@ -51,7 +52,7 @@ export const registerNativeCallCommands = ( OPERATION, z.record(z.string(), jsonValueSchema).parse(parsed.data), logger, - ); + ).then(projectCliAnalysisResult); }), }); }; diff --git a/src/cli/pcapCommands.ts b/src/cli/pcapCommands.ts index a2e1fc447f..954943bebe 100644 --- a/src/cli/pcapCommands.ts +++ b/src/cli/pcapCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js"; import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js"; @@ -27,7 +28,7 @@ export const registerPcapCommands = ( "pcap", operation, signal, - ), + ).then(projectCliAnalysisResult), ), ); diff --git a/src/cli/projectCliAnalysisResult.ts b/src/cli/projectCliAnalysisResult.ts new file mode 100644 index 0000000000..5e5f838ed3 --- /dev/null +++ b/src/cli/projectCliAnalysisResult.ts @@ -0,0 +1,7 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import type { Result } from "../domain/result.js"; + +/** Flatten a finalized outcome only when returning the command's JSON output. */ +export const projectCliAnalysisResult = ( + result: Result, +): Value | JsonValue => (result.ok ? result.value : result.error); diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index efdf21d409..a645bb3285 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -185,7 +185,10 @@ export const registerReverseEngineeringCommands = ( ), ); return result.ok - ? publishCliAnalysisEvidence(result.value, delivery) + ? publishCliAnalysisEvidence( + { ok: true, value: result.value }, + delivery, + ) : projectAnalysisError(result.error); }), ), @@ -225,7 +228,10 @@ export const registerReverseEngineeringCommands = ( ), ); return result.ok - ? publishCliAnalysisEvidence(result.value, delivery) + ? publishCliAnalysisEvidence( + { ok: true, value: result.value }, + delivery, + ) : projectAnalysisError(result.error); }, ), @@ -309,7 +315,11 @@ export const registerReverseEngineeringCommands = ( const started = await manager.start(signal, { inferiorTty: options.inferiorTty, }); - if (!started.ok) return projectAnalysisError(started.error); + if (!started.ok) + return { + ok: false as const, + error: projectAnalysisError(started.error), + }; sessionId = started.value.session_id; const executed = await manager.execute( sessionId, @@ -317,8 +327,11 @@ export const registerReverseEngineeringCommands = ( signal, ); return executed.ok - ? executed.value.evidence - : projectAnalysisError(executed.error); + ? { ok: true as const, value: executed.value.evidence } + : { + ok: false as const, + error: projectAnalysisError(executed.error), + }; } finally { let cleanupFailed = false; if (sessionId !== undefined) { diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index 1673d5734d..2ee5c42307 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { inspectSignalDecodersInputSchema } from "../domain/signals/signalDecoders.js"; import { z } from "incur"; import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; @@ -64,7 +65,7 @@ export const registerSignalCommands = ( "sigrok-recording", "inspect_signal_decoders", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); @@ -119,7 +120,7 @@ export const registerSignalCommands = ( "sigrok-recording", "decode_signal_recording", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); diff --git a/src/cli/sourceToBundleCommand.ts b/src/cli/sourceToBundleCommand.ts index 82933714ec..92f61e8647 100644 --- a/src/cli/sourceToBundleCommand.ts +++ b/src/cli/sourceToBundleCommand.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; @@ -68,9 +69,9 @@ export const registerSourceToBundleCommand = ( "compare_source_to_bundle", signal, ); - return "evidence_id" in result - ? result - : { error: "Application workflow failed", ...result }; + return result.ok + ? result.value + : { error: "Application workflow failed", ...result.error }; }), ), }); diff --git a/src/cli/sqliteCommands.ts b/src/cli/sqliteCommands.ts index 2e69ba1aec..72cce0429d 100644 --- a/src/cli/sqliteCommands.ts +++ b/src/cli/sqliteCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { createSqliteDatabaseService } from "../composition/sqlite.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -80,7 +81,7 @@ export const registerSqliteCommands = ( "sqlite", "inspect_sqlite_database", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cli/strictPositionals.ts b/src/cli/strictPositionals.ts index dba17a478c..e897276925 100644 --- a/src/cli/strictPositionals.ts +++ b/src/cli/strictPositionals.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { Errors, Parser, z } from "incur"; -import { splitIncurBuiltinArguments } from "../cliOutput.js"; +import { splitIncurBuiltinArguments } from "../cliBuiltinArguments.js"; import type { CliInstance } from "./types.js"; import { visitRegisteredCommands } from "./visitRegisteredCommands.js"; diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index aa2b105e08..e84cc46a1e 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; @@ -108,7 +109,7 @@ const registerNativeUiCommands = ( "observe_native_ui", nativeUiScope(options), logger, - ), + ).then(projectCliAnalysisResult), ), }); const operation = "capture_native_ui_scenario"; @@ -152,7 +153,7 @@ const registerNativeUiCommands = ( ? scenario : { ...scenario, native_ui_deadline_ms: deadline }, logger, - ); + ).then(projectCliAnalysisResult); }), }); }; @@ -209,7 +210,7 @@ const registerNativeCommands = ( ? {} : { native_command_timeout_ms: options.nativeCommandTimeoutMs }, logger, - ), + ).then(projectCliAnalysisResult), ), }); } @@ -239,7 +240,7 @@ const registerNativeCommands = ( : { native_command_timeout_ms: options.nativeCommandTimeoutMs }), }, logger, - ), + ).then(projectCliAnalysisResult), ), }); cli.command(CLI_COMMANDS.demangleSwift, { @@ -263,7 +264,7 @@ const registerNativeCommands = ( : { native_command_timeout_ms: options.nativeCommandTimeoutMs }), }, logger, - ), + ).then(projectCliAnalysisResult), ), }); }; diff --git a/src/cli/wasmCommands.ts b/src/cli/wasmCommands.ts index 9748f03e63..a2651fb73b 100644 --- a/src/cli/wasmCommands.ts +++ b/src/cli/wasmCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; @@ -85,7 +86,7 @@ export const registerWasmCommands = ( "wabt-artifact", "inspect_wasm_artifact", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cli/webModuleCommands.ts b/src/cli/webModuleCommands.ts index fc1ab27036..cef3f31a3a 100644 --- a/src/cli/webModuleCommands.ts +++ b/src/cli/webModuleCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { createWebModuleTraceService } from "../composition/webModules.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -67,7 +68,7 @@ export const registerWebModuleCommands = ( "web-module-trace", "trace_web_module_imports", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cli/webRuntimeCommands.ts b/src/cli/webRuntimeCommands.ts index 083ca57ad9..10592be4bb 100644 --- a/src/cli/webRuntimeCommands.ts +++ b/src/cli/webRuntimeCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { Cli, z } from "incur"; import { createWebRuntimeService } from "../composition/webRuntime.js"; import { browserCliError } from "./browserCliError.js"; @@ -70,7 +71,7 @@ export const registerWebRuntimeCommands = ( service.provider.identity().id, "observe_web_execution", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); @@ -111,7 +112,7 @@ export const registerWebRuntimeCommands = ( service.provider.identity().id, "inspect_web_event_listeners", signal, - ); + ).then(projectCliAnalysisResult); }), ), }); diff --git a/src/cli/webScriptCommands.ts b/src/cli/webScriptCommands.ts index 17303ebb43..cade5933a7 100644 --- a/src/cli/webScriptCommands.ts +++ b/src/cli/webScriptCommands.ts @@ -47,9 +47,9 @@ export const registerWebScriptCommands = ( signal, "Web script export resources", ); - return "evidence_id" in result - ? result - : { error: "Script export failed", ...result }; + return result.ok + ? result.value + : { error: "Script export failed", ...result.error }; }), ), }); diff --git a/src/cli/webSourceLocationCommands.ts b/src/cli/webSourceLocationCommands.ts index 56e8bd412b..136083fb53 100644 --- a/src/cli/webSourceLocationCommands.ts +++ b/src/cli/webSourceLocationCommands.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { createWebSourceLocationService } from "../composition/webSourceLocations.js"; import { webSourceLocationInputSchema } from "../domain/webSourceLocation.js"; @@ -62,7 +63,7 @@ export const registerWebSourceLocationCommands = ( "web-source-location", "trace_web_source_location", signal, - ), + ).then(projectCliAnalysisResult), ), ), }); diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 11234a26ac..2f3ec96975 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -14,14 +14,18 @@ import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js"; import { logCliCommand } from "./cliLogging.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; -import { browserScenarioSchema } from "./domain/browserScenario.js"; +import { + browserScenarioSchema, + type BrowserScenario, +} from "./domain/browserScenario.js"; +import { err, ok, type Result } from "./domain/result.js"; import type { BrowserScenarioCapturePort } from "./application/BrowserScenarioCapturePort.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "pino"; const OPERATION = "capture_browser_scenario"; -/** Register the one-shot controlled browser scenario command. */ +/** Register controlled browser execution and offline validation commands. */ export const registerBrowserScenarioCommands = ( cli: ReturnType, logger: Logger, @@ -38,7 +42,9 @@ export const registerBrowserScenarioCommands = ( args: z.object({ inputJson: z .string() - .describe("Inline browser scenario JSON or JSON file path"), + .describe( + "Inline browser scenario JSON, JSON file path, or '-' for stdin", + ), }), options: z.object(analysisEvidenceDeliveryOptions), run: ({ args, options: delivery }) => @@ -50,24 +56,12 @@ export const registerBrowserScenarioCommands = ( operation: OPERATION, }); if (deliveryError !== undefined) return cliError(deliveryError); - const input = await parseCliJsonInput( + const scenario = await parseBrowserScenarioInput( args.inputJson, - OPERATION, signal, + "Browser scenario capture failed", ); - if (!input.ok) return input.error; - const scenario = browserScenarioSchema.safeParse( - resolveCliJsonPaths(input.value, [["browser", "executable_path"]]), - ); - if (!scenario.success) - return cliError( - analysisInputErrorFromIssues( - OPERATION, - scenario.error.issues, - input.value, - { cause: scenario.error }, - ), - ); + if (!scenario.ok) return scenario.error; // Load Playwright only for this command, not during CLI startup. const provider = options.createProvider === undefined @@ -77,7 +71,7 @@ export const registerBrowserScenarioCommands = ( ) : options.createProvider(environment); const result = await finishAnalysisCommand( - () => captureBrowserScenario(provider, scenario.data, { signal }), + () => captureBrowserScenario(provider, scenario.value, { signal }), { close: async () => { await provider?.close?.(); @@ -89,8 +83,11 @@ export const registerBrowserScenarioCommands = ( undefined, { failureProjection: "initial" }, ); - if (!("evidence_id" in result)) - return { error: "Browser scenario capture failed", ...result }; + if (!result.ok) + return { + error: "Browser scenario capture failed", + ...result.error, + }; return publishCliAnalysisEvidence(result, { providerId: "rea-playwright-browser-scenario", operation: OPERATION, @@ -101,11 +98,77 @@ export const registerBrowserScenarioCommands = ( }), ), }); + registerBrowserScenarioValidationCommand(cli, logger); +}; + +/** Validate the execution contract without provider or environment access. */ +const registerBrowserScenarioValidationCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.validateBrowserScenario, { + description: + "Validate one browser scenario without launching or attaching to a browser", + args: z.object({ + inputJson: z + .string() + .describe( + "Inline browser scenario JSON, JSON file path, or '-' for stdin", + ), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand( + logger, + CLI_COMMANDS.validateBrowserScenario, + async () => { + const scenario = await parseBrowserScenarioInput( + args.inputJson, + signal, + "Browser scenario validation failed", + ); + if (!scenario.ok) return scenario.error; + return { + valid: true, + actions: scenario.value.actions.length, + secrets: scenario.value.secrets.length, + }; + }, + ), + ), + }); +}; + +/** Keep caller-path normalization and canonical diagnostics identical for both commands. */ +const parseBrowserScenarioInput = async ( + inputJson: string, + signal: AbortSignal, + headline: string, +): Promise> => { + const input = await parseCliJsonInput(inputJson, OPERATION, signal); + if (!input.ok) return input; + const scenario = browserScenarioSchema.safeParse( + resolveCliJsonPaths(input.value, [["browser", "executable_path"]]), + ); + return scenario.success + ? ok(scenario.data) + : err( + cliError( + analysisInputErrorFromIssues( + OPERATION, + scenario.error.issues, + input.value, + { cause: scenario.error }, + ), + headline, + ), + ); }; const cliError = ( error: Parameters[0], + headline = "Browser scenario capture failed", ): JsonValue => ({ - error: "Browser scenario capture failed", + error: headline, ...projectAnalysisError(error), }); diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index b56abae118..b0d397d019 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -158,6 +158,7 @@ export const CLI_COMMANDS = Object.freeze({ captureWebScreenshot: "capture-web-screenshot", compareWebScreenshots: "compare-web-screenshots", captureBrowserScenario: "capture-browser-scenario", + validateBrowserScenario: "validate-browser-scenario", listElectronTargets: "list-electron-targets", inspectElectronPage: "inspect-electron-page", inspectElectronFuses: "inspect-electron-fuses", diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 733bb927b2..faf66a0f26 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -1,3 +1,4 @@ +import { withCommandCancellation } from "./cli/commandCancellation.js"; import { safeParseJson } from "./domain/safeJson.js"; import { AnalysisInputError } from "./domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; @@ -27,10 +28,12 @@ export const registerEvidenceCommands = ( path: z.string().describe("Evidence bundle JSON path"), }), run: ({ args }) => - logCliCommand(logger, "evidence-import", async () => { - const imported = await importEvidenceBundleCommand(args.path); - return imported.ok ? imported.value : cliError(imported.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "evidence-import", async () => { + const imported = await importEvidenceBundleCommand(args.path, signal); + return imported.ok ? imported.value : cliError(imported.error); + }), + ), }); cli.command(CLI_COMMANDS.evidenceExport, { description: "Validate and atomically export canonical Evidence JSON", @@ -42,14 +45,17 @@ export const registerEvidenceCommands = ( overwrite: z.boolean().default(false).describe("Replace output file"), }), run: ({ args, options }) => - logCliCommand(logger, "evidence-export", async () => { - const exported = await exportEvidenceBundleCommand( - args.source, - args.output, - options.overwrite, - ); - return exported.ok ? exported.value : cliError(exported.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "evidence-export", async () => { + const exported = await exportEvidenceBundleCommand( + args.source, + args.output, + options.overwrite, + signal, + ); + return exported.ok ? exported.value : cliError(exported.error); + }), + ), }); cli.command(CLI_COMMANDS.compare, { description: "Compare two canonical Evidence bundles", @@ -66,44 +72,47 @@ export const registerEvidenceCommands = ( ), }), run: ({ args, options }) => - logCliCommand(logger, "compare", async () => { - const decoded = - options.record_pairs === undefined - ? { ok: true as const, value: [] } - : safeParseJson(options.record_pairs); - if (!decoded.ok) - return cliError( - new AnalysisInputError( - "compare_bundles", - { cause: decoded.cause }, - [ - { - path: ["record_pairs"], - reason: "invalid_format", - expected: "JSON array", - }, - ], - ), - ); - const input = { record_pairs: decoded.value }; - const selected = bundleComparisonInputSchema - .pick({ record_pairs: true }) - .safeParse(input); - if (!selected.success) - return cliError( - analysisInputErrorFromIssues( - "compare_bundles", - selected.error.issues, - input, - ), - ); - const compared = await compareEvidenceBundlesCommand({ - leftPath: args.left, - rightPath: args.right, - recordPairs: selected.data.record_pairs, - }); - return compared.ok ? compared.value : cliError(compared.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "compare", async () => { + const decoded = + options.record_pairs === undefined + ? { ok: true as const, value: [] } + : safeParseJson(options.record_pairs); + if (!decoded.ok) + return cliError( + new AnalysisInputError( + "compare_bundles", + { cause: decoded.cause }, + [ + { + path: ["record_pairs"], + reason: "invalid_format", + expected: "JSON array", + }, + ], + ), + ); + const input = { record_pairs: decoded.value }; + const selected = bundleComparisonInputSchema + .pick({ record_pairs: true }) + .safeParse(input); + if (!selected.success) + return cliError( + analysisInputErrorFromIssues( + "compare_bundles", + selected.error.issues, + input, + ), + ); + const compared = await compareEvidenceBundlesCommand({ + leftPath: args.left, + rightPath: args.right, + recordPairs: selected.data.record_pairs, + signal, + }); + return compared.ok ? compared.value : cliError(compared.error); + }), + ), }); }; diff --git a/src/cliJsonInput.ts b/src/cliJsonInput.ts index d3df8608bb..fcdc101f47 100644 --- a/src/cliJsonInput.ts +++ b/src/cliJsonInput.ts @@ -100,7 +100,7 @@ export const parseCliJsonInput = async ( const physicalCause = cause.physicalCause; const systemCode = accessDeniedSystemCode(physicalCause); return systemCode === undefined - ? jsonFileError(value, operation, "read-failed", physicalCause) + ? jsonFileReadError(value, operation, physicalCause) : jsonAccessDeniedError(value, operation, systemCode, physicalCause); } if (signal?.aborted && isJsonInputCancellation(cause, signal)) @@ -120,7 +120,7 @@ export const parseCliJsonInput = async ( typeof cause.code !== "string" ) throw cause; - return jsonFileError(value, operation, "read-failed", cause); + return jsonFileReadError(value, operation, cause); } }; @@ -332,26 +332,21 @@ const inputError = (operation: string): JsonValue => ({ ), }); -const jsonFileError = ( - path: string | undefined, +const jsonFileReadError = ( + path: string, operation: string, - reason: "invalid-json" | "read-failed", - cause?: unknown, + cause: unknown, ) => ({ ok: false as const, error: { error: "Application workflow failed", ...projectAnalysisError( - new AnalysisInputError( - operation, - cause === undefined ? undefined : { cause }, - reason === "invalid-json" - ? [{ path: [], reason: "invalid_format", expected: "JSON" }] - : [readFailureIssue(path, cause)], - ), + new AnalysisInputError(operation, { cause }, [ + readFailureIssue(path, cause), + ]), ), - ...(path === undefined ? {} : { input_path: path }), - input_reason: reason, + input_path: path, + input_reason: "read-failed" as const, }, }); @@ -373,7 +368,7 @@ const jsonAccessDeniedError = ( }); /** Preserve file-selection and system failures alongside the requested path. */ -const readFailureIssue = (path: string | undefined, cause: unknown) => { +const readFailureIssue = (path: string, cause: unknown) => { const code = typeof cause === "object" && cause !== null && @@ -386,7 +381,7 @@ const readFailureIssue = (path: string | undefined, cause: unknown) => { reason: "invalid_value" as const, message: cause instanceof NonRegularFileReadError && cause.code === "ENOTFILE" - ? `The JSON input must be a regular file${code}: ${path ?? ""}` - : `The JSON input file could not be read${code}: ${path ?? ""}`, + ? `The JSON input must be a regular file${code}: ${path}` + : `The JSON input file could not be read${code}: ${path}`, }; }; diff --git a/src/cliOutput.ts b/src/cliOutput.ts index eb6bb06bbc..4b81e19c35 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -1,8 +1,3 @@ -export { - normalizeCliArguments, - splitIncurBuiltinArguments, - type IncurBuiltinArgumentSplit, -} from "./cliBuiltinArguments.js"; import { declaredSensitiveValues, splitIncurBuiltinArguments, diff --git a/src/contracts/android/androidToolContracts.ts b/src/contracts/android/androidToolContracts.ts index dca2c9f51e..52c75a1f48 100644 --- a/src/contracts/android/androidToolContracts.ts +++ b/src/contracts/android/androidToolContracts.ts @@ -4,7 +4,12 @@ import { androidPackageInspectionInputSchema, androidResultSchemas, } from "../../domain/android/androidAnalysis.js"; -import { androidPackageSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js"; +import { + androidPackageSummaryViewResultSchema, + androidManifestViewResultSchema, + androidComponentsViewResultSchema, + androidComponentSummaryViewResultSchema, +} from "../../domain/analysisView/analysisView.js"; import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; @@ -15,13 +20,16 @@ export const ANDROID_TOOL_CONTRACTS = [ name: "inspect_android_package", ...toolContractMetadata("inspect_android_package"), description: - 'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts, with a bounded static component projection. Omitted or complete detail preserves the complete result. Select detail: "summary" for all declarations, engine identity and counts without decoded manifest text or duplicate raw calls; retains the complete source and returns a distinct linked inspect_analysis_view summary. Read the exact retained manifest with view kind android, facet manifest, offset and limit (UTF-16 code units), using normalized_result.parent_evidence_id. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.', + 'Inspect a local APK\'s package, SDK and permission declarations, decoded manifest and class/resource counts, with a bounded static component projection. Omitted or complete detail preserves the complete result. Select detail: "summary" for all declarations, engine identity and counts without decoded manifest text or duplicate raw calls; retains the complete source and returns a distinct linked inspect_analysis_view summary. With summary detail, optionally select view kind android: facet components supports offset/limit, fields and exact kinds/names/actions/exported filters; source_index preserves the retained parent array ordinal and declaration_coverage preserves historical unknowns. Facet component-summary counts selected kinds and exported declarations separately from defaults. Facet manifest pages exact UTF-16 text. Reuse the same views with inspect_analysis_view and normalized_result.parent_evidence_id; complete detail rejects view selection. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and JDK; uses one worker in an isolated temporary workspace.', kind: "android-provider", inputSchema: androidPackageInspectionInputSchema, outputSchema: evidenceResultOf( z.union([ androidResultSchemas.inspect_android_package, androidPackageSummaryViewResultSchema, + androidManifestViewResultSchema, + androidComponentsViewResultSchema, + androidComponentSummaryViewResultSchema, ]), ), examples: [ @@ -30,6 +38,28 @@ export const ANDROID_TOOL_CONTRACTS = [ title: "Inspect APK declarations with retained manifest", input: { path: "/tmp/Example.apk", detail: "summary" }, }, + { + title: "Read selected VIEW handlers with retained complete Evidence", + input: { + path: "/tmp/Example.apk", + detail: "summary", + view: { + kind: "android", + facet: "components", + offset: 0, + limit: 20, + actions: ["android.intent.action.VIEW"], + fields: [ + "kind", + "name", + "enabled", + "process", + "intent_filters", + "location", + ], + }, + }, + }, ], }, { diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 016fa198d1..fe2464a0f6 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -111,6 +111,7 @@ const runtimeMembers = (): ManagedMemberInspection => { max_stack: 8, init_locals: false, local_var_sig_token: null, + local_var_signature: null, il_size: 1, il_sha256: "4".repeat(64), normalized_il_sha256: "5".repeat(64), diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index 023fa81e9c..c421563732 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -1,15 +1,7 @@ import type { z } from "zod"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { ToolEffects } from "./toolEffects.js"; - -/** Caller-visible MCP execution hints. */ -interface ToolAnnotations { - readonly readOnlyHint: boolean; - readonly destructiveHint: boolean; - readonly idempotentHint: boolean; - readonly openWorldHint: boolean; -} +import type { DerivedToolAnnotations, ToolEffects } from "./toolEffects.js"; /** One validated example request for an MCP tool contract. */ export interface ToolExample { @@ -64,7 +56,7 @@ export interface ToolContract< readonly inputSchema: InputSchema; readonly outputSchema: OutputSchema; readonly effects: ToolEffects; - readonly annotations: ToolAnnotations; + readonly annotations: DerivedToolAnnotations; /** * Results, successful or failed, can contain text that originates from the * analysis target. MCP delivery marks such results as untrusted data. diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 2e685f7641..6e700ff16c 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -49,7 +49,6 @@ const browserEvidence = effects({ mutatesSession: true, accessesNetwork: true, }); -const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { @@ -633,10 +632,10 @@ export const TOOL_EFFECTS: Readonly> = { inspect_managed_artifact: evidence, inspect_managed_members: evidence, inspect_managed_native_boundaries: evidence, - compare_managed_members: sessionEvidence, - verify_managed_native_boundaries: sessionEvidence, - import_managed_reconstruction: sessionEvidence, - project_managed_application_graph: sessionEvidence, + compare_managed_members: evidence, + verify_managed_native_boundaries: evidence, + import_managed_reconstruction: evidence, + project_managed_application_graph: evidence, list_browser_targets: browserEvidence, inspect_web_page: browserEvidence, analyze_web_bundle: effects({ @@ -646,9 +645,9 @@ export const TOOL_EFFECTS: Readonly> = { }), observe_web_session: browserEvidence, discover_webmcp_tools: browserEvidence, - compare_web_captures: sessionEvidence, + compare_web_captures: evidence, capture_web_screenshot: browserEvidence, - compare_web_screenshots: sessionEvidence, + compare_web_screenshots: evidence, capture_browser_scenario: effects({ mutatesTarget: true, mutatesSession: true, @@ -732,16 +731,16 @@ export const TOOL_EFFECTS: Readonly> = { executesForeignCode: true, idempotent: false, }), - compare_process_captures: sessionEvidence, - compare_artifacts: sessionEvidence, - compare_wasm_interfaces: sessionEvidence, - compare_functions: sessionEvidence, - match_native_functions: sessionEvidence, + compare_process_captures: evidence, + compare_artifacts: evidence, + compare_wasm_interfaces: evidence, + compare_functions: evidence, + match_native_functions: evidence, compare_bundles: effects({ mutatesSession: true }), - find_changed_behavior: sessionEvidence, - build_call_path: sessionEvidence, - correlate_static_and_runtime: sessionEvidence, - verify_reconstruction: sessionEvidence, + find_changed_behavior: evidence, + build_call_path: evidence, + correlate_static_and_runtime: evidence, + verify_reconstruction: evidence, list_unknowns: effects(), record_unknown: effects({ mutatesSession: true, idempotent: false }), update_unknown: effects({ mutatesSession: true, idempotent: false }), diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index abc2e51ae1..1bcbe9e43b 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -19,8 +19,12 @@ import { AnalysisResourceConstraintError, resourceConstraintRecovery, } from "../domain/analysisErrorCore.js"; -import { randomUUID } from "node:crypto"; -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { + randomUUID, + createHmac, + randomBytes, + timingSafeEqual, +} from "node:crypto"; import { createConnection } from "node:net"; import { lstat, opendir } from "node:fs/promises"; import { join } from "node:path"; @@ -40,6 +44,7 @@ import { CutterBridgeResponseFrames } from "./cutterAuthenticatedResponse.js"; import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { appendCallerPath } from "../filesystem/callerPath.js"; import { bridgeRequestLimitMismatchError, bridgeRequestOversizeError, @@ -895,7 +900,7 @@ export class CutterBridgeClient { ); } else { const boundedBytes = await withArtifactRegularFile( - join(this.#directory, entry), + appendCallerPath(this.#directory, entry, this.#platform), this.#descriptorResources, signal, async ({ handle: file, initial: info }) => { @@ -1155,14 +1160,18 @@ const resolveBridgeDirectory = ( if (configured !== undefined && configured !== "") { if (configured === "~") return homeDirectory; if (configured.startsWith("~/")) - return join(homeDirectory, configured.slice(2)); + return platform === "win32" + ? join(homeDirectory, configured.slice(2)) + : appendCallerPath(homeDirectory, configured.slice(2), platform); if (platform === "win32" && configured.startsWith("~\\")) return join(homeDirectory, configured.slice(2)); return configured; } return platform === "win32" && localAppData ? join(localAppData, "REA", "CutterBridge") - : join(homeDirectory, ".cache", "rea", "cutter-bridge"); + : platform === "win32" + ? join(homeDirectory, ".cache", "rea", "cutter-bridge") + : appendCallerPath(homeDirectory, ".cache/rea/cutter-bridge", platform); }; class CutterDiscoveryFailure extends Error { diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 56e6b6fbe4..d758ee0739 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,3 +1,4 @@ +import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js"; import type { AnalysisSettingLimit } from "./analysisSettings.js"; import type { JsonValue } from "./jsonValue.js"; @@ -300,6 +301,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = + | SavedAnalysisSnapshotPartialObservation | WebArtifactAcquisition | WebTraceObservation | CutterBridgeDiscovery diff --git a/src/domain/analysisErrorCancellation.ts b/src/domain/analysisErrorCancellation.ts new file mode 100644 index 0000000000..da7164bb16 --- /dev/null +++ b/src/domain/analysisErrorCancellation.ts @@ -0,0 +1,24 @@ +import { + primaryAnalysisError, + type AnalysisError, +} from "./analysisErrorBase.js"; +import { AnalysisCancelledError } from "./analysisErrorCore.js"; + +/** Keep the established failure while retaining a later caller cancellation once. */ +export const analysisErrorWithCancellation = ( + failure: AnalysisError | undefined, + cancellation: AnalysisCancelledError, +): AnalysisError => { + if (failure === undefined) return cancellation; + const primary = primaryAnalysisError(failure); + if ( + !(primary instanceof AnalysisCancelledError) && + !primary.secondaryFailures.some( + (secondary) => + secondary instanceof AnalysisCancelledError && + secondary.cause === cancellation.cause, + ) + ) + primary.retainSecondaryFailure(cancellation); + return failure; +}; diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts index 279818275d..46dcfa24eb 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.test.ts @@ -37,6 +37,7 @@ import { import { updatedFields } from "./environmentStateWriteApply.js"; import { retainFixed, + retainCreateExemplar, type EffectContext, } from "./environmentStateWriteEffects.js"; @@ -3113,6 +3114,61 @@ it("learns omitted fields using the raw ID resolved by the item owner", () => { }); }); +describe("create exemplar retention", () => { + const facts = () => { + const snapshot = { id: "held", payload: "p".repeat(1_000_000) }; + const held = Array.from({ length: 1000 }, () => snapshot); + return { + entity: { id: "new", url: "/new" }, + call: { + path: "/items", + pathValues: new Map(), + body: { payload: "x".repeat(10_000) }, + }, + idField: "id", + startMs: undefined, + collection: held, + type: held, + }; + }; + it("charges temporary request facts and shared snapshot references without rebilling owned entity payloads", () => { + let retained = 0; + const context = { + io: { + retain: (bytes: number) => { + retained += bytes; + }, + }, + } as unknown as EffectContext; + const input = facts(); + expect(retainCreateExemplar(context, input, [])).toBe(input); + expect(retained).toBeGreaterThan(28_000); + expect(retained).toBeLessThan(40_000); + }); + it("retains no exemplar when every string field is already explained", () => { + const retain = vi.fn(); + const context = { io: { retain } } as unknown as EffectContext; + expect( + retainCreateExemplar(context, facts(), [ + { field: "id", rule: "echo" }, + { field: "url", rule: "echo" }, + ]), + ).toBeUndefined(); + expect(retain).not.toHaveBeenCalled(); + }); + it("does not adopt request facts when the retained budget refuses them", () => { + const context = { + io: { + retain: () => { + throw new Error("budget exhausted"); + }, + }, + } as unknown as EffectContext; + expect(() => retainCreateExemplar(context, facts(), [])).toThrow( + "budget exhausted", + ); + }); +}); it.each([ { label: "point read with simultaneous unknown action", diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 1f564d777c..a461897b90 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -293,6 +293,7 @@ export class EnvironmentStateWriteSimulation { unstamped: new Set(), kept: new Set(), fixedWidth: new Map(), + ampersands: new Map(), linked: link.collection, }); } diff --git a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts index 81e64b7066..0c9d7128e3 100644 --- a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts +++ b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts @@ -147,23 +147,21 @@ export const admitHarCaptureParent = ( return unsupported( `${viewKind} does not support capture format ${parsed.data.format}.`, ); - // Environment views cite the report path and digest, so their parent's subject must be exactly them; + // Environment views preserve the recorded report path and authenticate its digest; // the api-surface view keeps the admission its existing callers rely on. const { artifact } = parsed.data; + const subject = parent.subject; if ( viewKind !== "api-surface" && artifact.sha256 !== null && - (parent.subject == null || - parent.subject.local_path !== artifact.path || - parent.subject.digest.sha256 !== artifact.sha256) + (subject == null || subject.digest.sha256 !== artifact.sha256) ) return err( analysisViewInputError([ { path: ["source", "subject"], reason: "invalid_value", - message: - "Capture parent subject must identify the report's exact path and SHA-256.", + message: "Capture parent subject must identify the report's SHA-256.", }, ]), ); diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 595fc9ff3f..d52c2433fc 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -625,6 +625,69 @@ it("projects layout summary, facet, item, and stable pages", () => { }); }); +it.each(["sections", "symbols"] as const)( + "selects %s by exact source bytes and preserves opaque names by index", + (collection) => { + const layout = analysisViewLayoutFixture(); + const rows = layout[collection]; + const original = rows[0]; + if (original === undefined) throw new Error("missing layout row"); + const opaque = { + ...original, + name: { + ...original.name, + display: "�name", + bytes_base64: Buffer.from([0xff, ...Buffer.from("name")]).toString( + "base64", + ), + }, + }; + const valid = { + ...rows[1], + name: { + ...original.name, + display: "provider-formatted-name", + bytes_base64: Buffer.from("�name").toString("base64"), + }, + }; + const parent = { + ...layoutParent(), + normalizedResult: { + ...layout, + [collection]: [opaque, valid], + }, + }; + const selected = projectAnalysisView(parent, { + kind: "item", + collection, + selector: { name: "�name" }, + }); + expect(selected).toMatchObject({ ok: true, value: { item: valid } }); + expect( + projectAnalysisView(parent, { + kind: "item", + collection, + selector: { name: "\ud800name" }, + }).ok, + ).toBe(false); + expect( + projectAnalysisView(parent, { + kind: "item", + collection, + selector: { name: "provider-formatted-name" }, + }).ok, + ).toBe(false); + expect( + projectAnalysisView(parent, { + kind: "item", + collection, + selector: { index: 0 }, + }), + ).toMatchObject({ ok: true, value: { item: opaque } }); + expect(parent.normalizedResult[collection]).toEqual([opaque, valid]); + }, +); + it("rejects ambiguous layout names with candidate indexes", () => { const parent = layoutParent(); const result = projectAnalysisView(parent, { diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index cfd24de9c0..323cb62f3e 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,4 @@ +import { analysisViewCoverageSchema } from "./analysisViewCoverage.js"; import { javaScriptRouteInventorySourceSchema, javaScriptRouteInventorySchema, @@ -303,6 +304,12 @@ import { androidPackageSummarySchema, androidViewRequestSchema, androidManifestViewItemSchema, + androidManifestViewRequestSchema, + androidComponentsViewRequestSchema, + androidComponentSummaryViewRequestSchema, + androidComponentViewRowSchema, + androidComponentViewContextSchema, + androidComponentSummarySchema, } from "./androidPackageView.js"; import { projectDartAotView, @@ -326,7 +333,13 @@ const facetViewSchema = z.strictObject({ const indexSelectorSchema = z.strictObject({ index: z.int().nonnegative(), }); -const nameSelectorSchema = z.strictObject({ name: z.string() }); +const nameSelectorSchema = z.strictObject({ + name: z + .string() + .describe( + "Exact UTF-8 section or symbol name, matched against retained source bytes. Use index for non-UTF-8 names or unresolved name bytes.", + ), +}); const pathSelectorSchema = z.strictObject({ path: z .string() @@ -480,14 +493,6 @@ export const inspectAnalysisViewInputSchema = z.strictObject({ view: analysisViewRequestSchema, }); -const coverageSchema = z.strictObject({ - status: z.enum(["complete-within-view", "page", "empty"]), - examined: z.int().nonnegative(), - total: z.int().nonnegative(), - next_offset: z.int().nonnegative().nullable(), - exhausted: z.boolean(), -}); - const artifactSchema = z.strictObject({ path: z .string() @@ -566,7 +571,7 @@ const viewResultBase = { parent_digest: digestSchema, view_digest: digestSchema, artifact: artifactSchema, - coverage: coverageSchema, + coverage: analysisViewCoverageSchema, limitations: z.array(z.string()), unknowns: z.array(z.string()), } as const; @@ -859,6 +864,40 @@ export const androidPackageSummaryViewResultSchema = z.strictObject({ summary: androidPackageSummarySchema, }); +/** Legacy manifest views keep their original shape; new facets are explicit. */ +export const androidManifestViewResultSchema = z.strictObject({ + ...viewResultBase, + kind: z.literal("android"), + facet: z.literal("manifest").optional(), + view: androidManifestViewRequestSchema, + item: androidManifestViewItemSchema, + parent_operation: z.literal("inspect_android_package"), +}); +export const androidComponentsViewResultSchema = z.strictObject({ + ...viewResultBase, + ...androidComponentViewContextSchema.shape, + kind: z.literal("android"), + facet: z.literal("components"), + view: androidComponentsViewRequestSchema, + items: z.array(androidComponentViewRowSchema), + byte_limited: z.boolean(), + parent_operation: z.literal("inspect_android_package"), +}); +export const androidComponentSummaryViewResultSchema = z.strictObject({ + ...viewResultBase, + ...androidComponentViewContextSchema.shape, + kind: z.literal("android"), + facet: z.literal("component-summary"), + view: androidComponentSummaryViewRequestSchema, + summary: androidComponentSummarySchema, + parent_operation: z.literal("inspect_android_package"), +}); +const androidPackageViewResultSchema = z.discriminatedUnion("facet", [ + androidManifestViewResultSchema, + androidComponentsViewResultSchema, + androidComponentSummaryViewResultSchema, +]); + export const pcapCaptureSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, parent_operation: z.literal("inspect_pcap_capture"), @@ -1389,13 +1428,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...managedMembersViewContextSchema.shape, parent_operation: z.literal("inspect_managed_members"), }), - z.strictObject({ - kind: z.literal("android"), - view: androidViewRequestSchema, - item: androidManifestViewItemSchema, - ...viewResultBase, - parent_operation: z.literal("inspect_android_package"), - }), + androidPackageViewResultSchema, z.strictObject({ kind: z.literal("dart-aot"), view: dartAotViewRequestSchema, @@ -1432,7 +1465,6 @@ export type InspectAnalysisViewInput = z.output< typeof inspectAnalysisViewInputSchema >; export type AnalysisViewResult = z.output; -export type AnalysisViewCoverage = z.output; /** Parent record already resolved from inline Evidence or a session reference. */ export interface AnalysisViewParent { @@ -1445,7 +1477,7 @@ export interface AnalysisViewParent { readonly environment?: Evidence["environment"]; /** The owner proved normalizedResult belongs to a sealed, deep-frozen Evidence snapshot. */ readonly sealedResult?: boolean; - /** Preserve the authenticated source identity, including explicit absence. */ + /** Subject presentation for this delivery, including explicit absence. */ readonly subject?: Evidence["subject"]; readonly limitations: readonly string[]; readonly artifact?: { readonly path: string; readonly sha256: string } | null; diff --git a/src/domain/analysisView/analysisViewCoverage.ts b/src/domain/analysisView/analysisViewCoverage.ts index c012568460..392c711095 100644 --- a/src/domain/analysisView/analysisViewCoverage.ts +++ b/src/domain/analysisView/analysisViewCoverage.ts @@ -1,5 +1,15 @@ import { uniqueSorted } from "../canonicalOrdering.js"; -import type { AnalysisViewCoverage } from "./analysisView.js"; +import { z } from "zod"; + +export const analysisViewCoverageSchema = z.strictObject({ + status: z.enum(["complete-within-view", "page", "empty"]), + examined: z.int().nonnegative(), + total: z.int().nonnegative(), + next_offset: z.int().nonnegative().nullable(), + exhausted: z.boolean(), +}); + +export type AnalysisViewCoverage = z.output; /** Coverage for a summary, facet, or single selected object. */ export const completeWithinViewCoverage = ( diff --git a/src/domain/analysisView/analysisViewText.ts b/src/domain/analysisView/analysisViewText.ts index 6c2fa8bfb9..3caff87ee4 100644 --- a/src/domain/analysisView/analysisViewText.ts +++ b/src/domain/analysisView/analysisViewText.ts @@ -1,8 +1,17 @@ import { analysisViewInputError } from "./analysisViewErrors.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { err, ok, type Result } from "../result.js"; -import type { AnalysisViewCoverage } from "./analysisView.js"; -import { pageViewCoverage } from "./analysisViewCoverage.js"; +import { z } from "zod"; +import { + pageViewCoverage, + type AnalysisViewCoverage, +} from "./analysisViewCoverage.js"; + +/** Exact text pages use UTF-16 code-unit offsets without splitting surrogate pairs. */ +export const analysisTextPageItemSchema = z.strictObject({ + text: z.string(), + unit: z.literal("utf16-code-units"), +}); const viewError = (message: string): AnalysisError => analysisViewInputError([ @@ -17,7 +26,7 @@ export const analysisTextPage = ( label = "Pseudocode", ): Result< { - readonly item: { readonly text: string; readonly unit: "utf16-code-units" }; + readonly item: Readonly>; readonly coverage: AnalysisViewCoverage; }, AnalysisError diff --git a/src/domain/analysisView/androidPackageView.test.ts b/src/domain/analysisView/androidPackageView.test.ts new file mode 100644 index 0000000000..8ce3c4523f --- /dev/null +++ b/src/domain/analysisView/androidPackageView.test.ts @@ -0,0 +1,273 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../evidence.js"; +import type { JsonValue } from "../jsonValue.js"; +import { + analysisViewRequestSchema, + androidComponentsViewResultSchema, + androidComponentSummaryViewResultSchema, + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const component = (name: string, extra: Record = {}) => ({ + kind: "activity", + declared_name: name, + name, + exported: null, + exported_default: null, + permission: null, + permission_source: "none", + intent_filter_count: 0, + actions: [] as string[], + categories: [], + foreground_service_type: null, + authorities: null, + target_activity: null, + ...extra, +}); +const report = ( + items = [ + component("example.One", { + declared_name: ".One", + exported: true, + actions: ["VIEW"], + declaration_index: 10, + declaration_coverage: "complete", + }), + component("example.Boot", { + kind: "receiver", + exported_default: true, + actions: ["BOOT"], + declaration_index: 20, + }), + component("example.One", { + declared_name: ".One", + exported: false, + actions: ["VIEW"], + declaration_index: 30, + declaration_coverage: "partial", + }), + ], +) => ({ + engine: { + name: "jadx", + version: null, + artifact_sha256: "e".repeat(64), + source_revision: null, + worker_count: 1, + heap_limit_mib: null, + }, + package_name: "example", + version_name: null, + version_code: null, + min_sdk: null, + target_sdk: "35", + permissions: [], + manifest: { + status: "complete", + text: "", + reported_total_bytes: 11, + }, + components: { + status: "partial", + items, + limitations: ["Producer component limit"], + }, + class_count: 3, + resource_count: 0, + signature_verification: "not_performed", +}); +const parent = (value = report()): AnalysisViewParent => { + const evidence = createEvidence( + { path: "/retained.apk", sha256: "a".repeat(64), format: "apk" }, + { id: "jadx", name: "JADX", version: "0.7.1" }, + { + operation: "inspect_android_package", + parameters: {}, + result: value, + limitations: ["Parent condition"], + }, + ); + return { + evidenceId: evidence.evidence_id, + operation: evidence.operation, + normalizedResult: value, + subject: evidence.subject, + limitations: evidence.limitations, + }; +}; +const project = (source: AnalysisViewParent, view: unknown) => { + const result = projectAnalysisView( + source, + analysisViewRequestSchema.parse(view), + ); + if (!result.ok) throw result.error; + return result.value; +}; + +it("keeps parent ordinals, duplicate declarations and coverage after filtering, paging and field selection", () => { + const source = parent(); + const before = JSON.stringify(source); + const page = androidComponentsViewResultSchema.parse( + project(source, { + kind: "android", + facet: "components", + names: [".One"], + actions: ["VIEW"], + kinds: ["activity"], + offset: 1, + limit: 1, + fields: ["name", "declaration_index"], + }), + ); + expect(page.items).toEqual([ + { + source_index: 2, + name: "example.One", + declaration_index: 30, + declaration_coverage: "partial", + }, + ]); + expect(page.coverage).toMatchObject({ + total: 2, + examined: 1, + exhausted: true, + }); + expect(page.source_components).toBe(3); + expect(page.source_status).toBe("partial"); + expect(page.artifact).toEqual({ + path: "/retained.apk", + sha256: "a".repeat(64), + }); + expect(page.parent_evidence_id).toBe(source.evidenceId); + expect(page.limitations).toEqual( + expect.arrayContaining(["Parent condition", "Producer component limit"]), + ); + expect(JSON.stringify(source)).toBe(before); +}); + +it("keeps omitted historical facts unknown even with empty or expanded field selections", () => { + const source = parent(report([component("legacy.Class")])); + for (const fields of [[], ["enabled", "intent_filters", "location"]]) { + const page = androidComponentsViewResultSchema.parse( + project(source, { kind: "android", facet: "components", fields }), + ); + expect(page.items).toEqual([ + { source_index: 0, declaration_coverage: "unknown" }, + ]); + } +}); + +it("counts explicit exported declarations separately from defaults and unknown historical coverage", () => { + const source = parent(); + const summary = androidComponentSummaryViewResultSchema.parse( + project(source, { kind: "android", facet: "component-summary" }), + ); + expect(summary.summary).toEqual({ + matching_components: 3, + by_kind: { + activity: 2, + "activity-alias": 0, + service: 0, + receiver: 1, + provider: 0, + }, + exported_declarations: { true: 1, false: 1, unknown: 1 }, + exported_defaults: { true: 1, false: 0, unknown: 2 }, + declaration_coverage: { complete: 1, partial: 1, unknown: 1 }, + }); + const explicit = androidComponentsViewResultSchema.parse( + project(source, { + kind: "android", + facet: "components", + exported: true, + fields: [], + }), + ); + expect(explicit.items.map((row) => row.source_index)).toEqual([0]); + const unknown = androidComponentSummaryViewResultSchema.parse( + project(source, { + kind: "android", + facet: "component-summary", + exported: null, + }), + ); + expect(unknown.summary.matching_components).toBe(1); + expect(unknown.summary.exported_defaults.true).toBe(1); +}); + +it("preserves partial source coverage on empty filtered pages and never turns empty filters into all rows", () => { + for (const selection of [ + { actions: [] }, + { names: [] }, + { kinds: [] }, + { actions: ["absent"] }, + ]) { + const page = androidComponentsViewResultSchema.parse( + project(parent(), { kind: "android", facet: "components", ...selection }), + ); + expect(page.items).toEqual([]); + expect(page.coverage.total).toBe(0); + expect(page.source_status).toBe("partial"); + expect(page.unknowns.join(" ")).toContain("does not establish absence"); + } +}); + +it("bounds selected row serialization and lets field selection recover without changing the source", () => { + const source = parent( + report([ + component("large.Class", { process: "x".repeat(2 * 1024 * 1024) }), + ]), + ); + expect( + projectAnalysisView( + source, + analysisViewRequestSchema.parse({ kind: "android", facet: "components" }), + ), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError" }, + }); + const small = androidComponentsViewResultSchema.parse( + project(source, { kind: "android", facet: "components", fields: ["name"] }), + ); + expect(small.items).toEqual([ + { source_index: 0, name: "large.Class", declaration_coverage: "unknown" }, + ]); +}); + +it("returns a resumable byte-limited page instead of silently omitting later rows", () => { + const source = parent( + report( + Array.from({ length: 3 }, (_, index) => + component(`large.${index}`, { process: "x".repeat(400_000) }), + ), + ), + ); + const first = androidComponentsViewResultSchema.parse( + project(source, { + kind: "android", + facet: "components", + limit: 3, + fields: ["process"], + }), + ); + expect(first.byte_limited).toBe(true); + expect(first.items.map((row) => row.source_index)).toEqual([0, 1]); + expect(first.coverage).toMatchObject({ + total: 3, + next_offset: 2, + exhausted: false, + }); + const last = androidComponentsViewResultSchema.parse( + project(source, { + kind: "android", + facet: "components", + offset: 2, + limit: 3, + fields: ["process"], + }), + ); + expect(last.items.map((row) => row.source_index)).toEqual([2]); + expect(last.coverage.exhausted).toBe(true); +}); diff --git a/src/domain/analysisView/androidPackageView.ts b/src/domain/analysisView/androidPackageView.ts index 4a0c9b7da1..564a883c76 100644 --- a/src/domain/analysisView/androidPackageView.ts +++ b/src/domain/analysisView/androidPackageView.ts @@ -1,20 +1,46 @@ -import { analysisViewInputError } from "./analysisViewErrors.js"; import { z } from "zod"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { androidResultSchemas } from "../android/androidAnalysis.js"; +import { + androidManifestComponentSchema, + androidManifestComponentsSchema, + type AndroidManifestComponent, +} from "../android/androidManifestComponents.js"; +import { jsonValueByteLength } from "../jsonValueByteLength.js"; +import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; -import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import type { AnalysisViewParent, AnalysisViewRequest, UnsignedAnalysisView, } from "./analysisView.js"; -import { completeWithinViewCoverage } from "./analysisViewCoverage.js"; -import { analysisTextPage } from "./analysisViewText.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + analysisTextPage, + analysisTextPageItemSchema, +} from "./analysisViewText.js"; + +export { + androidViewRequestSchema, + androidManifestViewRequestSchema, + androidComponentsViewRequestSchema, + androidComponentSummaryViewRequestSchema, +} from "../android/androidPackageQuery.js"; const packageSchema = androidResultSchemas.inspect_android_package; +const readReport = createAnalysisViewResultReader(packageSchema); +const PAGE_BYTES = 1024 * 1024; +const DIGEST_BYTES = jsonValueByteLength({ view_digest: "0".repeat(64) }) - 1; +const PROJECTION_BYTES = PAGE_BYTES - DIGEST_BYTES; -/** All package declarations remain inline; decoded manifest text stays retained. */ +/** Legacy package summary preserves its declarations; only manifest text is retained. */ export const androidPackageSummarySchema = packageSchema.extend({ manifest: packageSchema.shape.manifest.omit({ text: true }).extend({ text_code_units: z.int().nonnegative(), @@ -22,22 +48,59 @@ export const androidPackageSummarySchema = packageSchema.extend({ }), }); -/** Exact decoded text; offsets and limits count UTF-16 code units. */ -export const androidViewRequestSchema = z.strictObject({ - kind: z.literal("android"), - facet: z.literal("manifest"), - offset: z.int().nonnegative().default(0), - limit: z.int().positive(), -}); - -export const androidManifestViewItemSchema = z.strictObject({ - text: z.string(), - unit: z.literal("utf16-code-units"), +export const androidManifestViewItemSchema = analysisTextPageItemSchema.extend({ source_status: packageSchema.shape.manifest.shape.status, reported_total_bytes: packageSchema.shape.manifest.shape.reported_total_bytes, }); -/** Project an authenticated package record without reopening the APK or engine. */ +/** Parent-array ordinal and declaration coverage survive every field selection. */ +export const androidComponentViewRowSchema = androidManifestComponentSchema + .omit({ declaration_coverage: true }) + .partial() + .extend({ + source_index: z.int().nonnegative(), + declaration_coverage: + androidManifestComponentSchema.shape.declaration_coverage, + }); +export const androidComponentViewContextSchema = z.strictObject({ + source_status: androidManifestComponentsSchema.shape.status, + source_components: z.int().nonnegative(), +}); +const booleanCountsSchema = z.strictObject({ + true: z.int().nonnegative(), + false: z.int().nonnegative(), + unknown: z.int().nonnegative(), +}); +export const androidComponentSummarySchema = z.strictObject({ + matching_components: z.int().nonnegative(), + by_kind: z.strictObject({ + activity: z.int().nonnegative(), + "activity-alias": z.int().nonnegative(), + service: z.int().nonnegative(), + receiver: z.int().nonnegative(), + provider: z.int().nonnegative(), + }), + exported_declarations: booleanCountsSchema, + exported_defaults: booleanCountsSchema, + declaration_coverage: z.strictObject({ + complete: z.int().nonnegative(), + partial: z.int().nonnegative(), + unknown: z.int().nonnegative(), + }), +}); + +const choose = ( + row: T, + fields: ReadonlySet | undefined, +): Partial => { + if (fields === undefined) return { ...row }; + const out: Partial = {}; + for (const key of Object.keys(row) as (keyof T)[]) + if (fields.has(String(key))) out[key] = row[key]; + return out; +}; + +/** Project authenticated retained facts without reopening the APK or engine. */ export const projectAndroidPackageView = ( parent: AnalysisViewParent, view: AnalysisViewRequest, @@ -49,14 +112,24 @@ export const projectAndroidPackageView = ( path: ["view"], reason: "invalid_value", message: - "Android package Evidence supports summary and manifest views.", + "Android package Evidence supports summary, manifest and component views.", }, ]), ); - const parsed = parseAnalysisViewResult(packageSchema, parent); + if (parent.operation !== "inspect_android_package") + return err( + analysisViewInputError([ + { + path: ["source", "operation"], + reason: "invalid_value", + message: + "Android package views require inspect_android_package Evidence.", + }, + ]), + ); + const parsed = readReport(parent); if (!parsed.ok) return parsed; - // Engine artifact_sha256 identifies the JAR, never the APK. A missing - // authenticated APK subject cannot be repaired using the engine identity. + // The engine digest identifies the JAR, never the authenticated APK subject. if (parent.subject == null) return err( analysisViewInputError([ @@ -104,22 +177,201 @@ export const projectAndroidPackageView = ( coverage: completeWithinViewCoverage(1, 1), }); } - const page = analysisTextPage( - report.manifest.text, - view.offset, - view.limit, - "Manifest", - ); - if (!page.ok) return page; - return ok({ - kind: "android", - view, + if (view.facet === "manifest") { + const page = analysisTextPage( + report.manifest.text, + view.offset, + view.limit, + "Manifest", + ); + if (!page.ok) return page; + return ok({ + kind: "android", + view, + ...shared, + item: { + ...page.value.item, + source_status: report.manifest.status, + reported_total_bytes: report.manifest.reported_total_bytes, + }, + coverage: page.value.coverage, + }); + } + + const rows = report.components.items; + const context = { ...shared, - item: { - ...page.value.item, - source_status: report.manifest.status, - reported_total_bytes: report.manifest.reported_total_bytes, + source_status: report.components.status, + source_components: rows.length, + limitations: boundedViewMessages( + [...parent.limitations, ...report.components.limitations], + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + [ + ...shared.unknowns, + "Components are static declarations. Omitted historical declaration fields remain unknown; exported declarations and SDK defaults are distinct.", + ...(report.components.status === "complete" + ? [] + : [ + `Component source coverage is ${report.components.status}; an empty filtered result does not establish absence from the complete manifest.`, + ]), + ], + parent.evidenceId, + "unknowns", + ), + }; + const tooLarge = (): Result => + err( + new AnalysisResourceConstraintError( + "inspect_analysis_view", + "transport", + "The Android component view exceeds the analysis-view byte budget; select fewer fields or a smaller page. Complete parent Evidence remains retained.", + { + parent_evidence_id: parent.evidenceId, + limit_bytes: PAGE_BYTES, + facet: view.facet, + }, + ), + ); + // Admit caller selections and provenance before building filter indexes or output rows. + if ( + jsonValueByteLength( + { ...context, view } as unknown as JsonValue, + PROJECTION_BYTES, + ) > PROJECTION_BYTES + ) + return tooLarge(); + const kinds = view.kinds === undefined ? undefined : new Set(view.kinds); + const names = view.names === undefined ? undefined : new Set(view.names); + const actions = + view.actions === undefined ? undefined : new Set(view.actions); + const filtered = + kinds !== undefined || + names !== undefined || + actions !== undefined || + view.exported !== undefined; + const matches = (row: AndroidManifestComponent): boolean => + (kinds === undefined || kinds.has(row.kind)) && + (names === undefined || + names.has(row.name) || + names.has(row.declared_name)) && + (actions === undefined || + row.actions.some((action) => actions.has(action))) && + (view.exported === undefined || row.exported === view.exported); + + if (view.facet === "component-summary") { + const summary: z.output = { + matching_components: 0, + by_kind: { + activity: 0, + "activity-alias": 0, + service: 0, + receiver: 0, + provider: 0, + }, + exported_declarations: { true: 0, false: 0, unknown: 0 }, + exported_defaults: { true: 0, false: 0, unknown: 0 }, + declaration_coverage: { complete: 0, partial: 0, unknown: 0 }, + }; + for (const row of rows) { + if (!matches(row)) continue; + summary.matching_components++; + summary.by_kind[row.kind]++; + summary.exported_declarations[ + row.exported === null ? "unknown" : row.exported ? "true" : "false" + ]++; + summary.exported_defaults[ + row.exported_default === null + ? "unknown" + : row.exported_default + ? "true" + : "false" + ]++; + summary.declaration_coverage[row.declaration_coverage]++; + } + const result: UnsignedAnalysisView = { + ...context, + kind: "android", + facet: "component-summary", + view, + summary, + coverage: completeWithinViewCoverage(rows.length, rows.length), + }; + return jsonValueByteLength( + result as unknown as JsonValue, + PROJECTION_BYTES, + ) > PROJECTION_BYTES + ? tooLarge() + : ok(result); + } + + const envelope = { + ...context, + kind: "android" as const, + facet: "components" as const, + view, + items: [], + byte_limited: false, + coverage: { + status: "complete-within-view", + examined: Number.MAX_SAFE_INTEGER, + total: Number.MAX_SAFE_INTEGER, + next_offset: Number.MAX_SAFE_INTEGER, + exhausted: false, }, - coverage: page.value.coverage, - }); + }; + let bytes = + DIGEST_BYTES + + jsonValueByteLength(envelope as unknown as JsonValue, PROJECTION_BYTES); + if (bytes > PAGE_BYTES) return tooLarge(); + const fields = + view.fields === undefined ? undefined : new Set(view.fields); + const items: z.output[] = []; + let byte_limited = false; + let total = filtered ? 0 : rows.length; + for ( + let source_index = filtered ? 0 : Math.min(view.offset, rows.length); + source_index < rows.length; + source_index++ + ) { + const row = rows[source_index]!; + if (filtered && !matches(row)) continue; + const ordinal = filtered ? total++ : source_index; + if (ordinal < view.offset) continue; + if (items.length >= view.limit || byte_limited) { + if (!filtered) break; + continue; + } + const item = { + source_index, + ...choose(row, fields), + declaration_coverage: row.declaration_coverage, + }; + const added = + jsonValueByteLength(item as unknown as JsonValue, PAGE_BYTES - bytes) + + Number(items.length > 0); + if (bytes + added > PAGE_BYTES) { + if (items.length === 0) return tooLarge(); + byte_limited = true; + if (!filtered) break; + continue; + } + bytes += added; + items.push(item); + } + const result: UnsignedAnalysisView = { + ...context, + kind: "android", + facet: "components", + view, + items, + byte_limited, + coverage: pageViewCoverage(view.offset, items.length, total), + }; + return jsonValueByteLength(result as unknown as JsonValue, PROJECTION_BYTES) > + PROJECTION_BYTES + ? tooLarge() + : ok(result); }; diff --git a/src/domain/analysisView/apkSignatureView.ts b/src/domain/analysisView/apkSignatureView.ts index 6a492401c0..8172766d2e 100644 --- a/src/domain/analysisView/apkSignatureView.ts +++ b/src/domain/analysisView/apkSignatureView.ts @@ -101,7 +101,6 @@ const read = (parent: AnalysisViewParent): Result => { if ( parent.subject != null && !apkSignatureMatchesSubject(report.artifact, { - path: parent.subject.local_path, sha256: parent.subject.digest.sha256, format: parent.subject.format, }) diff --git a/src/domain/analysisView/artifactInventoryView.test.ts b/src/domain/analysisView/artifactInventoryView.test.ts index ae873fe4f4..fc510639e4 100644 --- a/src/domain/analysisView/artifactInventoryView.test.ts +++ b/src/domain/analysisView/artifactInventoryView.test.ts @@ -106,6 +106,33 @@ const page = (source: AnalysisViewParent, view: unknown) => { }; afterEach(() => vi.restoreAllMocks()); +it.each(["node_count", "occurrence_count", "edge_count"] as const)( + "rejects inventory views whose retained members exceed %s", + (count) => { + const inventory = freshInventory(); + inventory.manifest[count] -= 1; + const result = projectArtifactInventoryView( + parent(inventory), + artifactInventoryViewRequestSchema.parse({ + kind: "inventory", + collection: "nodes", + }), + ); + expect(result).toMatchObject({ + ok: false, + error: { + issues: [ + { + path: ["source"], + reason: "invalid_value", + message: "Artifact inventory members exceed their manifest counts", + }, + ], + }, + }); + }, +); + it("batches exact occurrence IDs in source order while retaining linkage with no selected fields", () => { const inventory = freshInventory(); const selected = inventory.occurrences.slice(0, 2); diff --git a/src/domain/analysisView/binaryLayoutView.ts b/src/domain/analysisView/binaryLayoutView.ts index 0c4840ff82..b4b7865ff4 100644 --- a/src/domain/analysisView/binaryLayoutView.ts +++ b/src/domain/analysisView/binaryLayoutView.ts @@ -40,17 +40,20 @@ const layoutUnknowns = (layout: BinaryLayout): readonly string[] => { return unknowns; }; -const displayName = (name: LayoutName): string => name.display; - const namedMatches = ( rows: readonly Row[], name: string, -): readonly { readonly index: number; readonly row: Row }[] => - rows.flatMap((row, index) => - row.name.unknown_reason === null && displayName(row.name) === name +): readonly { readonly index: number; readonly row: Row }[] => { + const bytes = Buffer.from(name, "utf8"); + // Display text may replace malformed source bytes or use provider formatting. + if (bytes.toString("utf8") !== name) return []; + const encoded = bytes.toString("base64"); + return rows.flatMap((row, index) => + row.name.unknown_reason === null && row.name.bytes_base64 === encoded ? [{ index, row }] : [], ); +}; const selectNamed = ( collection: "sections" | "symbols", diff --git a/src/domain/analysisView/browserScenarioView.ts b/src/domain/analysisView/browserScenarioView.ts index 4b66c6bf82..a64363a275 100644 --- a/src/domain/analysisView/browserScenarioView.ts +++ b/src/domain/analysisView/browserScenarioView.ts @@ -11,10 +11,10 @@ import { analysisViewInputError } from "./analysisViewErrors.js"; import { completeWithinViewCoverage, pageViewCoverage, + type AnalysisViewCoverage, } from "./analysisViewCoverage.js"; import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import type { - AnalysisViewCoverage, AnalysisViewParent, AnalysisViewRequest, UnsignedAnalysisView, diff --git a/src/domain/analysisView/desktopApplicationView.ts b/src/domain/analysisView/desktopApplicationView.ts index 2be6d1dca2..a179baf873 100644 --- a/src/domain/analysisView/desktopApplicationView.ts +++ b/src/domain/analysisView/desktopApplicationView.ts @@ -181,12 +181,12 @@ export const projectDesktopApplicationView = ( validatedParents.add(key); } } + const subject = parent.subject; if ( - parent.subject === undefined || - parent.subject === null || - parent.subject.format !== "directory" || - parent.subject.local_path !== report.root.path || - parent.subject.digest.sha256 !== report.root.snapshot_sha256 + subject === undefined || + subject === null || + subject.format !== "directory" || + subject.digest.sha256 !== report.root.snapshot_sha256 ) return err( analysisViewInputError([ diff --git a/src/domain/analysisView/environmentManifestView.test.ts b/src/domain/analysisView/environmentManifestView.test.ts index 6a4c5915c3..995b737667 100644 --- a/src/domain/analysisView/environmentManifestView.test.ts +++ b/src/domain/analysisView/environmentManifestView.test.ts @@ -1152,7 +1152,7 @@ it("keeps opaque or redacted empty response text unknown when selecting bodiless ]); }); -it("refuses a parent whose subject is absent or names another path or digest", () => { +it("refuses a parent whose subject is absent or names another digest", () => { const parent = parentOf(); const refusal = { ok: false, @@ -1167,15 +1167,6 @@ it("refuses a parent whose subject is absent or names another path or digest", ( projectAnalysisView({ ...withoutSubject, subject: null }, view()), ).toMatchObject(refusal); if (subject == null) throw new Error("Fixture evidence carries a subject"); - expect( - projectAnalysisView( - { - ...parent, - subject: { ...subject, local_path: "/elsewhere/capture.har" }, - }, - view(), - ), - ).toMatchObject(refusal); expect( projectAnalysisView( { diff --git a/src/domain/analysisView/environmentStateDerivedWrites.test.ts b/src/domain/analysisView/environmentStateDerivedWrites.test.ts index 8fec6c4f91..17f7cca933 100644 --- a/src/domain/analysisView/environmentStateDerivedWrites.test.ts +++ b/src/domain/analysisView/environmentStateDerivedWrites.test.ts @@ -20,6 +20,7 @@ import { UNKNOWN_TAG_UPDATE_ROWS, } from "../../../tests/fixtures/environmentStateDerivedValues.js"; import { + bookClock, environmentStateViewOf, numberedUuid, } from "../../../tests/fixtures/environmentStateWrites.js"; @@ -42,6 +43,18 @@ const TRIMMED = { trimmed: true, } as const; +/** A capture row whose response leaves out one field. */ +const omitting = (row: TimedCaptureRow, field: string): TimedCaptureRow => { + const [method, path, body, ...rest] = row; + if (!isObject(body)) throw new Error("Expected an object response"); + return [ + method, + path, + Object.fromEntries(Object.entries(body).filter(([key]) => key !== field)), + ...rest, + ]; +}; + describe("later creates must retain the learned value rules", () => { it("withholds a later create whose captured composed address is null", () => { const snapshot = null; @@ -64,6 +77,33 @@ describe("later creates must retain the learned value rules", () => { }); }); + it.each([ + { + name: "composed snapshot address", + tool: "post_bookmarks", + rows: (): TimedCaptureRow[] => + BOOKMARK_ROWS.slice(0, 6).map((row, index) => + index === 5 ? omitting(row, "web_archive_snapshot_url") : row, + ), + }, + { + name: "templated id text", + tool: "post_folders", + rows: (): TimedCaptureRow[] => + mirrored((id) => + id === 1656166745726976 ? {} : { idText: String(id) }, + ), + }, + ])( + "withholds a later create whose response omits the $name the plan rebuilds", + ({ tool, rows }) => { + expect(planOf(rows(), tool)).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }, + ); + it.each([ { name: "a string integer becomes a UUID", @@ -346,6 +386,32 @@ describe("values an application composes or generates", () => { }); }); + it("serves folder creates whose address follows a generated uid and the title's slug", () => { + const generated = FOLDER_ROWS.map((row): TimedCaptureRow => { + const [method, path, body, status, sent] = row; + if (method !== "POST" || !isObject(sent) || body === undefined) + return row; + const { uid: _uid, ...title } = sent; + return [method, path, body, status!, title]; + }); + expect(planOf(generated, "post_folders")).toMatchObject({ + served: true, + body: { accepted: ["title"] }, + rules: expect.arrayContaining([ + { + field: "url", + rule: "composed", + parts: [ + { text: "/dashboards/f/" }, + { from: "uid", as: "value" }, + { text: "/" }, + { from: "title", as: "slug" }, + ], + }, + ]), + }); + }); + it("withholds renames whose address a later call composes when an earlier call left it otherwise", () => { expect( planOf(restoredAddressRows(true), "put_folders_by_uuid"), @@ -441,6 +507,495 @@ const mirrored = ( }); }; +/** A folder's slug with `&` spelled as Grafana's Go slugs spell it. */ +const wordSlug = (title: string): string => + title + .replaceAll("&", " and ") + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-|-$/gu, ""); + +/** A folder's slug with `&` dropped, as Django's slugify drops it. */ +const droppedSlug = (title: string): string => + wordSlug(title.replaceAll("&", " ")); + +/** When the seeded folder was made, well before the capture. */ +const SEEDED = "2026-10-01T09:00:00Z"; + +/** Grafana's time-based folder ids, as the recorded folders hold them. */ +const FOLDER_IDS = [1655476044521472, 1656166527623168, 1656166745726976]; + +interface CreatedFolder { + readonly uid: string; + readonly title: string; + /** Whether the call sent the uid, or the application generated it. */ + readonly sent: boolean; + /** The address's last segment, when the application spells it otherwise than the title's slug. */ + readonly tail?: string; +} + +/** A seeded folder and folder creates, each read back and listed, whose addresses join the uid and `spell` of the title. */ +const folderCreates = ( + created: readonly CreatedFolder[], + spell: (title: string) => string, + seed: { readonly uid: string; readonly title: string } = { + uid: numberedUuid(101), + title: "Lighthouse Rota", + }, +): TimedCaptureRow[] => { + const entity = ( + folder: { + readonly uid: string; + readonly title: string; + readonly tail?: string; + }, + index: number, + at: number, + ): JsonObject => ({ + id: FOLDER_IDS[index]!, + uid: folder.uid, + title: folder.title, + url: `/dashboards/f/${folder.uid}/${folder.tail ?? spell(folder.title)}`, + created: bookClock(at), + updated: bookClock(at), + }); + const held: JsonObject[] = [ + { ...entity(seed, 0, 0), created: SEEDED, updated: SEEDED }, + ]; + const rows: TimedCaptureRow[] = [ + ["GET", "/api/folders", [...held]], + ["GET", `/api/folders/${seed.uid}`, held[0]!], + ]; + created.forEach((folder, index) => { + // The application stamps a create with the time of its call, the row's position. + const answered = entity(folder, index + 1, rows.length); + held.push(answered); + rows.push( + [ + "POST", + "/api/folders", + answered, + 200, + folder.sent + ? { uid: folder.uid, title: folder.title } + : { title: folder.title }, + ], + ["GET", `/api/folders/${folder.uid}`, answered], + ["GET", "/api/folders", [...held]], + ); + }); + return rows; +}; + +const addressParts = (ampersand?: "and") => [ + { text: "/dashboards/f/" }, + { from: "uid", as: "value" }, + { text: "/" }, + { + from: "title", + as: "slug", + ...(ampersand === undefined ? {} : { ampersand }), + }, +]; + +describe("slug spellings across creates", () => { + it("learns that a slug spells & as the word from the first create that sends one", () => { + const rows = folderCreates( + [ + { uid: numberedUuid(102), title: "Alpha Harbour", sent: false }, + { uid: numberedUuid(103), title: "Bravo Tide & Wind", sent: false }, + ], + wordSlug, + ); + expect(planOf(rows, "post_folders")).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "url", rule: "composed", parts: addressParts("and") }, + ]), + }); + }); + + it("keeps a slug that drops &, and refuses a later create that spells it as the word", () => { + const dropped = folderCreates( + [ + { uid: numberedUuid(102), title: "Alpha & Harbour", sent: false }, + { uid: numberedUuid(103), title: "Bravo Tide", sent: false }, + ], + droppedSlug, + ); + expect(planOf(dropped, "post_folders")).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "url", rule: "composed", parts: addressParts() }, + ]), + }); + const mixed = folderCreates( + [ + { uid: numberedUuid(102), title: "Alpha & Harbour", sent: false }, + { + uid: numberedUuid(103), + title: "Bravo Tide & Wind", + sent: false, + tail: "bravo-tide-and-wind", + }, + ], + droppedSlug, + ); + expect(planOf(mixed, "post_folders")).toMatchObject({ served: false }); + }); + + it("keeps a dropped & that an accepted create showed when a later one spells it as the word", () => { + const tag = (id: number, name: string, slug: string) => ({ + id, + name, + slug, + }); + const seed = tag(1, "Lamp Oil", "lamp-oil"); + const created = [ + tag(2, "Alpha Notes", "alpha-notes"), + tag(3, "Bravo & Tide", "bravo-tide"), + tag(4, "Charlie & Wind", "charlie-and-wind"), + ]; + const rows: TimedCaptureRow[] = [ + ["GET", "/api/tags", [seed]], + ["GET", "/api/tags/1", seed], + ]; + created.forEach((entity, index) => + rows.push( + ["POST", "/api/tags", entity, 201, { name: entity.name }], + ["GET", `/api/tags/${String(entity.id)}`, entity], + ["GET", "/api/tags", [seed, ...created.slice(0, index + 1)]], + ), + ); + const plan = planOf(rows, "post_tags"); + expect(plan.rules).toContainEqual({ + field: "slug", + rule: "slug", + from: "name", + }); + expect(plan.served).toBe(false); + }); +}); + +/** A category whose slug the application derives from its name. */ +const category = (id: number, name: string, slug: string) => ({ + id, + name, + slug, +}); + +/** Two category renames, each read back, after reading the `held` categories; a PUT sends the whole object back. */ +const renameRows = ( + method: "PATCH" | "PUT", + held: readonly ReturnType[], + renames: readonly ReturnType[], +): TimedCaptureRow[] => { + const current = new Map(held.map((entity) => [entity.id, entity])); + const rows: TimedCaptureRow[] = [ + ["GET", "/api/categories", [...held]], + ...held.map((entity): TimedCaptureRow => [ + "GET", + `/api/categories/${entity.id}`, + entity, + ]), + ]; + for (const renamed of renames) { + const before = current.get(renamed.id)!; + rows.push( + [ + method, + `/api/categories/${renamed.id}`, + renamed, + 200, + method === "PATCH" + ? { name: renamed.name } + : { ...before, name: renamed.name }, + ], + ["GET", `/api/categories/${renamed.id}`, renamed], + ); + current.set(renamed.id, renamed); + } + rows.push(["GET", "/api/categories", [...current.values()]]); + return rows; +}; + +describe("slug spellings across updates", () => { + it.each(["PATCH", "PUT"] as const)( + "applies held spelling to the initial %s rule without needing a later update", + (method) => { + for (const held of [ + [category(1, "Salt & Pepper", "salt-and-pepper")], + [ + category(1, "Lamp Oil", "lamp-oil"), + category(2, "Salt & Pepper", "salt-and-pepper"), + ], + ]) { + const plan = planOf( + renameRows(method, held, [ + category(1, "Harbour Notes", "harbour-notes"), + ]), + `${method.toLowerCase()}_categories_by_id`, + ); + expect(plan).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "slug", rule: "slug", from: "name", ampersand: "and" }, + ]), + }); + } + }, + ); + + it.each(["PATCH", "PUT"] as const)( + "withholds an initial %s that contradicts held slug spelling", + (method) => { + const plan = planOf( + renameRows( + method, + [category(1, "Salt & Pepper", "salt-and-pepper")], + [category(1, "Tide & Wind", "tide-wind")], + ), + `${method.toLowerCase()}_categories_by_id`, + ); + expect(plan.served).toBe(false); + }, + ); + + it.each(["PATCH", "PUT"] as const)( + "withholds an initial %s with contradictory held slug conventions", + (method) => { + const plan = planOf( + renameRows( + method, + [ + category(1, "Lamp Oil", "lamp-oil"), + category(2, "Salt & Pepper", "salt-and-pepper"), + category(3, "Tide & Wind", "tide-wind"), + ], + [category(1, "Harbour Notes", "harbour-notes")], + ), + `${method.toLowerCase()}_categories_by_id`, + ); + expect(plan.served).toBe(false); + }, + ); + + it.each(["PATCH", "PUT"] as const)( + "learns that an update's slug spells & as the word from a later %s that renames to one", + (method) => { + const rows = renameRows( + method, + [ + category(1, "Harbour Notes", "harbour-notes"), + category(2, "Lamp Oil", "lamp-oil"), + ], + [ + category(1, "Quay Notes", "quay-notes"), + category(2, "Tide & Wind", "tide-and-wind"), + ], + ); + expect( + planOf(rows, `${method.toLowerCase()}_categories_by_id`), + ).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "slug", rule: "slug", from: "name", ampersand: "and" }, + ]), + }); + }, + ); + + it.each(["PATCH", "PUT"] as const)( + "keeps a slug that spells & as the word when a later %s renames to a name without one", + (method) => { + const rows = renameRows( + method, + [ + category(1, "Harbour Notes", "harbour-notes"), + category(2, "Lamp Oil", "lamp-oil"), + ], + [ + category(1, "Tide & Wind", "tide-and-wind"), + category(2, "Quay Notes", "quay-notes"), + ], + ); + expect( + planOf(rows, `${method.toLowerCase()}_categories_by_id`), + ).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "slug", rule: "slug", from: "name", ampersand: "and" }, + ]), + }); + }, + ); + + it.each([ + [ + "a held category", + [ + category(1, "Harbour Notes", "harbour-notes"), + category(3, "Salt & Pepper", "salt-pepper"), + ], + [category(1, "Quay Notes", "quay-notes")], + ], + [ + "an earlier rename", + [ + category(1, "Harbour Notes", "harbour-notes"), + category(3, "Lamp Oil", "lamp-oil"), + ], + [category(1, "Salt & Pepper", "salt-pepper")], + ], + ])( + "refuses a later rename that spells & as the word after %s dropped it", + (_, held, earlier) => { + const rows = renameRows("PATCH", held, [ + ...earlier, + category(held[1]!.id, "Tide & Wind", "tide-and-wind"), + ]); + expect(planOf(rows, "patch_categories_by_id")).toMatchObject({ + served: false, + }); + }, + ); +}); + +describe("slug spellings the held entities show", () => { + it("spells & as the held entities do when the creates slug none", () => { + const rows = folderCreates( + [ + { uid: numberedUuid(102), title: "Alpha Harbour", sent: false }, + { uid: numberedUuid(103), title: "Bravo Tide", sent: false }, + ], + wordSlug, + { uid: numberedUuid(101), title: "Lamp & Oil" }, + ); + expect(planOf(rows, "post_folders")).toMatchObject({ + served: true, + rules: expect.arrayContaining([ + { field: "url", rule: "composed", parts: addressParts("and") }, + ]), + }); + }); + + it("learns a slug field's & spelling from held tags, and no slug rule they contradict", () => { + const tagRows = (held: string, created: string, slug: string) => { + const seed = { id: 1, name: held, slug: wordSlug(held) }; + const tag = { id: 2, name: created, slug }; + return [ + ["GET", "/api/tags", [seed]], + ["GET", "/api/tags/1", seed], + ["POST", "/api/tags", tag, 201, { name: created }], + ["GET", "/api/tags/2", tag], + ["GET", "/api/tags", [seed, tag]], + ] as TimedCaptureRow[]; + }; + expect( + planOf( + tagRows("Salt & Pepper", "Harbour Notes", "harbour-notes"), + "post_tags", + ).rules, + ).toContainEqual({ + field: "slug", + rule: "slug", + from: "name", + ampersand: "and", + }); + expect( + planOf(tagRows("Salt & Pepper", "Tide & Wind", "tide-wind"), "post_tags") + .rules, + ).not.toContainEqual( + expect.objectContaining({ field: "slug", rule: "slug" }), + ); + }); + + it("learns no slug rule from held tags that spell & both ways", () => { + const held = [ + { id: 1, name: "Salt & Pepper", slug: "salt-and-pepper" }, + { id: 3, name: "Fish & Chips", slug: "fish-chips" }, + ]; + const tag = { id: 2, name: "Harbour Notes", slug: "harbour-notes" }; + const rows: TimedCaptureRow[] = [ + ["GET", "/api/tags", held], + ["GET", "/api/tags/1", held[0]!], + ["GET", "/api/tags/3", held[1]!], + ["POST", "/api/tags", tag, 201, { name: tag.name }], + ["GET", "/api/tags/2", tag], + ["GET", "/api/tags", [...held, tag]], + ]; + expect(planOf(rows, "post_tags").rules).not.toContainEqual( + expect.objectContaining({ field: "slug", rule: "slug" }), + ); + }); + + it("learns no & spelling from a held slug neither spelling gives", () => { + const rows = (created: string, slug: string): TimedCaptureRow[] => { + const seed = { id: 1, name: "Salt & Pepper", slug: "condiments" }; + const tag = { id: 2, name: created, slug }; + return [ + ["GET", "/api/tags", [seed]], + ["GET", "/api/tags/1", seed], + ["POST", "/api/tags", tag, 201, { name: created }], + ["GET", "/api/tags/2", tag], + ["GET", "/api/tags", [seed, tag]], + ]; + }; + expect( + planOf(rows("Tide & Wind", "tide-and-wind"), "post_tags").rules, + ).not.toContainEqual( + expect.objectContaining({ field: "slug", rule: "slug" }), + ); + expect( + planOf(rows("Harbour Notes", "harbour-notes"), "post_tags").rules, + ).toContainEqual({ field: "slug", rule: "slug", from: "name" }); + }); +}); + +describe("ambiguous addresses and templates across creates", () => { + it("learns an address the first create cannot tell apart, its uid being its title's slug, from the second", () => { + const rows = folderCreates( + [ + { uid: "alpha-harbour", title: "Alpha Harbour", sent: true }, + { uid: "bravo-store", title: "Bravo Tide Notes", sent: true }, + ], + wordSlug, + { uid: "lighthouse-rota", title: "Lighthouse Rota" }, + ); + expect(planOf(rows, "post_folders")).toMatchObject({ + served: true, + body: { accepted: ["uid", "title"], fixed: [] }, + rules: expect.arrayContaining([ + { field: "url", rule: "composed", parts: addressParts() }, + ]), + }); + }); + + it("withholds creates whose template a later create answers otherwise", () => { + const rows = folderCreates( + [ + { + uid: numberedUuid(102), + title: "Alpha Harbour", + sent: false, + tail: "inbox", + }, + { + uid: numberedUuid(103), + title: "Bravo Tide", + sent: false, + tail: "outbox", + }, + ], + wordSlug, + ); + expect(planOf(rows, "post_folders")).toMatchObject({ + served: false, + reason: "unexplained-change", + }); + }); +}); + describe("generated integers another field repeats", () => { it("withholds folder creates a later call answers with a UUID where the exemplar generated an integer", () => { const later = 1656166745726976; @@ -515,3 +1070,37 @@ describe("generated integers another field repeats", () => { }); }); }); + +describe("slugs the held entities' spellings refuse", () => { + const SALT = { id: 1, name: "Salt & Pepper", slug: "salt-and-pepper" }; + const FISH = { id: 3, name: "Fish & Chips", slug: "fish-chips" }; + + it.each([ + ["spell & both ways", [SALT, FISH], "Harbour Notes", "harbour-notes"], + ["spell & otherwise than the create", [SALT], "Tide & Wind", "tide-wind"], + ])( + "accepts a created tag's name only as recorded when held tags %s", + (_case, held, name, slug) => { + const tag = { id: 2, name, slug }; + const rows: TimedCaptureRow[] = [ + ["GET", "/api/tags", held], + ...held.map((entity): TimedCaptureRow => [ + "GET", + `/api/tags/${String(entity.id)}`, + entity, + ]), + ["POST", "/api/tags", tag, 201, { name }], + ["GET", "/api/tags/2", tag], + ["GET", "/api/tags", [...held, tag]], + ]; + const plan = planOf(rows, "post_tags"); + expect(plan.rules).not.toContainEqual( + expect.objectContaining({ field: "slug", rule: "slug" }), + ); + // The slug kept as recorded fits only the recorded name. + expect(plan.body).not.toBeNull(); + expect(plan.body?.accepted).not.toContain("name"); + expect(plan.body?.fixed).toContainEqual({ member: "name", sent: name }); + }, + ); +}); diff --git a/src/domain/analysisView/environmentStateInitialOrder.ts b/src/domain/analysisView/environmentStateInitialOrder.ts index 374b79c5c7..09ed20a3c0 100644 --- a/src/domain/analysisView/environmentStateInitialOrder.ts +++ b/src/domain/analysisView/environmentStateInitialOrder.ts @@ -1,3 +1,4 @@ +import { parseUrlQueryParameterNames } from "../recordedTransportUrl.js"; import type { ObservedEntity } from "./environmentStateMerge.js"; import { appliesOrder } from "./environmentStateOrderParameters.js"; @@ -5,10 +6,11 @@ import { appliesOrder } from "./environmentStateOrderParameters.js"; const RANDOM = new Set(["random", "rand", "shuffle", "seed"]); /** Whether a recorded query leaves the order to the application: no sort, direction or random parameter. */ -const defaultOrder = (query: string | null | undefined): boolean => - [...new URLSearchParams(query ?? "").keys()].every( - (name) => !appliesOrder(name) && !RANDOM.has(name.toLowerCase()), - ); +const defaultOrder = (query: string | null | undefined): boolean => { + for (const name of parseUrlQueryParameterNames(query ?? "")) + if (appliesOrder(name) || RANDOM.has(name.toLowerCase())) return false; + return true; +}; /** * Order each collection's entities as the earliest whole list line in the application's own order diff --git a/src/domain/analysisView/environmentStateQueryProjection.test.ts b/src/domain/analysisView/environmentStateQueryProjection.test.ts new file mode 100644 index 0000000000..a190e222cd --- /dev/null +++ b/src/domain/analysisView/environmentStateQueryProjection.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; +import { projectsFields, selectsSomeFields } from "./environmentStateRules.js"; +import { queryValues } from "./environmentStateVariants.js"; + +it("uses decoded names while retaining every recorded query value in order", () => { + expect( + queryValues( + "?%66ields=*&fields=id&fields=two+words&=empty&flag&fields=%FF", + ), + ).toEqual( + new Map([ + ["fields", ["*", "id", "two words", "\uFFFD"]], + ["", ["empty"]], + ["flag", [""]], + ]), + ); +}); + +it.each([ + [undefined, false, false], + ["?q=fields%3Did", false, false], + ["?%66ields=%2A", true, false], + ["?%66ields=%2A&fields=id", true, true], + ["?fields%5Barticle%5D=", true, true], + ["?%24SELECT=%2A", true, false], + ["?fields=%FF", true, true], + ["??fields=id", false, false], +] as const)( + "preserves projection meaning for recorded query %s", + (query, projects, selects) => { + expect(projectsFields(query)).toBe(projects); + expect(selectsSomeFields(query)).toBe(selects); + }, +); diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 68def91670..ce1f1bd48e 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -1,3 +1,7 @@ +import { + iterateUrlQueryParameters, + parseUrlQueryParameterNames, +} from "../recordedTransportUrl.js"; import type { OffsetDateTimeInstant } from "../offsetDateTime.js"; import type { StateRecordChronology } from "./StateRecordChronology.js"; import type { ListQuery } from "./environmentStateListQuery.js"; @@ -165,16 +169,18 @@ export const isProjectionParameter = (name: string): boolean => { }; /** Whether a recorded query string names a projection parameter. */ -export const projectsFields = (query: string | undefined): boolean => - query !== undefined && - [...new URLSearchParams(query).keys()].some(isProjectionParameter); +export const projectsFields = (query: string | undefined): boolean => { + for (const name of parseUrlQueryParameterNames(query ?? "")) + if (isProjectionParameter(name)) return true; + return false; +}; /** Whether a recorded query asks for some fields only: a projection parameter with a value other than `*`, which asks for all. */ -export const selectsSomeFields = (query: string | undefined): boolean => - query !== undefined && - [...new URLSearchParams(query)].some( - ([name, value]) => isProjectionParameter(name) && value !== "*", - ); +export const selectsSomeFields = (query: string | undefined): boolean => { + for (const [name, value] of iterateUrlQueryParameters(query ?? "")) + if (isProjectionParameter(name) && value !== "*") return true; + return false; +}; export type StateListView = EntityViewPlan & ReplayedReads & { diff --git a/src/domain/analysisView/environmentStateVariants.ts b/src/domain/analysisView/environmentStateVariants.ts index c553b7d341..cd732544f9 100644 --- a/src/domain/analysisView/environmentStateVariants.ts +++ b/src/domain/analysisView/environmentStateVariants.ts @@ -1,3 +1,7 @@ +import { + iterateUrlQueryParameters, + parseUrlQueryParameterNames, +} from "../recordedTransportUrl.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js"; import type { LateLine, StatePlan } from "./environmentStateGate.js"; @@ -46,8 +50,11 @@ export const queryValues = ( query: string | null | undefined, ): Map => { const values = new Map(); - for (const [name, value] of new URLSearchParams(query ?? "")) - values.set(name, [...(values.get(name) ?? []), value]); + for (const [name, value] of iterateUrlQueryParameters(query ?? "")) { + const previous = values.get(name); + if (previous === undefined) values.set(name, [value]); + else previous.push(value); + } return values; }; @@ -57,7 +64,7 @@ export const shapesOtherwise = (query: string | null | undefined): boolean => /** The query names that may change how a line shows its entities: all but paging. */ const shapingNames = (query: string | null | undefined): string[] => - [...queryValues(query).keys()].filter( + [...new Set(parseUrlQueryParameterNames(query ?? ""))].filter( (name) => !PAGING.has(name.toLowerCase()), ); diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index a3768d4ef2..ce5e583d3e 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -268,7 +268,12 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [ }), z.strictObject({ ...ruled, rule: z.literal("copy"), from: z.string() }), z.strictObject({ ...ruled, rule: z.literal("path"), from: z.string() }), - z.strictObject({ ...ruled, rule: z.literal("slug"), from: z.string() }), + z.strictObject({ + ...ruled, + rule: z.literal("slug"), + from: z.string(), + ampersand: z.literal("and").exactOptional(), + }), z.strictObject({ ...ruled, rule: z.literal("template"), from: z.string() }), z.strictObject({ ...ruled, @@ -277,9 +282,11 @@ const fieldRuleSchema = z.discriminatedUnion("rule", [ .array( z.union([ z.strictObject({ text: z.string().min(1) }), + z.strictObject({ from: z.string(), as: z.literal("value") }), z.strictObject({ from: z.string(), - as: z.enum(["value", "slug"]), + as: z.literal("slug"), + ampersand: z.literal("and").exactOptional(), }), z.strictObject({ from: z.string(), diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index a775c4cbe0..40eaa60c05 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -290,7 +290,7 @@ const derive = ( if (rule.rule === "slug") { const from = ownMember(sent, rule.from); if (typeof from === "string") - setOwnMember(written, rule.field, slugOf(from)); + setOwnMember(written, rule.field, slugOf(from, rule.ampersand)); } else if (rule.rule === "template") { const value = templated(observed, written, rule); if (value !== undefined) setOwnMember(written, rule.field, value); diff --git a/src/domain/analysisView/environmentStateWriteCalls.ts b/src/domain/analysisView/environmentStateWriteCalls.ts index 68fcfdb866..a649471573 100644 --- a/src/domain/analysisView/environmentStateWriteCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCalls.ts @@ -4,6 +4,10 @@ import type { JsonValue } from "../jsonValue.js"; import { ownMember, same, type JsonObject } from "./environmentStateRules.js"; import { storedKinds } from "./environmentStateWriteApply.js"; import { composedValue } from "./environmentStateWriteComposed.js"; +import { + ampersandSpelled, + type AmpersandSpelling, +} from "./environmentStateWriteCreateCalls.js"; import type { explainUpdate } from "./environmentStateWriteExplain.js"; import { alikeRules, @@ -104,14 +108,29 @@ export interface UpdateCall { readonly kept: readonly string[]; } -/** Ambiguous saves may echo an already-derived slug or hold a clock in its own second. Keep the demonstrated derivation; call evidence still has to reproduce it. */ +/** How a later update's entity and the plan's calls and held entities showed `&` in the slugs it learns. */ +export interface UpdateSpelling { + readonly entity: JsonObject; + readonly ampersands: ReadonlyMap; +} + +/** Ambiguous saves may echo an already-derived slug, hold a clock in its own second, or slug no `&`. Keep the demonstrated derivation; call evidence still has to reproduce it. */ export const reconciledUpdateRule = ( held: FieldRule, later: FieldRule, kept: ReadonlySet, + spelling?: UpdateSpelling, ): FieldRule | undefined => { const alike = alikeRules(held, later, kept); if (alike !== undefined) return alike; + if (spelling !== undefined) { + const spelled = ampersandSpelled( + held, + { rule: later, entity: spelling.entity }, + spelling.ampersands.get(held.field), + ); + if (spelled !== undefined) return spelled; + } for (const [derived, ambiguous] of [ [held, later], [later, held], @@ -142,10 +161,18 @@ export const contradictsEarlierCalls = ( ): boolean => { const ruled = new Map(draft.plan.rules.map((rule) => [rule.field, rule])); return rules.some((rule) => { + const held = ruled.get(rule.field); + // A slug that echoes, or that spells `&` as the word where the plan's drops it, must reproduce every earlier call; one that slugged no `&` keeps the plan's spelling. if ( !(unsentChange(rule) || regenerates(rule) || rule.rule === "clock") || - (ruled.has(rule.field) && - !(rule.rule === "slug" && ruled.get(rule.field)?.rule === "echo")) + (held !== undefined && + !( + rule.rule === "slug" && + (held.rule === "echo" || + (held.rule === "slug" && + held.ampersand === undefined && + rule.ampersand !== undefined)) + )) ) return false; // Compute a structured rule's digest once, rather than once per earlier call. @@ -181,7 +208,10 @@ export const contradictsEarlierCalls = ( return ( rule.rule === "slug" && (typeof from === "string" - ? !sameObserved(observation, observedValue(slugOf(from))) + ? !sameObserved( + observation, + observedValue(slugOf(from, rule.ampersand)), + ) : !kept.includes(rule.field)) ); }); @@ -220,6 +250,7 @@ export const conflictsWithPlan = ( plan: Pick, explained: ReturnType, kept: ReadonlySet, + spelling?: UpdateSpelling, ): boolean => { const body = plan.body!; const fixed = new Map(body.fixed.map((entry) => [entry.member, entry.sent])); @@ -239,7 +270,7 @@ export const conflictsWithPlan = ( const held = rules.get(rule.field); return ( held !== undefined && - reconciledUpdateRule(held, rule, kept) === undefined + reconciledUpdateRule(held, rule, kept, spelling) === undefined ); }) ); diff --git a/src/domain/analysisView/environmentStateWriteChanges.ts b/src/domain/analysisView/environmentStateWriteChanges.ts index 35a85160ae..de17ce20d4 100644 --- a/src/domain/analysisView/environmentStateWriteChanges.ts +++ b/src/domain/analysisView/environmentStateWriteChanges.ts @@ -10,8 +10,9 @@ import type { FieldRule, WriteCall } from "./environmentStateWriteRules.js"; import { integerOf, readClock, - slugOf, + slugSpellings, sortedStrings, + type Ampersand, } from "./environmentStateWriteValues.js"; import { freshUuid, renewedRule } from "./environmentStateWriteGenerated.js"; import { composedChange } from "./environmentStateWriteComposed.js"; @@ -24,6 +25,20 @@ import { stampRule, } from "./environmentStateWriteStamps.js"; +/** The member a slug spells, and how it spells `&`. */ +export interface SlugSource { + readonly from: string; + readonly ampersand?: Ampersand; +} + +/** The slug rule a field follows from its source. */ +export const slugRule = (field: string, source: SlugSource): FieldRule => ({ + field, + rule: "slug", + from: source.from, + ...(source.ampersand === undefined ? {} : { ampersand: source.ampersand }), +}); + /** What explaining an update reads: the entity before and after, the call and its method. */ export interface UpdateEvidence { readonly before: JsonObject; @@ -51,7 +66,7 @@ export interface UpdateEvidence { export const changeRule = ( evidence: UpdateEvidence, field: string, - slugs: ReadonlyMap, + slugs: ReadonlyMap, ): FieldRule | undefined => { const before = ownMember(evidence.before, field); const after = ownMember(evidence.after, field)!; @@ -59,8 +74,8 @@ export const changeRule = ( return { field, rule: "clock", format: readClock(after)!.format }; if (freshUuid(evidence, field)) return { field, rule: "unique" }; // A renamed slug is spelled like a token, so the slug comes first. - const member = typeof after === "string" ? slugs.get(after) : undefined; - if (member !== undefined) return { field, rule: "slug", from: member }; + const source = typeof after === "string" ? slugs.get(after) : undefined; + if (source !== undefined) return slugRule(field, source); if (freshToken(evidence, field)) return { field, rule: "unique" }; const nested = nestedClockRule(field, { before, after }, evidence); if (nested !== undefined) return nested; @@ -145,16 +160,22 @@ export const sameSecondClocks = (evidence: UpdateEvidence): string[] => const sentSlugSource = ( evidence: UpdateEvidence, member: string, -): string | undefined => { +): SlugSource | undefined => { const stored = ownMember(evidence.after, member); if (typeof stored !== "string") return undefined; - return Object.entries(evidence.call.body).find( - ([other, value]) => + for (const [other, value] of Object.entries(evidence.call.body)) + if ( other !== member && typeof value === "string" && - same(ownMember(evidence.after, other), value) && - slugOf(value) === stored, - )?.[0]; + same(ownMember(evidence.after, other), value) + ) + for (const { slug, ampersand } of slugSpellings(value)) + if (slug === stored) + return { + from: other, + ...(ampersand === undefined ? {} : { ampersand }), + }; + return undefined; }; /** Whether a call sent back the clock the entity held, as a whole-object write does, and the application stamped it anew near the call. */ @@ -192,8 +213,8 @@ export const sentRule = ( rule: "clock", format: readClock(ownMember(evidence.after, member))!.format, }; - const from = sentSlugSource(evidence, member); - if (from !== undefined) return { field: member, rule: "slug", from }; + const source = sentSlugSource(evidence, member); + if (source !== undefined) return slugRule(member, source); return freshUuid(evidence, member) ? { field: member, rule: "unique" } : { diff --git a/src/domain/analysisView/environmentStateWriteComposed.test.ts b/src/domain/analysisView/environmentStateWriteComposed.test.ts index c350b4f62a..c34caebb1a 100644 --- a/src/domain/analysisView/environmentStateWriteComposed.test.ts +++ b/src/domain/analysisView/environmentStateWriteComposed.test.ts @@ -300,3 +300,77 @@ it( expect(reads).toBe(n + 2); }, ); + +describe("slug spellings", () => { + it("spells & in a slug part as the word only when the part says so", () => { + const parts = (ampersand?: "and") => [ + { text: "/dashboards/f/" }, + { from: "uid", as: "value" as const }, + { text: "/" }, + { + from: "title", + as: "slug" as const, + ...(ampersand === undefined ? {} : { ampersand }), + }, + ]; + const folder = { uid: "u1", title: "Tide & Wind" }; + expect(composedValue(parts("and"), folder)).toBe( + "/dashboards/f/u1/tide-and-wind", + ); + expect(composedValue(parts(), folder)).toBe("/dashboards/f/u1/tide-wind"); + }); + + it("reads an address whose uid is its title's slug as held entities spell it", () => { + const held = [ + { + uid: "a1b2c3", + title: "Lighthouse Rota", + url: "/dashboards/f/a1b2c3/lighthouse-rota", + }, + ]; + const created = { + uid: "scratch-pier", + title: "Scratch Pier", + url: "/dashboards/f/scratch-pier/scratch-pier", + }; + const rules: FieldRule[] = [ + { field: "uid", rule: "echo" }, + { field: "title", rule: "echo" }, + ]; + expect(createdComposer(created, rules, held)("url")).toEqual({ + field: "url", + rule: "composed", + parts: [ + { text: "/dashboards/f/" }, + { from: "uid", as: "value" }, + { text: "/" }, + { from: "title", as: "slug" }, + ], + }); + expect( + createdComposer(created, rules, [ + { + ...held[0]!, + uid: "lighthouse-rota", + url: "/dashboards/f/lighthouse-rota/lighthouse-rota", + }, + ])("url"), + ).toBeUndefined(); + }); + + it("reads no address that more sources spell than it can tell apart", () => { + const fields = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [ + `alias${String(index)}`, + "harbour-notes", + ]), + ); + const rules: FieldRule[] = Object.keys(fields).map((field) => ({ + field, + rule: "echo", + })); + const created = { ...fields, url: "/notes/harbour-notes/harbour-notes" }; + const held = [{ ...fields, url: "/notes/harbour-notes/harbour-notes" }]; + expect(createdComposer(created, rules, held)("url")).toBeUndefined(); + }); +}); diff --git a/src/domain/analysisView/environmentStateWriteComposed.ts b/src/domain/analysisView/environmentStateWriteComposed.ts index 5572c6b296..5660f99b06 100644 --- a/src/domain/analysisView/environmentStateWriteComposed.ts +++ b/src/domain/analysisView/environmentStateWriteComposed.ts @@ -13,6 +13,7 @@ import { joinsBefore, readClock, slugOf, + slugSpellings, zoneMinutesForFormat, } from "./environmentStateWriteValues.js"; import { @@ -47,7 +48,7 @@ export const composedPartText = ( const value = ownMember(fields, part.from); if (part.as === "clock") return clockDigits(value, part.pattern); if (part.as === "value") return scalarText(value); - return typeof value === "string" ? slugOf(value) : undefined; + return typeof value === "string" ? slugOf(value, part.ampersand) : undefined; }; /** A composed string spelled from written fields, or undefined when a part has nothing to spell. */ @@ -74,11 +75,17 @@ const candidatesOf = ( const text = scalarText(value); if (text !== undefined && text.length >= shortest) candidates.push({ token: text, part: { from: field, as: "value" } }); - if (typeof value === "string") { - const slug = slugOf(value); - if (slug !== value && slug.length >= COPY_MIN_LENGTH) - candidates.push({ token: slug, part: { from: field, as: "slug" } }); - } + if (typeof value === "string") + for (const { slug, ampersand } of slugSpellings(value)) + if (slug !== value && slug.length >= COPY_MIN_LENGTH) + candidates.push({ + token: slug, + part: { + from: field, + as: "slug", + ...(ampersand === undefined ? {} : { ampersand }), + }, + }); if (readClock(value) !== undefined) for (const pattern of CLOCK_PATTERNS) { const digits = clockDigits(value, pattern); @@ -123,36 +130,65 @@ const matchesAt = ( return []; }; +/** Parts with each slug of a source holding no `&` spelling it as the word, or undefined when no such slug part exists. */ +const ampersandWord = ( + parts: readonly ComposedPart[], + entity: JsonObject, +): ComposedPart[] | undefined => { + let changed = false; + const spelled = parts.map((part): ComposedPart => { + if (!("as" in part) || part.as !== "slug" || part.ampersand !== undefined) + return part; + const source = ownMember(entity, part.from); + if (typeof source === "string" && source.includes("&")) return part; + changed = true; + return { ...part, ampersand: "and" }; + }); + return changed ? spelled : undefined; +}; + +/** More ways to read one string than this leave it unexplained. */ +const MAX_SEGMENTATIONS = 8; + /** - * Literal text around the longest delimited candidate at each position, left to right; undefined when none shows, or - * when one text comes from two sources serving could tell apart. `alike` names parts serving fills with one value. + * Literal text around the longest delimited candidate at each position, left to right: one reading per choice where + * sources serving could tell apart spell the same text, which other entities then tell apart. Empty when no candidate + * shows, or the choices exceed `MAX_SEGMENTATIONS`. `alike` names parts serving fills with one value. */ -const segmented = ( +const segmentations = ( text: string, index: CandidateIndex, alike: (left: SourcePart, right: SourcePart) => boolean, -): ComposedPart[] | undefined => { - const parts: ComposedPart[] = []; +): ComposedPart[][] => { + let readings: ComposedPart[][] = [[]]; let literal = ""; let shown = false; for (let at = 0; at < text.length;) { const matches = matchesAt(text, at, index); - const match = matches[0]; - if (match === undefined) { + if (matches.length === 0) { literal += text[at]; at += 1; continue; } - if (matches.some((other) => !alike(match.part, other.part))) - return undefined; - if (literal !== "") parts.push({ text: literal }); + // Stop at the cap before comparing or allocating more, however many sources spell the text. + const choices: SourcePart[] = []; + for (const { part } of matches) + if (!choices.some((choice) => alike(choice, part))) { + choices.push(part); + if (readings.length * choices.length > MAX_SEGMENTATIONS) return []; + } + const lead: ComposedPart[] = literal === "" ? [] : [{ text: literal }]; + readings = readings.flatMap((parts) => + choices.map((choice) => [...parts, ...lead, choice]), + ); literal = ""; - parts.push(match.part); shown = true; - at += match.token.length; + at += matches[0]!.token.length; } - if (literal !== "") parts.push({ text: literal }); - return shown ? parts : undefined; + if (!shown) return []; + return literal === "" + ? readings + : readings.map((parts) => [...parts, { text: literal }]); }; const samePart = (left: SourcePart, right: SourcePart): boolean => @@ -160,7 +196,10 @@ const samePart = (left: SourcePart, right: SourcePart): boolean => left.as === right.as && (left.as !== "clock" || right.as !== "clock" || - left.pattern === right.pattern); + left.pattern === right.pattern) && + (left.as !== "slug" || + right.as !== "slug" || + left.ampersand === right.ampersand); /** How many entities holding the field and what its parts read spell it as the parts do, or undefined when one holds it otherwise, a non-string value included. */ const heldSpellings = ( @@ -227,12 +266,20 @@ export const createdComposer = ( : [], ), ); - const parts = segmented(text, index, alike); - return parts === undefined || - parts.some((part) => "from" in part && part.from === field) || - !((heldSpellings(parts, field, held) ?? 0) > 0) - ? undefined - : { field, rule: "composed", parts }; + const fits = segmentations(text, index, alike).flatMap((parts) => { + if (parts.some((part) => "from" in part && part.from === field)) + return []; + // A slug whose source here holds no `&` spells it as the held entities do. + const spelled = [parts, ampersandWord(parts, entity)].find( + (candidate) => + candidate !== undefined && + (heldSpellings(candidate, field, held) ?? 0) > 0, + ); + return spelled === undefined ? [] : [spelled]; + }); + return fits.length === 1 + ? { field, rule: "composed", parts: fits[0]! } + : undefined; }; }; @@ -270,10 +317,12 @@ export const composedChange = ( ), ), ); - const parts = segmented(text, index, samePart); - return parts === undefined || - composedValue(parts, before) !== ownMember(before, field) || - heldSpellings(parts, field, entity.others) === undefined - ? undefined - : { field, rule: "composed", parts }; + const fits = segmentations(text, index, samePart).filter( + (parts) => + composedValue(parts, before) === ownMember(before, field) && + heldSpellings(parts, field, entity.others) !== undefined, + ); + return fits.length === 1 + ? { field, rule: "composed", parts: fits[0]! } + : undefined; }; diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 53be933326..616bc9b927 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -42,6 +42,7 @@ import { refuse, requestBodyPlan, retainFixed, + retainCreateExemplar, searchFrom, startOf, UNKNOWN, @@ -53,6 +54,7 @@ import { import { createdInteger, explainCreate, + type CreateEvidence, insertCandidates, insertIndex, randomIdentities, @@ -78,7 +80,11 @@ import { fittedClockFormat, noteFixedWidths, } from "./environmentStateWriteClockFormats.js"; -import { mergedCreatePlan } from "./environmentStateWriteCreateCalls.js"; +import { + mergedCreatePlan, + noteAmpersands, + noteHeldAmpersands, +} from "./environmentStateWriteCreateCalls.js"; import { containsDelimited, generatedIntegerDigits, @@ -278,27 +284,77 @@ const refineCreate = ( linkedUnexplained(explained.rules, evidence.observed.object, held) ) return false; - const merged = mergedCreatePlan( - { rules: draft.plan.rules, body: draft.plan.body! }, - draft.observed!, + const later = laterOf(evidence, explained); + const widened = + mergedCreatePlan( + { rules: draft.plan.rules, body: draft.plan.body! }, + draft.observed!, + later, + earlierOf(draft), + ) ?? reexplained(draft, explained.rules, later); + if (widened === undefined) return false; + retainFixed(context, widened.body.fixed.slice(draft.plan.body!.fixed.length)); + draft.plan.rules = widened.rules; + draft.plan.body = widened.body; + draft.exemplarInput = undefined; + noteAmpersands(draft.ampersands, explained.rules, evidence.observed.object); + return true; +}; + +/** What the plan's earlier calls showed, for merging a later one. */ +const earlierOf = (draft: WriteDraft) => ({ + kept: draft.kept, + clockOf: (field: string) => clockEvidence(draft, field), + ampersands: draft.ampersands, +}); + +/** A later create as the plan merge reads it. */ +const laterOf = ( + evidence: CreateCallEvidence, + explained: ReturnType, +) => ({ + entity: evidence.observed.object, + body: evidence.call.body, + rules: explained.rules, + contract: { + ...explained.body, + ...(evidence.envelope === undefined ? {} : { envelope: evidence.envelope }), + }, +}); + +/** + * The plan its exemplar gives again with the composed rules a later call learned for fields the exemplar left + * unexplained, such as an address spelled from an id that equals the title's slug, merged with that call. Undefined + * once a later call widened the plan, or unless the rules spell the exemplar's own values. + */ +const reexplained = ( + draft: WriteDraft, + learned: readonly FieldRule[], + later: ReturnType, +) => { + const input = draft.exemplarInput; + if (input === undefined) return undefined; + const hints = learned.filter( + (rule) => + rule.rule === "composed" && + !draft.plan.rules.some((held) => held.field === rule.field), + ); + if (hints.length === 0) return undefined; + const again = explainCreate({ ...input, hints }); + if (!hints.every((hint) => again.rules.includes(hint))) return undefined; + const envelope = draft.plan.body!.envelope; + return mergedCreatePlan( { - entity: evidence.observed.object, - body: evidence.call.body, - rules: explained.rules, - contract: { - ...explained.body, - ...(evidence.envelope === undefined - ? {} - : { envelope: evidence.envelope }), + rules: again.rules, + body: { + ...again.body, + ...(envelope === undefined ? {} : { envelope }), }, }, - { kept: draft.kept, clockOf: (field) => clockEvidence(draft, field) }, + draft.observed!, + later, + earlierOf(draft), ); - if (merged === undefined) return false; - retainFixed(context, merged.body.fixed.slice(draft.plan.body!.fixed.length)); - draft.plan.rules = merged.rules; - draft.plan.body = merged.body; - return true; }; type CreateCallEvidence = { @@ -318,7 +374,7 @@ const explainedCall = ( const type = draft.plan.target!; const members = fieldsOf(context.state.members(type, evidence.collection)); const held = fieldsOf(context.state.ofType(type)); - const explained = explainCreate({ + const input: CreateEvidence = { entity: evidence.observed.object, call: evidence.call, idField: context.idField(type), @@ -326,8 +382,8 @@ const explainedCall = ( collection: members, type: held, named: (id) => context.state.named(id), - }); - return { explained, members, held }; + }; + return { explained: explainCreate(input), members, held, input }; }; /** Build a create plan from its exemplar's observation. */ @@ -339,7 +395,7 @@ const buildCreate = ( ): boolean => { const type = draft.plan.target!; const idField = context.idField(type); - const { explained, members, held } = explainedCall( + const { explained, members, held, input } = explainedCall( context, draft, step, @@ -406,6 +462,9 @@ const buildCreate = ( insert: draft.candidates[0]!, }); draft.observed = evidence.observed.object; + draft.exemplarInput = retainCreateExemplar(context, input, explained.rules); + noteHeldAmpersands(draft.ampersands, explained.rules, held); + noteAmpersands(draft.ampersands, explained.rules, evidence.observed.object); for (const entity of held) noteFixedWidths(draft.fixedWidth, entity); return true; }; @@ -518,12 +577,13 @@ const followsCreateRules = ( draft.plan.rules.every((rule) => { const value = ownMember(created.fields, rule.field); const exemplar = ownMember(draft.observed, rule.field); - if (rule.rule === "composed" || rule.rule === "reference") + // Serving always rebuilds a composed or templated field, so a response that leaves it out contradicts the plan. + if (rule.rule === "composed" || rule.rule === "template") + return value === ownMember(created.observed, rule.field); + if (rule.rule === "reference") return ( !Object.hasOwn(created.observed, rule.field) || - value === ownMember(created.observed, rule.field) || - (rule.rule === "reference" && - same(value, ownMember(created.observed, rule.field))) + same(value, ownMember(created.observed, rule.field)) ); if (rule.rule === "clock") return ( diff --git a/src/domain/analysisView/environmentStateWriteCreateCalls.ts b/src/domain/analysisView/environmentStateWriteCreateCalls.ts index d1cbe42555..91e04bc91a 100644 --- a/src/domain/analysisView/environmentStateWriteCreateCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCreateCalls.ts @@ -2,12 +2,14 @@ import type { JsonValue } from "../jsonValue.js"; import { ownMember, same, type JsonObject } from "./environmentStateRules.js"; import { storedKinds } from "./environmentStateWriteApply.js"; import { fittedClockFormat } from "./environmentStateWriteClockFormats.js"; +import { composedValue } from "./environmentStateWriteComposed.js"; import { alikeRules, isBlankValue, type FieldRule, type WriteBody, } from "./environmentStateWriteRules.js"; +import { slugOf, type Ampersand } from "./environmentStateWriteValues.js"; type Default = { readonly member: string; readonly value: JsonValue }; type Blank = NonNullable[number]; @@ -16,8 +18,13 @@ type Blank = NonNullable[number]; export interface EarlierEvidence { readonly kept: ReadonlySet; readonly clockOf: (field: string) => JsonValue | undefined; + /** How the plan's accepted calls and the type's held entities spelled `&` in each field's slug; absent where none showed one. */ + readonly ampersands?: ReadonlyMap; } +/** How a field's slug showed `&`: dropped, as the word, or in a way neither spelling gives. */ +export type AmpersandSpelling = "dropped" | Ampersand | "unknown"; + /** The plan's rule for a field and a later call's agree: equal, or clocks one spelling covers given what earlier calls showed. */ const agreeing = ( held: FieldRule, @@ -26,10 +33,18 @@ const agreeing = ( readonly field: string; readonly later: JsonValue | undefined; readonly earlier: EarlierEvidence; + readonly exemplar: JsonObject; + readonly laterEntity: JsonObject; }, ): FieldRule | undefined => { const alike = alikeRules(held, later, values.earlier.kept); if (alike !== undefined) return alike; + const spelled = ampersandSpelled( + held, + { rule: later, entity: values.laterEntity }, + values.earlier.ampersands?.get(values.field), + ); + if (spelled !== undefined) return spelled; if (held.rule !== "clock" || later.rule !== "clock") return undefined; const format = fittedClockFormat( held.format, @@ -39,6 +54,134 @@ const agreeing = ( return format === undefined ? undefined : { ...held, format }; }; +/** A rule with its slugs spelled the default way. */ +const unspelled = (rule: FieldRule): FieldRule => { + if (rule.rule === "slug") { + const { ampersand: _ampersand, ...plain } = rule; + return plain; + } + if (rule.rule !== "composed") return rule; + return { + ...rule, + parts: rule.parts.map((part) => + "as" in part && part.as === "slug" + ? { from: part.from, as: "slug" as const } + : part, + ), + }; +}; + +/** Whether a call's entity holds `&` in a field the rule slugs, which shows how it spells one. */ +const showsAmpersand = (rule: FieldRule, entity: JsonObject): boolean => + (rule.rule === "slug" + ? [rule.from] + : rule.rule === "composed" + ? rule.parts.flatMap((part) => + "as" in part && part.as === "slug" ? [part.from] : [], + ) + : [] + ).some((field) => { + const value = ownMember(entity, field); + return typeof value === "string" && value.includes("&"); + }); + +/** + * Two calls' rules that differ only in how a slug spells `&`: the one that spells it as the word, when the call + * spelling it otherwise slugged no `&`, and no call the plan accepted nor entity the type held dropped one (`shown`). + */ +export const ampersandSpelled = ( + held: FieldRule, + later: { readonly rule: FieldRule; readonly entity: JsonObject }, + shown: AmpersandSpelling | undefined, +): FieldRule | undefined => { + const plainHeld = unspelled(held); + if (!same(plainHeld, unspelled(later.rule))) return undefined; + const heldSpells = !same(held, plainHeld); + if (heldSpells === !same(later.rule, unspelled(later.rule))) return undefined; + if (heldSpells) + return showsAmpersand(later.rule, later.entity) ? undefined : held; + return shown === "dropped" || shown === "unknown" ? undefined : later.rule; +}; + +/** How a rule's slug spells `&`, for a rule that slugs a field. */ +const slugSpelling = (rule: FieldRule): AmpersandSpelling | undefined => { + if (rule.rule === "slug") return rule.ampersand ?? "dropped"; + if (rule.rule !== "composed") return undefined; + for (const part of rule.parts) + if ("as" in part && part.as === "slug") return part.ampersand ?? "dropped"; + return undefined; +}; + +/** Note how a call that slugged `&` spelled it in each field its own rules slug. */ +export const noteAmpersands = ( + ampersands: Map, + rules: readonly FieldRule[], + entity: JsonObject, +): void => { + for (const rule of rules) { + const spelling = slugSpelling(rule); + if (spelling !== undefined && showsAmpersand(rule, entity)) + ampersands.set(rule.field, spelling); + } +}; + +/** Exact spelling observations in held entities; absence and contradictory spellings remain distinct. */ +export const heldAmpersandSpellings = ( + rule: FieldRule, + held: readonly JsonObject[], +): ReadonlySet => { + const observed = new Set(); + if (slugSpelling(rule) === undefined) return observed; + const plain = unspelled(rule); + for (const entity of held) { + if (!showsAmpersand(rule, entity)) continue; + const stored = ownMember(entity, rule.field); + if (stored === undefined) continue; + const source = + rule.rule === "slug" ? ownMember(entity, rule.from) : undefined; + const spelled = (spelling: Ampersand | undefined): string | undefined => + rule.rule === "slug" + ? typeof source === "string" + ? slugOf(source, spelling) + : undefined + : plain.rule === "composed" + ? composedValue( + plain.parts.map((part) => + "as" in part && part.as === "slug" && spelling !== undefined + ? { ...part, ampersand: spelling } + : part, + ), + entity, + ) + : undefined; + observed.add( + spelled(undefined) === stored + ? "dropped" + : spelled("and") === stored + ? "and" + : "unknown", + ); + } + return observed; +}; + +/** Retain the constraints later rule refinement needs from the same owned observations. */ +export const noteHeldAmpersands = ( + ampersands: Map, + rules: readonly FieldRule[], + held: readonly JsonObject[], +): void => { + for (const rule of rules) { + const observed = heldAmpersandSpellings(rule, held); + const known = ampersands.get(rule.field); + if (observed.has("dropped")) ampersands.set(rule.field, "dropped"); + else if (observed.has("unknown") && known !== "dropped") + ampersands.set(rule.field, "unknown"); + else if (observed.has("and") && known === undefined) + ampersands.set(rule.field, "and"); + } +}; + /** Defaults joined, undefined when two calls showed one member left out at different values. */ const joinedDefaults = ( held: readonly Default[], @@ -82,6 +225,8 @@ const addedReference = ( field, later: later.entity[field], earlier: calls.earlier, + exemplar: calls.exemplar, + laterEntity: later.entity, }); return rule === undefined ? undefined : { rule }; }; @@ -115,6 +260,8 @@ const mergedField = ( field, later: later.entity[field], earlier: calls.earlier, + exemplar, + laterEntity: later.entity, }); return rule === undefined ? undefined : { rule }; } diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index ab56f9ff85..eac6cf7c2e 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -51,6 +51,12 @@ import { } from "./environmentStateWriteObserve.js"; import { plannedBody, restampedFields } from "./environmentStateWriteStamps.js"; import { readClock } from "./environmentStateWriteValues.js"; +import type { CreateEvidence } from "./environmentStateWriteExplain.js"; +import { + noteAmpersands, + noteHeldAmpersands, + type AmpersandSpelling, +} from "./environmentStateWriteCreateCalls.js"; import { fittedClockFormat, matchesClockFormat, @@ -98,6 +104,10 @@ export interface WriteDraft { readonly kept: Set; /** A value a held entity or an accepted call showed each clock field at a fixed width in, which keeps it untrimmed. */ readonly fixedWidth: Map; + /** What explained a create's exemplar, kept until a later call widens the plan, which it may then explain again. */ + exemplarInput?: CreateEvidence | undefined; + /** How the calls a create plan accepted and the entities its type held spelled `&` in each slugged field. */ + readonly ampersands: Map; } /** The value a draft fits a clock field against: one showing it at a fixed width, else the exemplar's. */ @@ -401,7 +411,10 @@ const refineUpdate = ( const shown = later.get(rule.field); return shown === undefined ? rule - : (reconciledUpdateRule(rule, shown, draft.kept) ?? rule); + : (reconciledUpdateRule(rule, shown, draft.kept, { + entity: evidence.after.object, + ampersands: draft.ampersands, + }) ?? rule); }), ...evidence.explained.rules.filter((rule) => !ruled.has(rule.field)), ]; @@ -415,6 +428,40 @@ export const retainFixed = ( if (fixed.length > 0) context.io.retain(64 + jsonTextCodeUnits(fixed) * 2); }; +/** Retain only exemplars that may acquire a composed rule; state already owns and charges their entity facts. */ +export const retainCreateExemplar = ( + context: EffectContext, + input: CreateEvidence, + rules: readonly FieldRule[], +): CreateEvidence | undefined => { + if ( + !Object.entries(input.entity).some( + ([field, value]) => + typeof value === "string" && + !rules.some((rule) => rule.field === field), + ) + ) + return undefined; + // Snapshot arrays borrow immutable entity fields whose state charges last + // throughout simulation. Their containers and the request reader's temporary + // facts need their own retained charge before the draft adopts them. + const references = [...new Set([input.collection, input.type])].reduce( + (count, items) => count + items.length, + 0, + ); + context.io.retain( + 192 + + references * 8 + + jsonTextCodeUnits(input.call.body) * 2 + + input.call.path.length * 2 + + [...input.call.pathValues].reduce( + (bytes, [key, value]) => bytes + 64 + (key.length + value.length) * 2, + 0, + ), + ); + return input; +}; + /** An update's targets, and its recorded outcome: the entity takes what its observation shows, attributed to no tool. */ const outcomes = ( context: EffectContext, @@ -478,8 +525,13 @@ const buildUpdate = ( rules: evidence.explained.rules, }); draft.observed = evidence.after.object; - for (const entity of context.state.ofType(draft.plan.target!)) - noteFixedWidths(draft.fixedWidth, entity.fields); + const held = context.state.ofType(draft.plan.target!); + for (const entity of held) noteFixedWidths(draft.fixedWidth, entity.fields); + noteHeldAmpersands( + draft.ampersands, + evidence.explained.rules, + held.map((entity) => entity.fields), + ); }; /** @@ -598,6 +650,7 @@ export const updateEntity = ( draft.plan.rules = rules; noteFixedWidths(draft.fixedWidth, after.object); buildUpdate(context, draft, step, { after, explained, idField }); + noteAmpersands(draft.ampersands, explained.rules, after.object); // Serving reads the plan's observation, so the gate does too; generated values are this call's. const written = updatedFields( draft.plan, @@ -742,4 +795,7 @@ const contradictsPlan = ( ): boolean => contradictsEarlierCalls(draft, explained.rules) || contradictsGeneratedClock(before, after, draft.plan.rules, explained) || - conflictsWithPlan(draft.plan, explained, draft.kept); + conflictsWithPlan(draft.plan, explained, draft.kept, { + entity: after, + ampersands: draft.ampersands, + }); diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index b65f37a770..562ce66fdc 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -1,3 +1,4 @@ +import { heldAmpersandSpellings } from "./environmentStateWriteCreateCalls.js"; import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { @@ -28,11 +29,14 @@ import { isUuid, readClock, sequenceNumber, - slugOf, + slugSpellings, sortedStrings, type ClockFormat, } from "./environmentStateWriteValues.js"; -import { createdComposer } from "./environmentStateWriteComposed.js"; +import { + composedValue, + createdComposer, +} from "./environmentStateWriteComposed.js"; import { referenceRule } from "./environmentStateWriteReferences.js"; import { fittedClockFormat, @@ -62,6 +66,8 @@ import { heldClock, sameSecondClocks, sentRule, + slugRule, + type SlugSource, type UpdateEvidence, } from "./environmentStateWriteChanges.js"; @@ -76,6 +82,8 @@ export interface CreateEvidence { readonly type: readonly JsonObject[]; /** The entities of any type the state holds under an id. */ readonly named?: (id: string) => readonly StateEntity[]; + /** Composed rules another call of the tool learned, which explain a field here when they spell its value. */ + readonly hints?: readonly FieldRule[]; } /** What explaining a create looks values up in, built once per request body. */ @@ -83,9 +91,24 @@ interface BodyIndex { /** Each body string long enough to show a copy, at its shallowest pointer (breadth-first, members in order). */ readonly copies: ReadonlyMap; /** Each slug a top-level string member slugs to, other than its own value, from the first such member. */ - readonly slugs: ReadonlyMap; + readonly slugs: ReadonlyMap; } +/** Add a string's slugs to an index, keeping the first member that spells each; `own` keeps one equal to the string. */ +const addSlugs = ( + slugs: Map, + member: string, + sent: string, + own = false, +): void => { + for (const { slug, ampersand } of slugSpellings(sent)) + if ((own || slug !== sent) && !slugs.has(slug)) + slugs.set(slug, { + from: member, + ...(ampersand === undefined ? {} : { ampersand }), + }); +}; + /** Index a request body once, so explaining each field looks values up rather than walking the body again. */ const indexBody = (body: JsonObject): BodyIndex => { const copies = new Map(); @@ -111,12 +134,9 @@ const indexBody = (body: JsonObject): BodyIndex => { pointer: `${next.pointer}${pointerTo(key)}`, }); } - const slugs = new Map(); + const slugs = new Map(); for (const [member, sent] of Object.entries(body)) - if (typeof sent === "string") { - const slug = slugOf(sent); - if (slug !== sent && !slugs.has(slug)) slugs.set(slug, member); - } + if (typeof sent === "string") addSlugs(slugs, member, sent); return { copies, slugs }; }; @@ -167,10 +187,8 @@ const requestRule = ( if (text !== undefined && text === pathValue) return { field, rule: "path", from: name }; const slugged = typeof value === "string" && value.length >= 3; - const member = slugged ? index.slugs.get(value) : undefined; - return member === undefined - ? undefined - : { field, rule: "slug", from: member }; + const source = slugged ? index.slugs.get(value) : undefined; + return source === undefined ? undefined : slugRule(field, source); }; /** A clock's format, trimmed when the type's values of the field show its fraction trimmed and none shows a fixed width. */ @@ -292,6 +310,36 @@ const bodyOf = ( }); /** Explain every field of a created entity: echoed, copied, path, slug, generated, template or constant (unlisted). */ +/** + * A slug rule as the held entities spell `&`: as the word when the call slugged none and they spell it so, and no rule + * when they spell it both ways, or otherwise than the call did or in a way neither spelling gives. Other rules pass + * unchanged. + */ +const heldSlugRule = ( + rule: FieldRule | undefined, + sent: JsonObject, + held: readonly JsonObject[], +): FieldRule | undefined => { + if (rule?.rule !== "slug") return rule; + const shown = heldAmpersandSpellings(rule, held); + const source = ownMember(sent, rule.from); + if (typeof source === "string" && source.includes("&")) + return shown.has("unknown") || + shown.has(rule.ampersand === "and" ? "dropped" : "and") + ? undefined + : rule; + if (shown.has("and") && shown.has("dropped")) return undefined; + return shown.has("and") && !shown.has("unknown") + ? { ...rule, ampersand: "and" } + : rule; +}; + +/** Whether a composed string spells a field besides the one a template rebuilds it around, such as a title's slug beside a generated id. */ +const spellsMore = (composed: FieldRule, template: FieldRule): boolean => + composed.rule === "composed" && + template.rule === "template" && + composed.parts.some((part) => "from" in part && part.from !== template.from); + export const explainCreate = ( evidence: CreateEvidence, ): { @@ -302,9 +350,15 @@ export const explainCreate = ( const ruled: FieldRule[] = []; const unexplained: string[] = []; const index = indexBody(evidence.call.body); + // The member each slug the held entities' `&` spellings refused was slugged from. + const refusedSlugs = new Map(); for (const [field, value] of Object.entries(evidence.entity)) { + const requested = requestRule(evidence, index, field, value); + const held = heldSlugRule(requested, evidence.call.body, evidence.type); + if (requested?.rule === "slug" && held === undefined) + refusedSlugs.set(field, requested.from); const rule = - requestRule(evidence, index, field, value) ?? + held ?? generatedRule(evidence, field, value) ?? (evidence.named === undefined ? undefined @@ -332,10 +386,26 @@ export const explainCreate = ( const constants: string[] = []; const composed = createdComposer(evidence.entity, rules, evidence.type); for (const field of unexplained) { - const template = - templateRule(evidence.entity, field, sources) ?? composed(field); - if (template === undefined) constants.push(field); - else rules.push(template); + const hinted = evidence.hints?.find( + (rule) => + rule.field === field && + rule.rule === "composed" && + composedValue(rule.parts, evidence.entity) === + ownMember(evidence.entity, field), + ); + if (hinted !== undefined) { + rules.push(hinted); + continue; + } + const template = templateRule(evidence.entity, field, sources); + const spelled = composed(field); + const rule = + spelled !== undefined && + (template === undefined || spellsMore(spelled, template)) + ? spelled + : template; + if (rule === undefined) constants.push(field); + else rules.push(rule); } const tokens = [...evidence.call.pathValues.values()].filter( (value) => value.length > 0, @@ -360,6 +430,11 @@ export const explainCreate = ( evidence.named ?? (() => []), ), ...instantMembers(evidence.call.body, evidence.entity, constants), + // A refused slug kept as recorded is right only for the name it was slugged from. + ...constants.flatMap((field) => { + const from = refusedSlugs.get(field); + return from === undefined ? [] : [from]; + }), ]); const pinned = unreferenced( rules, @@ -401,12 +476,11 @@ export const explainCreate = ( const echoedSlugs = ( body: JsonObject, echoed: ReadonlySet, -): Map => { - const slugs = new Map(); +): Map => { + const slugs = new Map(); for (const member of echoed) { const sent = ownMember(body, member); - if (typeof sent === "string" && !slugs.has(slugOf(sent))) - slugs.set(slugOf(sent), member); + if (typeof sent === "string") addSlugs(slugs, member, sent, true); } return slugs; }; @@ -440,6 +514,7 @@ export const explainUpdate = ( readonly unstamped: string[]; } => { const { body } = evidence.call; + const knownEntities = [evidence.before, ...evidence.others]; const rules: FieldRule[] = []; const echoed = new Set(); const unexplained: string[] = []; @@ -465,7 +540,15 @@ export const explainUpdate = ( continue; } if (!Object.hasOwn(evidence.after, member)) continue; - const rule = sentRule(evidence, member, held); + const rule = heldSlugRule( + sentRule(evidence, member, held), + body, + knownEntities, + ); + if (rule === undefined) { + unexplained.push(member); + continue; + } rules.push(rule); if (rule.rule === "echo") echoed.add(member); else if (rule.rule !== "constant") generated.add(member); @@ -493,7 +576,11 @@ export const explainUpdate = ( if (kept !== undefined) rules.push(kept); continue; } - const rule = changeRule(evidence, field, slugs); + const rule = heldSlugRule( + changeRule(evidence, field, slugs), + body, + knownEntities, + ); if (rule === undefined) unexplained.push(field); else rules.push(rule); } diff --git a/src/domain/analysisView/environmentStateWriteRules.ts b/src/domain/analysisView/environmentStateWriteRules.ts index cad517f8ab..a91c60050f 100644 --- a/src/domain/analysisView/environmentStateWriteRules.ts +++ b/src/domain/analysisView/environmentStateWriteRules.ts @@ -11,6 +11,7 @@ import { } from "./environmentStateRules.js"; import { sortedStrings, + type Ampersand, type ClockFormat, } from "./environmentStateWriteValues.js"; import { @@ -89,7 +90,13 @@ export type FieldRule = | { readonly field: string; readonly rule: "echo"; readonly sorted?: true } | { readonly field: string; readonly rule: "copy"; readonly from: string } | { readonly field: string; readonly rule: "path"; readonly from: string } - | { readonly field: string; readonly rule: "slug"; readonly from: string } + /** A slug of the sent member, spelling `&` as the word when `ampersand` says so. */ + | { + readonly field: string; + readonly rule: "slug"; + readonly from: string; + readonly ampersand?: Ampersand; + } | { readonly field: string; readonly rule: "template"; readonly from: string } /** A string rebuilt from literal text and other written fields, in order. */ | { @@ -195,7 +202,12 @@ export const keptOrders = (body: JsonObject, stored: JsonObject): string[] => /** One part of a composed string: literal text, or another written field as written, slugged, or as a clock's calendar digits. */ export type ComposedPart = | { readonly text: string } - | { readonly from: string; readonly as: "value" | "slug" } + | { readonly from: string; readonly as: "value" } + | { + readonly from: string; + readonly as: "slug"; + readonly ampersand?: Ampersand; + } | { readonly from: string; readonly as: "clock"; diff --git a/src/domain/analysisView/environmentStateWriteValues.ts b/src/domain/analysisView/environmentStateWriteValues.ts index 79d1fe7030..aa62539e65 100644 --- a/src/domain/analysisView/environmentStateWriteValues.ts +++ b/src/domain/analysisView/environmentStateWriteValues.ts @@ -305,14 +305,25 @@ const fillMask = (mask: string, value: number): string => { }; /** Lower case, with every run of other characters joined by one `-` and none at either end. */ -export const slugOf = (text: string): string => - text +/** How a slug spells `&`: dropped when absent, as Django does, or as the word, as Go's gosimple/slug and JavaScript's slugify do. */ +export type Ampersand = "and"; + +export const slugOf = (text: string, ampersand?: Ampersand): string => + (ampersand === "and" ? text.replaceAll("&", " and ") : text) .normalize("NFKD") .replace(/[̀-ͯ]/gu, "") .toLowerCase() .replace(/[^a-z0-9]+/gu, "-") .replace(/^-+|-+$/gu, ""); +/** A text's slugs, each with the spelling that gives it: also with `&` as the word when the text holds one. */ +export const slugSpellings = ( + text: string, +): readonly { readonly slug: string; readonly ampersand?: Ampersand }[] => + text.includes("&") + ? [{ slug: slugOf(text) }, { slug: slugOf(text, "and"), ampersand: "and" }] + : [{ slug: slugOf(text) }]; + const isAlphanumeric = (character: string | undefined): boolean => character !== undefined && /[\p{L}\p{N}]/u.test(character); diff --git a/src/domain/analysisView/evmInterfaceView.ts b/src/domain/analysisView/evmInterfaceView.ts index d8f89e9469..246e525a0f 100644 --- a/src/domain/analysisView/evmInterfaceView.ts +++ b/src/domain/analysisView/evmInterfaceView.ts @@ -14,7 +14,10 @@ import { completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; -import { analysisTextPage } from "./analysisViewText.js"; +import { + analysisTextPage, + analysisTextPageItemSchema, +} from "./analysisViewText.js"; /** Retained inferred candidates or exact text; offsets for text count UTF-16 code units. */ export const evmViewRequestSchema = z.strictObject({ @@ -26,7 +29,7 @@ export const evmViewRequestSchema = z.strictObject({ export const evmViewItemSchema = z.union([ evmInterfaceSchema.shape.functions, - z.strictObject({ text: z.string(), unit: z.literal("utf16-code-units") }), + analysisTextPageItemSchema, ]); /** Complete inferred interface facts without the unrequested hex or duplicated stdout. */ @@ -63,11 +66,11 @@ export const projectEvmInterfaceView = ( const parsed = parseAnalysisViewResult(evmInterfaceSchema, parent); if (!parsed.ok) return parsed; const report = parsed.value; + const subject = parent.subject; if ( - parent.subject == null || - parent.subject.format !== "file" || - parent.subject.local_path !== report.artifact.path || - parent.subject.digest.sha256 !== report.artifact.sha256 + subject == null || + subject.format !== "file" || + subject.digest.sha256 !== report.artifact.sha256 ) return err( analysisViewInputError([ @@ -75,7 +78,7 @@ export const projectEvmInterfaceView = ( path: ["source", "subject"], reason: "invalid_value", message: - "EVM parent subject must identify the report's exact carrier path and SHA-256.", + "EVM parent subject must identify the report's carrier format and SHA-256.", }, ]), ); diff --git a/src/domain/analysisView/goBinaryView.test.ts b/src/domain/analysisView/goBinaryView.test.ts index f8ff4aecdc..807b10f14d 100644 --- a/src/domain/analysisView/goBinaryView.test.ts +++ b/src/domain/analysisView/goBinaryView.test.ts @@ -72,14 +72,13 @@ it("bounds inherited messages while keeping their complete parent reference", () expect(input.limitations[0]).toBe(note); }); -it.each(["path", "digest", "format"] as const)( +it.each(["digest", "format"] as const)( "rejects a Go parent whose authenticated %s conflicts with its report", (field) => { const input = parent(); if (input.subject == null) throw new Error("Expected artifact subject"); const subject = { ...input.subject, - ...(field === "path" ? { local_path: "/artifacts/other" } : {}), ...(field === "digest" ? { digest: { sha256: "b".repeat(64) } } : {}), }; const result = projectAnalysisView( @@ -144,7 +143,7 @@ it("revalidates the parent subject after immutable Go report facts have been cac projectAnalysisView( { ...input, - subject: { ...input.subject, local_path: "/artifacts/other" }, + subject: { ...input.subject, digest: { sha256: "b".repeat(64) } }, }, { kind: "summary" }, ).ok, diff --git a/src/domain/analysisView/goBinaryView.ts b/src/domain/analysisView/goBinaryView.ts index 64a3b63bb9..83e2510a07 100644 --- a/src/domain/analysisView/goBinaryView.ts +++ b/src/domain/analysisView/goBinaryView.ts @@ -122,11 +122,12 @@ export const projectGoBinaryView = ( const parsed = parseReport(parent); if (!parsed.ok) return parsed; const report = parsed.value; + const subject = parent.subject; if ( - parent.subject?.local_path !== report.artifact.path || - parent.subject.digest.sha256 !== report.artifact.sha256 || - parent.subject.format !== metadataReportSubjectFormat(report) || - parent.subject.architecture !== + subject == null || + subject.digest.sha256 !== report.artifact.sha256 || + subject.format !== metadataReportSubjectFormat(report) || + subject.architecture !== (binaryMetadataArchitecture(report.architecture) ?? null) ) return err( diff --git a/src/domain/analysisView/gpuCodeObjectsView.test.ts b/src/domain/analysisView/gpuCodeObjectsView.test.ts index 147a1bbd6e..bf4f74a2e0 100644 --- a/src/domain/analysisView/gpuCodeObjectsView.test.ts +++ b/src/domain/analysisView/gpuCodeObjectsView.test.ts @@ -41,7 +41,7 @@ it("checks each subject despite reusable GPU summary indexes", () => { const input = parent(source); expect(projectGpuCodeObjectsView(input, { kind: "summary" }).ok).toBe(true); const subject = createEvidence( - { path: "/other", sha256: source.artifact.sha256, format: "file" }, + { path: "/other", sha256: "b".repeat(64), format: "file" }, { id: "fixture", name: "Fixture", version: "1" }, { operation: input.operation, diff --git a/src/domain/analysisView/gpuCodeObjectsView.ts b/src/domain/analysisView/gpuCodeObjectsView.ts index 10d0f5800d..2447d0f268 100644 --- a/src/domain/analysisView/gpuCodeObjectsView.ts +++ b/src/domain/analysisView/gpuCodeObjectsView.ts @@ -193,10 +193,10 @@ const readGpuFacts = ( }; if (reusable) checkedGpu.set(report, checked); } + const subject = parent.subject; if ( - parent.subject != null && - (parent.subject.local_path !== checked.report.artifact.path || - parent.subject.digest.sha256 !== checked.report.artifact.sha256) + subject != null && + subject.digest.sha256 !== checked.report.artifact.sha256 ) return err( analysisViewInputError([ diff --git a/src/domain/analysisView/il2cppMetadataView.ts b/src/domain/analysisView/il2cppMetadataView.ts index 3878526133..e62d2a4fd2 100644 --- a/src/domain/analysisView/il2cppMetadataView.ts +++ b/src/domain/analysisView/il2cppMetadataView.ts @@ -535,11 +535,11 @@ export const projectIl2cppMetadataView = ( const parsed = parseMetadataReport(parent); if (!parsed.ok) return parsed; const { report, counts } = parsed.value; + const subject = parent.subject; if ( - parent.subject != null && - (parent.subject.format !== "file" || - parent.subject.local_path !== report.artifact.path || - parent.subject.digest.sha256 !== report.artifact.sha256) + subject != null && + (subject.format !== "file" || + subject.digest.sha256 !== report.artifact.sha256) ) return err( analysisViewInputError([ diff --git a/src/domain/analysisView/javascriptRoutesView.test.ts b/src/domain/analysisView/javascriptRoutesView.test.ts index 20cbde9e36..c58af1afa7 100644 --- a/src/domain/analysisView/javascriptRoutesView.test.ts +++ b/src/domain/analysisView/javascriptRoutesView.test.ts @@ -179,7 +179,7 @@ it("checks subject identity again after warming an immutable parent", () => { subject: { name: "app", local_path: "/different", - digest: { sha256: "c".repeat(64) }, + digest: { sha256: "d".repeat(64) }, format: "javascript-bundle", architecture: null, }, diff --git a/src/domain/analysisView/javascriptRoutesView.ts b/src/domain/analysisView/javascriptRoutesView.ts index 4fa079c3dd..1b2974ff5c 100644 --- a/src/domain/analysisView/javascriptRoutesView.ts +++ b/src/domain/analysisView/javascriptRoutesView.ts @@ -344,11 +344,8 @@ export const projectJavaScriptRoutesView = ( path: identity.data.input_path, sha256: identity.data.root_artifact_sha256, }; - if ( - parent.subject != null && - (parent.subject.local_path !== artifact.path || - parent.subject.digest.sha256 !== artifact.sha256) - ) + const subject = parent.subject; + if (subject != null && subject.digest.sha256 !== artifact.sha256) return err( inputError( "Parent subject does not match its application artifact identity.", diff --git a/src/domain/analysisView/modelArtifactView.ts b/src/domain/analysisView/modelArtifactView.ts index 006281aa9d..3b96c9b7ee 100644 --- a/src/domain/analysisView/modelArtifactView.ts +++ b/src/domain/analysisView/modelArtifactView.ts @@ -142,11 +142,8 @@ const read = (parent: AnalysisViewParent): Result => { const report = parsed.value; const reusable = isImmutableJsonSnapshot(report); const cached = reusable ? checked.get(report) : undefined; - if ( - parent.subject != null && - (parent.subject.local_path !== report.artifact.path || - parent.subject.digest.sha256 !== report.artifact.sha256) - ) + const subject = parent.subject; + if (subject != null && subject.digest.sha256 !== report.artifact.sha256) return err( analysisViewInputError([ { diff --git a/src/domain/analysisView/nativeExportsView.ts b/src/domain/analysisView/nativeExportsView.ts index 579ef62c7f..d0d8d1ee97 100644 --- a/src/domain/analysisView/nativeExportsView.ts +++ b/src/domain/analysisView/nativeExportsView.ts @@ -38,7 +38,12 @@ export const nativeExportsViewRequestSchema = z.discriminatedUnion( z.strictObject({ ...page, collection: z.literal("exports"), - names: z.array(z.string()).optional(), + names: z + .array(z.string()) + .optional() + .describe( + "Exact PE names, or UTF-8 ELF source names. ELF display fallbacks and names with unknown encoding are not identities; use indices for those rows. Omitted includes all names; [] matches none.", + ), indices: z.array(z.int().nonnegative()).optional(), fields: z.array(z.union([peRow.keyof(), elfRow.keyof()])).optional(), }), @@ -174,6 +179,32 @@ export const projectNativeExportsView = ( `Selected export fields do not belong to ${report.format}; available fields: ${allowed.join(", ")}.`, ); const names = view.names === undefined ? undefined : new Set(view.names); + const encodedNames = + names === undefined + ? undefined + : new Set( + [...names].flatMap((name) => { + const bytes = Buffer.from(name, "utf8"); + return bytes.toString("utf8") === name + ? [bytes.toString("base64")] + : []; + }), + ); + if ( + names !== undefined && + names.size > 0 && + report.format === "elf" && + report.exports.some( + (row) => + row.name_utf8_decodable === null || + (row.name_utf8_decodable && + row.name_bytes_base64 === null && + !/^[\x00-\x7f]*$/u.test(row.name)), + ) + ) + shared.unknowns.push( + "Some ELF names lack retained encoding facts and cannot be matched by UTF-8 identity; select their source indices. A no-match page does not establish their absence.", + ); // A projected decoded name still carries its exact bytes and encoding qualification. const columns = new Set(fields); if (report.format === "elf" && columns.has("name")) { @@ -204,7 +235,13 @@ export const projectNativeExportsView = ( view, (row, index) => (indices === undefined || indices.has(index)) && - (names === undefined || (row.name !== null && names.has(row.name))), + (names === undefined || + ("name_utf8_decodable" in row + ? row.name_utf8_decodable === true && + (row.name_bytes_base64 !== null + ? encodedNames!.has(row.name_bytes_base64) + : /^[\x00-\x7f]*$/u.test(row.name) && names.has(row.name)) + : row.name !== null && names.has(row.name))), projectRow, ); return ok({ diff --git a/src/domain/analysisView/pcapCaptureView.ts b/src/domain/analysisView/pcapCaptureView.ts index e246869aad..5ae4095d88 100644 --- a/src/domain/analysisView/pcapCaptureView.ts +++ b/src/domain/analysisView/pcapCaptureView.ts @@ -113,17 +113,14 @@ const parseParent = ( ): Result => { const parsed = parseAnalysisViewResult(captureSchema, parent); if (!parsed.ok) return parsed; - if ( - parent.subject?.local_path !== parsed.value.target.path || - parent.subject.digest.sha256 !== parsed.value.target.sha256 - ) + const subject = parent.subject; + if (subject == null || subject.digest.sha256 !== parsed.value.target.sha256) return err( analysisViewInputError([ { path: ["source", "subject"], reason: "invalid_value", - message: - "Packet parent subject must identify the report's exact path and SHA-256.", + message: "Packet parent subject must identify the report's SHA-256.", }, ]), ); diff --git a/src/domain/analysisView/protobufCaptureView.test.ts b/src/domain/analysisView/protobufCaptureView.test.ts index 04d613a5a5..d0619f31b7 100644 --- a/src/domain/analysisView/protobufCaptureView.test.ts +++ b/src/domain/analysisView/protobufCaptureView.test.ts @@ -207,7 +207,10 @@ it("omits large values by default, returns requested text inline and preserves p items: [{ text: "hello", body_status: "partial" }], }); const changedSubject = projectAnalysisView( - { ...parent, subject: { ...parent.subject!, local_path: "/other.har" } }, + { + ...parent, + subject: { ...parent.subject!, digest: { sha256: "e".repeat(64) } }, + }, { kind: "protobuf", collection: "bodies", offset: 0, limit: 1 }, ); expect(changedSubject.ok).toBe(false); diff --git a/src/domain/analysisView/rustBinaryView.test.ts b/src/domain/analysisView/rustBinaryView.test.ts index 15aafbedf7..065b86a6fd 100644 --- a/src/domain/analysisView/rustBinaryView.test.ts +++ b/src/domain/analysisView/rustBinaryView.test.ts @@ -111,7 +111,6 @@ it.each([false, true])( it("checks exact artifact identity and subject architecture for every Rust view", () => { for (const changed of [ - { local_path: "/different" }, { format: "pe" as const }, { architecture: "arm64" as const }, { digest: { sha256: "b".repeat(64) } }, diff --git a/src/domain/analysisView/rustBinaryView.ts b/src/domain/analysisView/rustBinaryView.ts index cb3c9dfb71..5354e2441e 100644 --- a/src/domain/analysisView/rustBinaryView.ts +++ b/src/domain/analysisView/rustBinaryView.ts @@ -126,7 +126,6 @@ export const projectRustBinaryView = ( const subject = parent.subject; if ( subject == null || - subject.local_path !== report.artifact.path || subject.digest.sha256 !== report.artifact.sha256 || subject.format !== metadataReportSubjectFormat(report) || subject.architecture !== diff --git a/src/domain/analysisView/wasmArtifactView.ts b/src/domain/analysisView/wasmArtifactView.ts index ef62fac0a4..de6b65fb31 100644 --- a/src/domain/analysisView/wasmArtifactView.ts +++ b/src/domain/analysisView/wasmArtifactView.ts @@ -17,7 +17,10 @@ import { completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; -import { analysisTextPage } from "./analysisViewText.js"; +import { + analysisTextPage, + analysisTextPageItemSchema, +} from "./analysisViewText.js"; /** Source forms and text from completed WASM Evidence; this never executes a VM. */ export const wasmViewRequestSchema = z.strictObject({ @@ -43,7 +46,7 @@ export const wasmViewItemSchema = z.union([ wasmArtifactSchema.shape.candidates, wasmArtifactSchema.shape.glue, wasmArtifactSchema.shape.tool_profile, - z.strictObject({ text: z.string(), unit: z.literal("utf16-code-units") }), + analysisTextPageItemSchema, ]); /** Counts are observations of the completed producer, independent of runtime semantics. */ @@ -92,7 +95,6 @@ export const projectWasmArtifactView = ( parent.subject == null ? null : { - path: parent.subject.local_path, sha256: parent.subject.digest.sha256, format: parent.subject.format, }, @@ -104,7 +106,7 @@ export const projectWasmArtifactView = ( path: ["source", "subject"], reason: "invalid_value", message: - "WASM parent Evidence subject must identify the report's exact path and SHA-256.", + "WASM parent Evidence subject must identify the report's file format and SHA-256.", }, ]), ); diff --git a/src/domain/analysisView/webNetworkCaptureView.test.ts b/src/domain/analysisView/webNetworkCaptureView.test.ts index ea87b86863..f55e907a94 100644 --- a/src/domain/analysisView/webNetworkCaptureView.test.ts +++ b/src/domain/analysisView/webNetworkCaptureView.test.ts @@ -93,7 +93,7 @@ it("checks each parent's subject even after its immutable capture has been valid projectAnalysisView( { ...source, - subject: { ...source.subject, local_path: "/different.saz" }, + subject: { ...source.subject, digest: { sha256: "e".repeat(64) } }, }, { kind: "summary" }, ).ok, @@ -154,7 +154,7 @@ it("keeps selected-record page coverage distinct from capture coverage and unsaf }); }); -it("retains explicitly unknown digests and rejects a different subject path", () => { +it("retains explicitly unknown digests and rejects a different subject digest", () => { const { parent, report } = capture(); const unknown = projectAnalysisView( { @@ -177,7 +177,7 @@ it("retains explicitly unknown digests and rejects a different subject path", () const mismatched = projectAnalysisView( { ...parent, - subject: { ...parent.subject, local_path: "/different/source.saz" }, + subject: { ...parent.subject, digest: { sha256: "e".repeat(64) } }, }, { kind: "summary" }, ); diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts index d236af38ab..eabee83134 100644 --- a/src/domain/analysisView/webNetworkCaptureView.ts +++ b/src/domain/analysisView/webNetworkCaptureView.ts @@ -422,19 +422,17 @@ const parseCaptureParent = ( const parsed = parseAnalysisViewResult(captureParser, parent); if (!parsed.ok) return parsed; const report = parsed.value; + const subject = parent.subject; if ( report.artifact.sha256 !== null && - (parent.subject == null || - parent.subject.local_path !== report.artifact.path || - parent.subject.digest.sha256 !== report.artifact.sha256) + (subject == null || subject.digest.sha256 !== report.artifact.sha256) ) return err( analysisViewInputError([ { path: ["source", "subject"], reason: "invalid_value", - message: - "Capture parent subject must identify the report's exact path and SHA-256.", + message: "Capture parent subject must identify the report's SHA-256.", }, ]), ); diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index a1e007b0d9..e3380224fb 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -1,5 +1,10 @@ import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; +import { androidManifestComponentsSchema } from "./androidManifestComponents.js"; +import { + androidViewRequestSchema, + validateAndroidPackageDelivery, +} from "./androidPackageQuery.js"; const target = { path: localPathStringSchema.describe( @@ -53,9 +58,12 @@ export const androidInputSchemas = { /** Optional retained delivery is separate from the engine's APK request. */ export const androidPackageInspectionInputSchema = - androidInputSchemas.inspect_android_package.extend({ - detail: z.enum(["complete", "summary"]).optional(), - }); + androidInputSchemas.inspect_android_package + .extend({ + detail: z.enum(["complete", "summary"]).optional(), + view: androidViewRequestSchema.optional(), + }) + .superRefine(validateAndroidPackageDelivery); /** Supported static Android operations. */ export type AndroidOperation = keyof typeof androidInputSchemas; @@ -97,39 +105,6 @@ const textCoverage = z.strictObject({ reported_total_bytes: z.int().nonnegative().nullable(), }); -const manifestComponent = z.strictObject({ - kind: z.enum([ - "activity", - "activity-alias", - "service", - "receiver", - "provider", - ]), - declared_name: z.string(), - name: z.string(), - exported: z.boolean().nullable(), - exported_default: z.boolean().nullable(), - permission: z.string().nullable(), - permission_source: z.enum(["component", "application", "none", "unknown"]), - intent_filter_count: z.number().int().nonnegative(), - actions: z.array(z.string()), - categories: z.array(z.string()), - foreground_service_type: z.string().nullable(), - authorities: z.array(z.string()).nullable(), - target_activity: z.string().nullable(), -}); - -/** - * Static component projection of the decoded manifest. The export default is - * Android's documented rule for an absent android:exported when one applies; - * null also means the default is unknown or an explicit declaration is - * required. This is not a merged-manifest or runtime observation. - */ -const manifestComponents = z.strictObject({ - status: z.enum(["complete", "partial", "unavailable"]), - items: z.array(manifestComponent), - limitations: z.array(z.string()), -}); /** A DEX prototype; hex parentheses also keep the advertised class valid in v mode. */ export const dexDescriptorSchema = z .string() @@ -212,7 +187,7 @@ export const androidResultSchemas = { manifest: textCoverage, // Evidence retained before the component projection existed re-parses // with an explicit unavailable inventory; current results always carry it. - components: manifestComponents.default(() => ({ + components: androidManifestComponentsSchema.default(() => ({ status: "unavailable" as const, items: [], limitations: [ diff --git a/src/domain/android/androidManifestComponents.ts b/src/domain/android/androidManifestComponents.ts new file mode 100644 index 0000000000..2a8fda640e --- /dev/null +++ b/src/domain/android/androidManifestComponents.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +/** Component declarations, not effective installation or runtime state. */ +export const ANDROID_COMPONENT_KINDS = [ + "activity", + "activity-alias", + "service", + "receiver", + "provider", +] as const; +export type AndroidComponentKind = (typeof ANDROID_COMPONENT_KINDS)[number]; + +/** Positions in retained decoded manifest text; offset counts UTF-16 code units. */ +export const androidManifestLocationSchema = z.strictObject({ + line: z.int().positive(), + column: z.int().positive(), + offset: z.int().nonnegative(), +}); +export type AndroidManifestLocation = z.output< + typeof androidManifestLocationSchema +>; + +export const androidIntentDataSchema = z.strictObject({ + location: androidManifestLocationSchema.nullable(), + scheme: z.string().nullable(), + host: z.string().nullable(), + port: z.string().nullable(), + path: z.string().nullable(), + path_prefix: z.string().nullable(), + path_pattern: z.string().nullable(), + path_advanced_pattern: z.string().nullable(), + path_suffix: z.string().nullable(), + mime_type: z.string().nullable(), +}); +export const androidIntentFilterSchema = z.strictObject({ + source_index: z.int().nonnegative(), + location: androidManifestLocationSchema.nullable(), + /** Exact declaration, including resource references; no effective priority claim. */ + priority: z.string().nullable(), + actions: z.array(z.string()), + categories: z.array(z.string()), + data: z.array(androidIntentDataSchema), + coverage: z.enum(["complete", "partial"]), +}); +export const androidComponentMetadataSchema = z.strictObject({ + name: z.string().nullable(), + location: androidManifestLocationSchema.nullable(), +}); + +export const androidManifestComponentSchema = z.strictObject({ + kind: z.enum(ANDROID_COMPONENT_KINDS), + declared_name: z.string(), + name: z.string(), + exported: z.boolean().nullable(), + exported_default: z.boolean().nullable(), + permission: z.string().nullable(), + permission_source: z.enum(["component", "application", "none", "unknown"]), + intent_filter_count: z.int().nonnegative(), + actions: z.array(z.string()), + categories: z.array(z.string()), + foreground_service_type: z.string().nullable(), + authorities: z.array(z.string()).nullable(), + target_activity: z.string().nullable(), + // Historical records never observed these facets. Omission stays omission; + // the default describes unknown coverage, never an absent declaration. + declaration_coverage: z + .enum(["complete", "partial", "unknown"]) + .default("unknown"), + declaration_index: z.int().nonnegative().optional(), + location: androidManifestLocationSchema.nullable().optional(), + enabled: z.boolean().nullable().optional(), + enabled_declaration: z.string().nullable().optional(), + process: z.string().nullable().optional(), + grant_uri_permissions: z.boolean().nullable().optional(), + grant_uri_permissions_declaration: z.string().nullable().optional(), + intent_filters: z.array(androidIntentFilterSchema).optional(), + meta_data: z.array(androidComponentMetadataSchema).optional(), +}); +export type AndroidManifestComponent = Readonly< + z.output +>; +export const androidManifestComponentsSchema = z.strictObject({ + status: z.enum(["complete", "partial", "unavailable"]), + items: z.array(androidManifestComponentSchema), + limitations: z.array(z.string()), +}); +export type AndroidManifestComponentInventory = Readonly< + z.output +>; + +/** Current producers observe every declaration facet; historical readers accept omissions. */ +export const observedAndroidManifestComponentSchema = + androidManifestComponentSchema.required(); +export type ObservedAndroidManifestComponent = Readonly< + z.output +>; +export const observedAndroidManifestComponentsSchema = + androidManifestComponentsSchema.extend({ + items: z.array(observedAndroidManifestComponentSchema), + }); +export type ObservedAndroidManifestComponentInventory = Readonly< + z.output +>; diff --git a/src/domain/android/androidPackageQuery.ts b/src/domain/android/androidPackageQuery.ts new file mode 100644 index 0000000000..69b84a8933 --- /dev/null +++ b/src/domain/android/androidPackageQuery.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import { androidManifestComponentSchema } from "./androidManifestComponents.js"; + +const componentFilters = { + kinds: z.array(androidManifestComponentSchema.shape.kind).optional(), + actions: z + .array(z.string()) + .optional() + .describe("Match any exact retained intent action; no substring matching"), + names: z + .array(z.string()) + .optional() + .describe( + "Exact resolved class names or declared component names; no substring or alias guessing", + ), + exported: z + .boolean() + .nullable() + .optional() + .describe( + "Exact exported declaration; null selects omitted or unresolved declarations. exported_default is separate.", + ), +}; + +/** Exact decoded text; positions count UTF-16 code units. */ +export const androidManifestViewRequestSchema = z.strictObject({ + kind: z.literal("android"), + facet: z.literal("manifest"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive(), +}); + +export const androidComponentsViewRequestSchema = z.strictObject({ + kind: z.literal("android"), + facet: z.literal("components"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), + fields: z.array(androidManifestComponentSchema.keyof()).optional(), + ...componentFilters, +}); + +export const androidComponentSummaryViewRequestSchema = z.strictObject({ + kind: z.literal("android"), + facet: z.literal("component-summary"), + ...componentFilters, +}); + +export const androidViewRequestSchema = z.discriminatedUnion("facet", [ + androidManifestViewRequestSchema, + androidComponentsViewRequestSchema, + androidComponentSummaryViewRequestSchema, +]); + +export const validateAndroidPackageDelivery = ( + value: { + readonly detail?: "complete" | "summary" | undefined; + readonly view?: unknown; + }, + context: z.RefinementCtx, +): void => { + if (value.view !== undefined && value.detail !== "summary") + context.addIssue({ + code: "custom", + path: ["view"], + message: + "Android view selection requires detail summary; complete detail preserves the complete source result.", + }); +}; + +/** Delivery choices never change the provider's analysis request. */ +export const androidPackageDeliverySchema = z + .strictObject({ + detail: z.enum(["complete", "summary"]).default("complete"), + view: androidViewRequestSchema.optional(), + }) + .superRefine(validateAndroidPackageDelivery); diff --git a/src/domain/apkSignature/apkSignatureReport.ts b/src/domain/apkSignature/apkSignatureReport.ts index 2f5207fc3a..41565816b9 100644 --- a/src/domain/apkSignature/apkSignatureReport.ts +++ b/src/domain/apkSignature/apkSignatureReport.ts @@ -440,13 +440,12 @@ export const deriveApkPosture = ( }; }; -/** The returned artifact identity must match the caller-selected local file. */ +/** Match semantic artifact identity; local subject paths are delivery metadata. */ export const apkSignatureMatchesSubject = ( artifact: ApkSignatureResult["artifact"], - subject: EvidenceSubjectTarget, + subject: Pick, ): boolean => artifact.sha256 === subject.sha256 && - artifact.path === subject.path && (subject.format === "file" || subject.format === "apk" || subject.format === "zip"); diff --git a/src/domain/apkSignature/apkV1SignatureBlock.test.ts b/src/domain/apkSignature/apkV1SignatureBlock.test.ts index f4aa41a1a9..02b676c152 100644 --- a/src/domain/apkSignature/apkV1SignatureBlock.test.ts +++ b/src/domain/apkSignature/apkV1SignatureBlock.test.ts @@ -6,6 +6,7 @@ import { indefinite, oid, withIssuer, + withSerial, LEAF_CERTIFICATE, LEAF_SHA256, OID_ECDSA_SHA256, @@ -307,6 +308,57 @@ it("matches supported issuer strings across encodings and RDN attribute order, p ).toBeNull(); }); +it("matches v1 signer serials by signed INTEGER value, preserving raw certificates", () => { + // apksig tolerates redundant sign padding, although DER requires minimal + // encoding. A sign octet remains significant when the next high bit differs. + const identify = (certificate: Uint8Array, serial: Uint8Array) => { + const block = decoded( + signedDataBlock({ + certificates: [certificate], + signerInfos: [signerInfo({ names: certificate, serial })], + }), + ); + expect(block.certificates.map((value) => Buffer.from(value))).toEqual([ + Buffer.from(certificate), + ]); + return block.signerInfos[0]?.certificateIndex; + }; + + expect(identify(LEAF_CERTIFICATE, Uint8Array.of(0x00, 0x12, 0x34))).toBe(0); + expect( + identify( + withSerial(LEAF_CERTIFICATE, tlv(0x02, Uint8Array.of(0xff))), + Uint8Array.of(0xff, 0xff), + ), + ).toBe(0); + for (const [canonical, padded] of [ + [Uint8Array.of(0x12, 0x34), Uint8Array.of(0x00, 0x00, 0x12, 0x34)], + [Uint8Array.of(0xff), Uint8Array.of(0xff, 0xff, 0xff)], + ] as const) { + const canonicalCertificate = withSerial( + LEAF_CERTIFICATE, + tlv(0x02, canonical), + ); + const paddedCertificate = withSerial(LEAF_CERTIFICATE, tlv(0x02, padded)); + expect(identify(canonicalCertificate, padded)).toBe(0); + expect(identify(paddedCertificate, canonical)).toBe(0); + expect(identify(paddedCertificate, padded)).toBe(0); + } + // 0x00 0x80 is +128; a bare 0x80 is -128. 0xff 0x7f is -129, not 0x7f. + expect( + identify( + withSerial(LEAF_CERTIFICATE, tlv(0x02, Uint8Array.of(0x00, 0x80))), + Uint8Array.of(0x80), + ), + ).toBeNull(); + expect( + identify( + withSerial(LEAF_CERTIFICATE, tlv(0x02, Uint8Array.of(0xff, 0x7f))), + Uint8Array.of(0x7f), + ), + ).toBeNull(); +}); + it("keeps exact issuer identity when its comparison key cannot be interpreted", () => { const unsupported = tlv( 0x30, diff --git a/src/domain/apkSignature/apkV1SignatureBlock.ts b/src/domain/apkSignature/apkV1SignatureBlock.ts index c38fabddac..0ec4ff4542 100644 --- a/src/domain/apkSignature/apkV1SignatureBlock.ts +++ b/src/domain/apkSignature/apkV1SignatureBlock.ts @@ -17,6 +17,7 @@ * conflated with malformed input. */ import { type Result, err, ok } from "../result.js"; +import { decodeAsn1Oid } from "../asn1Oid.js"; import { Asn1BerFailure, Asn1BerReader, type Asn1BerNode } from "../asn1Ber.js"; export type ApkV1BlockError = @@ -152,24 +153,11 @@ const expectOctetString = ( /** Dotted OID text; arcs are accumulated as BigInt so no arc overflows. */ const oidText = (content: Uint8Array): string => { - if (content.length === 0 || content.length > 64) - throw malformed(`object identifier of ${String(content.length)} bytes`); - const arcs: bigint[] = []; - let value = 0n; - for (const [index, byte] of content.entries()) { - value = (value << 7n) | BigInt(byte & 0x7f); - if ((byte & 0x80) !== 0) { - if (index === content.length - 1) - throw malformed("object identifier is truncated"); - continue; - } - if (arcs.length === 0) { - const first = value < 80n ? value / 40n : 2n; - arcs.push(first, value - first * 40n); - } else arcs.push(value); - value = 0n; - } - return arcs.join("."); + const decoded = decodeAsn1Oid(content, { bytes: 64, arcBytes: 64 }); + if (decoded.ok) return decoded.value; + if (decoded.error.kind === "truncated") + throw malformed("object identifier is truncated"); + throw malformed(`object identifier of ${String(content.length)} bytes`); }; const algorithmOid = (tlv: Asn1BerNode | undefined, what: string) => @@ -184,6 +172,22 @@ const algorithmOid = (tlv: Asn1BerNode | undefined, what: string) => const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((byte, i) => byte === b[i]); +/** + * apksig compares serials as signed INTEGER values, tolerating redundant sign + * octets even though DER requires minimal encoding. Normalize each parsed + * identity once; retain the original certificate encoding independently. + */ +const integerIdentityContents = (bytes: Uint8Array): Uint8Array => { + let start = 0; + while (start < bytes.length - 1) { + const first = bytes[start]; + const nextHigh = ((bytes[start + 1] ?? 0) & 0x80) !== 0; + if (!((first === 0x00 && !nextHigh) || (first === 0xff && nextHigh))) break; + start += 1; + } + return start === 0 ? bytes : bytes.subarray(start); +}; + /** * RFC 5280 §7.1: RDN order matters; attributes in one SET do not. This * comparison profile admits ASCII PrintableString/UTF8String for common @@ -243,7 +247,7 @@ const directoryNameKey = (name: Asn1BerNode): string | null => { interface CertificateIdentity { readonly issuer: Uint8Array; readonly issuerKey: string | null; - readonly serial: Uint8Array; + readonly serialIdentity: Uint8Array; readonly subjectKeyIdentifier: Uint8Array | null; } @@ -290,7 +294,7 @@ const certificateIdentity = ( return { issuer: reader.rawEncodedSpan(issuer), issuerKey: directoryNameKey(issuer), - serial: serial.content, + serialIdentity: integerIdentityContents(serial.content), subjectKeyIdentifier, }; } catch (cause: unknown) { @@ -317,18 +321,16 @@ const signerInfo = ( const issuerNode = expectTag(parts[0], TAG.sequence, `${what} issuer`); const issuer = reader.rawEncodedSpan(issuerNode); const issuerKey = directoryNameKey(issuerNode); - const serial = expectTag( - parts[1], - TAG.integer, - `${what} serialNumber`, - ).content; + const serialIdentity = integerIdentityContents( + expectTag(parts[1], TAG.integer, `${what} serialNumber`).content, + ); identifiedBy = "issuer-and-serial"; certificateIndex = identities.findIndex( (identity) => identity !== null && (equalBytes(identity.issuer, issuer) || (issuerKey !== null && identity.issuerKey === issuerKey)) && - equalBytes(identity.serial, serial), + equalBytes(identity.serialIdentity, serialIdentity), ); } else if (sid?.tag === TAG.context0Primitive) { const keyId = sid.content; diff --git a/src/domain/apple/jvmClassFile.ts b/src/domain/apple/jvmClassFile.ts index 40f52e2cd1..d7f9271491 100644 --- a/src/domain/apple/jvmClassFile.ts +++ b/src/domain/apple/jvmClassFile.ts @@ -256,7 +256,14 @@ export const isJvmClassFile = (bytes: Uint8Array): boolean => { ) return false; const interfaces = cursor.u2(); - if (interfaces === null || !cursor.skip(interfaces * 2)) return false; + if (interfaces === null) return false; + // JVMS 4.1: every interface index must name CONSTANT_Class_info, just + // like this_class and nonzero super_class. Framing alone is insufficient + // to establish JVM identity when the same magic also identifies FAT. + for (let index = 0; index < interfaces; index += 1) { + const reference = cursor.u2(); + if (reference === null || !poolHasTag(pool, reference, 7)) return false; + } if (!readMembers(cursor, pool) || !readMembers(cursor, pool)) return false; const attributes = cursor.u2(); return ( diff --git a/src/domain/apple/machoImageSelection.ts b/src/domain/apple/machoImageSelection.ts new file mode 100644 index 0000000000..081c065159 --- /dev/null +++ b/src/domain/apple/machoImageSelection.ts @@ -0,0 +1,50 @@ +import { err, ok, type Result } from "../result.js"; +import { + classifyFatContainerEvidence, + readFatContainerHeader, + readMachoHeaderPrefixFacts, + validateFatSlice, + type FatContainerHeader, + type FatSliceDeclaration, +} from "./machoContainer.js"; + +type UniversalMachoHeader = Extract; +interface MachoImageSelectionFailure { + readonly kind: "malformed"; + readonly message: string; +} + +/** Admit a complete in-memory universal image; FAT-shaped JVM data remains unselected. */ +export const readUniversalMachoImage = ( + bytes: Uint8Array, +): Result => { + const fat = readFatContainerHeader(bytes, bytes.byteLength); + if (fat.state === "not-fat") return ok(null); + if (fat.state !== "parsed") + return err({ + kind: "malformed", + message: + fat.state === "malformed" + ? fat.reason + : "Universal Mach-O header is truncated", + }); + const identity = classifyFatContainerEvidence(fat, bytes); + if (identity.state === "malformed") + return err({ kind: "malformed", message: identity.reason }); + return ok(identity.state === "mach-o" ? fat : null); +}; + +/** Validate the selected declaration against the complete embedded CPU identity. */ +export const readUniversalMachoSlice = ( + bytes: Buffer, + header: UniversalMachoHeader, + slice: FatSliceDeclaration, +): Result => { + const image = bytes.subarray(slice.offset, slice.offset + slice.size); + const identity = readMachoHeaderPrefixFacts(image).identity; + const message = + identity === null + ? `FAT architecture ${slice.index} has a truncated Mach-O identity` + : validateFatSlice(slice, bytes.length, header.tableEnd, identity); + return message === null ? ok(image) : err({ kind: "malformed", message }); +}; diff --git a/src/domain/apple/machoLoadCommands.ts b/src/domain/apple/machoLoadCommands.ts index 243870ab62..8278a31f17 100644 --- a/src/domain/apple/machoLoadCommands.ts +++ b/src/domain/apple/machoLoadCommands.ts @@ -62,7 +62,7 @@ export const readMachoLoadCommandDeclarations = ( export const readMachoLoadCommandTableHeader = ( bytes: Uint8Array, fileSize: number, -): Result => { +): Result => { const parsed = readMachoLoadCommandDeclarations(bytes, fileSize); if (!parsed.ok || parsed.value === null) return parsed; const { commandBytes, headerSize } = parsed.value; diff --git a/src/domain/apple/machoSegmentHeader.ts b/src/domain/apple/machoSegmentHeader.ts index cbe01c36d0..f6590f5699 100644 --- a/src/domain/apple/machoSegmentHeader.ts +++ b/src/domain/apple/machoSegmentHeader.ts @@ -29,3 +29,23 @@ export const readMachoSegmentHeader = ( sectionCount: view.getUint32(wide ? 64 : 48, littleEndian), }); }; + +/** Admit VM sizes once for consumers that construct segment mappings. */ +export const admitMachoSegmentMapping = ( + segment: { + readonly address: bigint; + readonly memorySize: bigint; + readonly fileSize: bigint; + readonly protection: number; + }, + bits: 32 | 64, +) => { + const unmapped = segment.memorySize === 0n && segment.protection === 0; + return (!unmapped && segment.fileSize > segment.memorySize) || + segment.address + segment.memorySize > 1n << BigInt(bits) + ? err({ + kind: "malformed" as const, + message: "Mach-O segment addresses or sizes are invalid", + }) + : ok({ unmapped }); +}; diff --git a/src/domain/apple/plistNumbers.ts b/src/domain/apple/plistNumbers.ts index f0e5bf0b87..e2e1b29f96 100644 --- a/src/domain/apple/plistNumbers.ts +++ b/src/domain/apple/plistNumbers.ts @@ -1,4 +1,4 @@ -import { plistUidValue } from "./plistUid.js"; +import { copyPlistUidIdentity, plistUidValue } from "./plistUid.js"; import type { JsonValue } from "../jsonValue.js"; /** Source element types needed to distinguish large integers from integral reals. */ @@ -45,32 +45,51 @@ export const createPlistNumberCollector = () => { }; }; +/** One association established from source literals, before adapter projection. */ +type PlistNumberSource = + | { readonly kind: "integer"; readonly decimal: string } + | { readonly kind: "real" } + | { readonly kind: "unknown" }; + +/** + * Associate a decoded number with its source type. A retained JSON literal can + * narrow integer candidates that otherwise collide after numeric rounding. + * Missing or incomplete metadata never establishes a source type. + */ +export const resolvePlistNumberSource = ( + value: number, + literals: PlistNumberLiterals | undefined, + sourceDecimal?: string, +): PlistNumberSource => { + if (literals === undefined || literals.incomplete) return { kind: "unknown" }; + const decimals = literals.integers.get(value); + const real = literals.reals.has(value); + const integer = + sourceDecimal === undefined + ? decimals !== undefined + : decimals?.has(sourceDecimal) === true; + if (real) return { kind: integer ? "unknown" : "real" }; + if (!integer) return { kind: "unknown" }; + const decimal = + sourceDecimal ?? (decimals?.size === 1 ? [...decimals][0] : undefined); + return decimal === undefined + ? { kind: "unknown" } + : { kind: "integer", decimal }; +}; + /** Preserve unambiguous exact integers; disclose rounded or unclassified numbers. */ export const createPlistNumberProjection = (literals: PlistNumberLiterals) => { let exactIntegerCount = 0; let ambiguousNumberCount = 0; const mapNumber = (item: number): JsonValue => { if (Number.isSafeInteger(item) || !Number.isInteger(item)) return item; - if (literals.incomplete) { - ambiguousNumberCount += 1; - return item; - } - const decimals = literals.integers.get(item); - if (decimals === undefined) { - if (!literals.reals.has(item)) ambiguousNumberCount += 1; - return item; - } - const [decimal] = decimals; - if ( - decimals.size !== 1 || - decimal === undefined || - literals.reals.has(item) - ) { - ambiguousNumberCount += 1; + const source = resolvePlistNumberSource(item, literals); + if (source.kind !== "integer") { + if (source.kind === "unknown") ambiguousNumberCount += 1; return item; } exactIntegerCount += 1; - return { $plist_type: "integer", decimal }; + return { $plist_type: "integer", decimal: source.decimal }; }; const map = (item: JsonValue): JsonValue => { if (typeof item === "number") return mapNumber(item); @@ -88,9 +107,11 @@ export const createPlistNumberProjection = (literals: PlistNumberLiterals) => { ambiguousNumberCount += 1; return item; } - return Object.fromEntries( + const projected = Object.fromEntries( Object.entries(item).map(([key, child]) => [key, map(child)]), ); + copyPlistUidIdentity(item, projected); + return projected; } return item; }; diff --git a/src/domain/artifactInventoryEvidence.test.ts b/src/domain/artifactInventoryEvidence.test.ts index 0a5508a876..748c32412c 100644 --- a/src/domain/artifactInventoryEvidence.test.ts +++ b/src/domain/artifactInventoryEvidence.test.ts @@ -1,5 +1,8 @@ import { artifactInventoryResultSchema } from "./artifactGraph.js"; -import { artifactManifestId } from "./artifactIdentity.js"; +import { + artifactIdForContent, + artifactManifestId, +} from "./artifactIdentity.js"; import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; import { expect, it } from "vitest"; @@ -38,6 +41,40 @@ const inspectionEvidence = (result: unknown, subject = target) => const inspection = createArtifactInspection(inventoryEvidence); +it.each([ + { occurrence_id: `occ_${"f".repeat(64)}` }, + { artifact_id: null }, + { artifact_format: "unknown" }, + { entry_kind: "directory" }, + { parent_occurrence_id: `occ_${"f".repeat(64)}` }, +])("rejects an invalid root on an incomplete inventory page: %j", (patch) => { + const inventory = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + const carrier = createEvidence( + { + path: target.local_path, + sha256: target.digest.sha256, + format: target.format, + }, + inventoryEvidence.provider, + { + operation: "inventory_artifact", + parameters: {}, + result: { + ...inventory, + nodes: [], + occurrences: [{ ...inventory.occurrences[0]!, ...patch }], + }, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + expect(() => parseArtifactInventoryEvidence(carrier)).toThrow( + EvidenceIntegrityError, + ); +}); + it("accepts an inspection whose carrier, declaration, and substep identities agree", () => { expect( parseArtifactInventoryEvidence(inspectionEvidence(inspection)).inventory, @@ -178,3 +215,81 @@ it("reports incompatible independently valid manifests as caller composition", ( ]), ).toThrow(ArtifactInventoryCarrierError); }); + +it.each( + (["nodes", "occurrences", "edges"] as const).flatMap((member) => + (["single", "assembled"] as const).map((mode) => ({ member, mode })), + ), +)( + "rejects $member exceeding the manifest in $mode pages", + ({ member, mode }) => { + const inventory = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + const root = inventory.occurrences[0]!; + const sha256 = "f".repeat(64); + const members = { + nodes: [ + inventory.nodes[0]!, + { + ...inventory.nodes[0]!, + sha256, + artifact_id: artifactIdForContent(sha256), + }, + ], + occurrences: [ + root, + { + ...root, + occurrence_id: `occ_${"f".repeat(64)}`, + logical_path: "child", + }, + ], + edges: ["a", "b"].map((digit) => ({ + edge_id: `edge_${digit.repeat(64)}`, + ordinal: 0, + parent_artifact_id: inventory.manifest.root_artifact_id, + relation: "contains" as const, + occurrence_id: root.occurrence_id, + producer: null, + })), + }; + const manifest = { ...inventory.manifest, edge_count: 1 }; + const page = (indices: readonly number[]) => + createEvidence( + { + path: target.local_path, + sha256: target.digest.sha256, + format: target.format, + }, + inventoryEvidence.provider, + { + operation: "inventory_artifact", + parameters: {}, + confidence: "observed", + authority: "shipped-artifact", + result: { + ...inventory, + manifest, + nodes: [], + occurrences: [], + edges: [], + [member]: indices.map((index) => members[member][index]!), + }, + }, + ); + const first = page([0]); + const second = page([1]); + expect(parseArtifactInventoryEvidence(first).inventory.complete).toBe( + false, + ); + expect(parseArtifactInventoryEvidence(second).inventory.complete).toBe( + false, + ); + expect(() => + parseArtifactInventoryEvidence( + mode === "single" ? page([0, 1]) : [first, second], + ), + ).toThrow(EvidenceIntegrityError); + }, +); diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index c91f0dff16..2cf462a03f 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -311,6 +311,42 @@ export const validateArtifactInventorySubject = ( }; const validateInventoryPage = (inventory: ArtifactInventoryResult): void => { + const manifest = inventory.manifest; + if (manifest.root_artifact_id !== artifactIdForContent(manifest.root_sha256)) + throw new ArtifactInventoryValidationError( + "Artifact root identity does not match its manifest digest", + ); + if ( + manifest.manifest_id !== + artifactManifestId(manifest.root_artifact_id, manifest.graph_sha256) + ) + throw new ArtifactInventoryValidationError( + "Artifact manifest ID does not match its commitment", + ); + for (const occurrence of inventory.occurrences) { + if (occurrence.logical_path !== ".") continue; + if ( + occurrence.artifact_id !== manifest.root_artifact_id || + occurrence.artifact_format !== manifest.root_format || + occurrence.parent_occurrence_id !== null || + occurrence.entry_kind !== + (manifest.root_format === "directory" ? "directory" : "file") + ) + throw new ArtifactInventoryValidationError( + "Artifact root occurrence does not match its manifest representation", + ); + if ( + occurrence.occurrence_id !== + occurrenceIdForLocation({ + rootArtifactId: manifest.root_artifact_id, + logicalPath: ".", + entryKind: occurrence.entry_kind, + }) + ) + throw new ArtifactInventoryValidationError( + "Artifact occurrence ID does not match its identity", + ); + } assertUnique( inventory.nodes.map(({ artifact_id: id }) => id), "node ID", @@ -383,6 +419,34 @@ const assembleInventoryResults = ( occurrences, edges, }); + if ( + inventory.nodes.length > inventory.manifest.node_count || + inventory.occurrences.length > inventory.manifest.occurrence_count || + inventory.edges.length > inventory.manifest.edge_count + ) + throw new ArtifactInventoryValidationError( + "Artifact inventory members exceed their manifest counts", + ); + const occurrencesById = new Map( + inventory.occurrences.map((occurrence) => [ + occurrence.occurrence_id, + occurrence, + ]), + ); + for (const edge of inventory.edges) { + const occurrence = occurrencesById.get(edge.occurrence_id); + // Missing pages leave references unresolved, but a supplied occurrence + // already provides every input to the edge's identity commitment. + if (occurrence === undefined) continue; + if (occurrence.artifact_id === null) + throw new ArtifactInventoryValidationError( + "Artifact edge references a missing graph member", + ); + if (edge.edge_id !== artifactEdgeId(edge, occurrence)) + throw new ArtifactInventoryValidationError( + "Artifact edge ID does not match its identity", + ); + } if (inventory.complete) validateCompleteInventory(inventory); return inventory; }; @@ -480,10 +544,7 @@ const validateCompleteInventory = (inventory: InventorySet): void => { const rootOccurrence = inventory.occurrences.find( ({ logical_path }) => logical_path === ".", ); - if ( - rootOccurrence?.artifact_id !== inventory.manifest.root_artifact_id || - rootOccurrence.artifact_format !== inventory.manifest.root_format - ) + if (rootOccurrence === undefined) throw new ArtifactInventoryValidationError( "Artifact root occurrence does not match its manifest representation", ); @@ -529,14 +590,6 @@ const validateCompleteInventory = (inventory: InventorySet): void => { throw new ArtifactInventoryValidationError( "Artifact graph commitment does not match its members", ); - const manifestId = artifactManifestId( - inventory.manifest.root_artifact_id, - graphSha256, - ); - if (manifestId !== inventory.manifest.manifest_id) - throw new ArtifactInventoryValidationError( - "Artifact manifest ID does not match its commitment", - ); }; const validateOccurrence = ( @@ -584,10 +637,6 @@ const validateEdge = ( throw new ArtifactInventoryValidationError( "Artifact edge references a missing graph member", ); - if (edge.edge_id !== artifactEdgeId(edge, occurrence)) - throw new ArtifactInventoryValidationError( - "Artifact edge ID does not match its identity", - ); }; const mergeExact = ( diff --git a/src/domain/asn1Oid.test.ts b/src/domain/asn1Oid.test.ts new file mode 100644 index 0000000000..c8240f27ae --- /dev/null +++ b/src/domain/asn1Oid.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from "vitest"; + +import { decodeAsn1Oid } from "./asn1Oid.js"; + +const limits = { bytes: 64, arcBytes: 32 }; + +it("decodes combined initial arcs and later arcs beyond JavaScript integer precision", () => { + expect(decodeAsn1Oid(Uint8Array.of(0x88, 0x37, 0x03), limits)).toEqual({ + ok: true, + value: "2.999.3", + }); + expect( + decodeAsn1Oid( + Uint8Array.of(0x2a, 0xa0, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00), + limits, + ), + ).toEqual({ ok: true, value: "1.2.18014398509481984" }); +}); + +it("distinguishes malformed arcs from each caller-selected capacity bound", () => { + expect(decodeAsn1Oid(new Uint8Array(), limits)).toEqual({ + ok: false, + error: { kind: "empty" }, + }); + expect(decodeAsn1Oid(Uint8Array.of(0x2a, 0x80), limits)).toEqual({ + ok: false, + error: { kind: "truncated" }, + }); + const content = Uint8Array.of(0x2a, 0x81, 0x00); + expect(decodeAsn1Oid(content, { bytes: 3, arcBytes: 2 })).toEqual({ + ok: true, + value: "1.2.128", + }); + expect(decodeAsn1Oid(content, { bytes: 2, arcBytes: 2 })).toEqual({ + ok: false, + error: { kind: "byte-limit", limit: 2 }, + }); + expect(decodeAsn1Oid(content, { bytes: 3, arcBytes: 1 })).toEqual({ + ok: false, + error: { kind: "arc-limit", limit: 1 }, + }); +}); diff --git a/src/domain/asn1Oid.ts b/src/domain/asn1Oid.ts new file mode 100644 index 0000000000..242022b3e4 --- /dev/null +++ b/src/domain/asn1Oid.ts @@ -0,0 +1,33 @@ +import { err, ok, type Result } from "./result.js"; + +/** Format owners choose their admission bounds; the shared decoder owns BER arcs. */ +export type Asn1OidError = + | { readonly kind: "empty" | "truncated" } + | { readonly kind: "byte-limit" | "arc-limit"; readonly limit: number }; + +/** Decode OBJECT IDENTIFIER content without narrowing arbitrary-size base-128 arcs. */ +export const decodeAsn1Oid = ( + content: Uint8Array, + limits: { readonly bytes: number; readonly arcBytes: number }, +): Result => { + if (content.length === 0) return err({ kind: "empty" }); + if (content.length > limits.bytes) + return err({ kind: "byte-limit", limit: limits.bytes }); + const arcs: bigint[] = []; + let value = 0n; + let arcBytes = 0; + for (const byte of content) { + arcBytes += 1; + if (arcBytes > limits.arcBytes) + return err({ kind: "arc-limit", limit: limits.arcBytes }); + value = (value << 7n) | BigInt(byte & 0x7f); + if ((byte & 0x80) !== 0) continue; + if (arcs.length === 0) { + const first = value < 80n ? value / 40n : 2n; + arcs.push(first, value - first * 40n); + } else arcs.push(value); + value = 0n; + arcBytes = 0; + } + return arcBytes === 0 ? ok(arcs.join(".")) : err({ kind: "truncated" }); +}; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index acf7e8ca1e..4646bcc8d7 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -147,8 +147,7 @@ const parseThinMachO = ( const table = readMachoLoadCommandTableHeader(bytes, fileSize); if (!table.ok) return table; if (table.value === null) return err("unsupported binary format"); - const identity = readMachoHeaderPrefixFacts(bytes.subarray(0, 12)).identity; - if (identity === null) return err("unsupported Mach-O architecture"); + const { identity } = table.value; const architecture = identity.cpuType === CPU_TYPE_ARM64_32 ? "arm64_32" diff --git a/src/domain/browserScenario.ts b/src/domain/browserScenario.ts index 2e27c445cd..cca415dcfd 100644 --- a/src/domain/browserScenario.ts +++ b/src/domain/browserScenario.ts @@ -14,7 +14,11 @@ import { type BrowserScenarioAction, type BrowserScenarioUrl, type BrowserScenarioValue, + SAVED_URL_PLACEHOLDER, + savedPlaceholderNames, + validBasicCredentialUsername, } from "./browserScenarioValues.js"; +import type { JsonValue } from "./jsonValue.js"; export const browserScenarioInputSchema = z.strictObject({ browser: browserScenarioBrowserSchema.describe( @@ -61,6 +65,47 @@ const secretReferencesInValue = ( value: BrowserScenarioValue | undefined, ): readonly string[] => (value?.source === "secret" ? [value.secret_id] : []); +/** A request step's header values, with Basic credentials as their two parts. */ +const headerValues = ( + action: Extract, +) => + action.headers.flatMap(({ value }) => + value.source === "basic_credentials" ? value.parts : [value], + ); + +/** Every string in a JSON value, at any depth. */ +const jsonStrings = (value: JsonValue | undefined): readonly string[] => { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(jsonStrings); + if (value !== null && typeof value === "object") + return Object.values(value).flatMap(jsonStrings); + return []; +}; + +/** The saved values a step sends: `{saved:name}` in a request path, JSON string or goto URL, and saved header values and credential parts. */ +const sentSavedNames = (action: BrowserScenarioAction): readonly string[] => { + if (action.action === "goto") + return savedPlaceholderNames(action.destination.url, SAVED_URL_PLACEHOLDER); + if (action.action !== "request") return []; + return [ + ...[action.path, ...jsonStrings(action.json)].flatMap((text) => + savedPlaceholderNames(text), + ), + ...headerValues(action).flatMap((value) => + value.source === "saved" ? [value.name] : [], + ), + ]; +}; + +/** The names a step saves: a request's `save`, or a UI step's `save_response`. */ +const savedNames = (action: BrowserScenarioAction): readonly string[] => + action.action === "request" + ? action.save.map(({ name }) => name) + : (action.action === "click" || action.action === "press") && + action.save_response !== undefined + ? action.save_response.save.map(({ name }) => name) + : []; + const destinationSecretReferences = ( destination: BrowserScenarioUrl, ): readonly string[] => @@ -76,13 +121,31 @@ const actionSecretReferences = ( if (action.action === "request") return [ ...action.query.flatMap(({ value }) => secretReferencesInValue(value)), - ...action.headers.flatMap(({ value }) => + ...headerValues(action).flatMap((value) => value.source === "secret" ? [value.secret_id] : [], ), ]; return []; }; +/** Each saved value a step sends was saved by an earlier step. */ +const validateSavedValues = ( + scenario: ScenarioShape, + context: z.RefinementCtx, +): void => { + const saved = new Set(); + scenario.actions.forEach((action, index) => { + for (const name of new Set(sentSavedNames(action))) + if (!saved.has(name)) + addIssue( + context, + ["actions", index], + `Step ${action.step_id} sends saved value ${name}, which no earlier step saves`, + ); + for (const name of savedNames(action)) saved.add(name); + }); +}; + /** Request steps stay on the start origin and send a body only with methods that carry one. */ const validateRequestSteps = ( scenario: ScenarioShape, @@ -132,6 +195,30 @@ const validateRequestSteps = ( ["actions", index, "headers", position, "value", "value"], "A header value cannot carry line breaks or control characters", ); + const username = + header.value.source === "basic_credentials" + ? header.value.parts[0] + : undefined; + if ( + username?.source === "literal" && + !validBasicCredentialUsername(username.value) + ) + addIssue( + context, + ["actions", index, "headers", position, "value", "parts", 0, "value"], + "A Basic username cannot contain a colon, which separates the username from the password", + ); + // Evidence records literals as written, so a password must come from a reference. + const password = + header.value.source === "basic_credentials" + ? header.value.parts[1] + : undefined; + if (password?.source === "literal" && password.value !== "") + addIssue( + context, + ["actions", index, "headers", position, "value", "parts", 1], + "Basic credentials take their password from a declared secret, a saved value, a cookie or local storage, not a literal", + ); }); }); }; @@ -198,6 +285,7 @@ const validateBrowserScenario = ( ); validateRequestSteps(scenario, context); + validateSavedValues(scenario, context); const references = scenario.actions.flatMap(actionSecretReferences); references.push(...destinationSecretReferences(scenario.start_url)); references.push(...validateStorage(scenario)); diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index f03945f563..2bc7cad612 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -401,6 +401,52 @@ describe("browser scenario network event order", () => { expect(compare(redirectChainCapture(true)).overall_status).toBe("changed"); }); + it("keeps duplicate request identities incomparable without dropping occurrences", () => { + const capture = concurrentCapture(["a", "b"], { a: 200, b: 404 }); + const request = capture.events.items.find( + (event) => event.kind === "request", + ); + if (request === undefined) throw new Error("Expected request fixture"); + const ambiguous = browserScenarioCaptureSchema.parse({ + ...capture, + events: { + ...capture.events, + retained: capture.events.retained + 1, + items: [ + ...capture.events.items, + { ...request, sequence: capture.events.retained + 1, step_index: 1 }, + ], + }, + }); + for (const [before_scenario, after_scenario] of [ + [ambiguous, ambiguous], + [ambiguous, capture], + [capture, ambiguous], + ] as const) { + const input = compareBrowserScenariosInputSchema.parse({ + before_scenario, + after_scenario, + }); + const compared = compareBrowserScenarios(input); + expect(compared.overall_status).toBe("unknown"); + expect( + compared.steps.flatMap((step) => step.artifact_diffs), + ).toContainEqual( + expect.objectContaining({ + artifact: "events", + status: "unknown", + reason: expect.stringContaining("Multiple requests share"), + }), + ); + expect(input.before_scenario.events.items).toEqual( + before_scenario.events.items, + ); + expect(input.after_scenario.events.items).toEqual( + after_scenario.events.items, + ); + } + }); + it("keeps missing and cyclic redirect ancestry incomparable", () => { for (const predecessor of ["missing", "middle-a", null]) { const capture = redirectChainCapture(); diff --git a/src/domain/browserScenarioNetworkComparison.ts b/src/domain/browserScenarioNetworkComparison.ts index 3d25311b5a..187cf86d8e 100644 --- a/src/domain/browserScenarioNetworkComparison.ts +++ b/src/domain/browserScenarioNetworkComparison.ts @@ -3,6 +3,7 @@ import type { RetainedBrowserScenarioCapture } from "./browserScenarioCapture.js import type { BrowserScenarioNormalizationRule } from "./browserScenarioDiffValues.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; import { normalizeScenarioValue } from "./browserScenarioNormalization.js"; +import { indexBrowserScenarioTransactions } from "./browserScenarioTransactions.js"; /** Numeric links are temporary local indices, never durable transaction identity. */ export const scenarioNetworkTransactionSchema = z.strictObject({ @@ -18,12 +19,17 @@ export type ScenarioNetworkTransaction = z.infer< export const scenarioNetworkTransactions = ( capture: RetainedBrowserScenarioCapture, ): ScenarioNetworkTransaction[] => { + const { requestCounts } = indexBrowserScenarioTransactions( + capture.events.items, + ); const sourceSteps = new Map( capture.events.items.map((event) => [event.sequence, event.step_index]), ); const requests = new Map( capture.events.items.flatMap((event) => - event.kind === "request" ? [[event.transaction_id, event]] : [], + event.kind === "request" && requestCounts.get(event.transaction_id) === 1 + ? [[event.transaction_id, event]] + : [], ), ); const grouped = new Map< @@ -135,6 +141,11 @@ export const scenarioNetworkCoverageReason = ( capture: RetainedBrowserScenarioCapture, nodes = scenarioNetworkTransactions(capture), ): string | undefined => { + const { requestCounts } = indexBrowserScenarioTransactions( + capture.events.items, + ); + if ([...requestCounts.values()].some((count) => count > 1)) + return "Multiple requests share a transaction ID; network transaction identity remains unknown."; const finished = new Set(); for (let start = 0; start < nodes.length; start += 1) { const path = new Set(); diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index ab3ff39580..bf42e54f6c 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -138,6 +138,97 @@ const browserScenarioPageValueSchemas = [ }), ] as const; +/** A path on the start origin: one leading slash, never `//` or `/\`. Written without look-around or reserved class syntax, so every JSON Schema regex dialect reads it. */ +const START_ORIGIN_PATH = /^\x2F(?:[^\x2F\x5C]|$)/u; + +/** `{saved:name}` as written, which sends a saved value in a request path or a JSON body string. */ +export const SAVED_PLACEHOLDER = /\{saved:([A-Za-z][A-Za-z0-9._-]*)\}/gu; + +/** + * `{saved:name}` in a goto destination's URL, which the scenario schema parsed, so it may spell the braces and the colon + * percent-encoded. Other text keeps an encoded spelling as written. + */ +export const SAVED_URL_PLACEHOLDER = + /(?:\{|%7[Bb])saved(?::|%3[Aa])([A-Za-z][A-Za-z0-9._-]*)(?:\}|%7[Dd])/gu; + +/** The saved value names a text's placeholders send, as `placeholder` spells them. */ +export const savedPlaceholderNames = ( + text: string, + placeholder: RegExp = SAVED_PLACEHOLDER, +): readonly string[] => + [...text.matchAll(placeholder)].map(([, name]) => name!); + +/** A value an earlier step saved from a JSON response. */ +const savedValueSchema = z.strictObject({ + source: z.literal("saved"), + name: scenarioIdentifierSchema, +}); + +/** A literal, declared secret or saved value a request step sends. */ +export const browserScenarioRequestValueSchema = z.discriminatedUnion( + "source", + [...browserScenarioValueSchema.options, savedValueSchema], +); +export type BrowserScenarioRequestValue = z.infer< + typeof browserScenarioRequestValueSchema +>; + +/** + * A part of Basic credentials: a literal, declared secret or saved value, or a value read from the page, as a frontend + * builds such a header from a login token. The page values carry no descriptions here, since each part repeats them. + */ +const basicCredentialPartSchema = z.discriminatedUnion("source", [ + ...browserScenarioRequestValueSchema.options, + z.strictObject({ source: z.literal("cookie"), name: z.string().min(1) }), + z.strictObject({ + source: z.literal("local_storage"), + key: z.string().min(1), + json_pointer: z + .string() + .regex(/^(?:\/.*)?$/u) + .optional(), + }), +]); +export type BrowserScenarioCredentialPart = z.infer< + typeof basicCredentialPartSchema +>; + +/** Basic auth uses the first colon as the username/password separator. */ +export const validBasicCredentialUsername = (username: string): boolean => + !username.includes(":"); + +/** A username and password sent as the base64 of `username:password`, as HTTP Basic auth spells them; redacted like a declared secret. */ +const basicCredentialsSchema = z.strictObject({ + source: z.literal("basic_credentials"), + parts: z + .array(basicCredentialPartSchema) + .length(2) + .describe("Username, then password."), +}); + +/** A string or number a step keeps from a JSON response, by name, for later steps. */ +const saveEntrySchema = z.strictObject({ + name: scenarioIdentifierSchema, + json_pointer: z.string().regex(/^(?:\/.*)?$/u), + secret: z.boolean().default(false), +}); +export type BrowserScenarioSaveEntry = z.infer; + +/** Values a UI step keeps from a newly observed matching response; matching does not prove causality. */ +const saveResponseSchema = z + .strictObject({ + method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS), + path: z + .string() + .regex(START_ORIGIN_PATH) + .describe("`{*}` matches one segment; the query is ignored."), + save: z.array(saveEntrySchema).min(1), + }) + .describe( + "Save values from a newly observed matching 2xx JSON response; matching does not prove the UI caused it. Multiple observed matches fail.", + ); +export type BrowserScenarioSaveResponse = z.infer; + const requestHeaderSchema = z.strictObject({ name: z .string() @@ -149,6 +240,8 @@ const requestHeaderSchema = z.strictObject({ value: z.discriminatedUnion("source", [ ...browserScenarioValueSchema.options, ...browserScenarioPageValueSchemas, + savedValueSchema, + basicCredentialsSchema, ]), prefix: z .string() @@ -284,6 +377,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ locator: locatorSchema, button: z.enum(["left", "middle", "right"]).default("left"), click_count: z.int().positive().default(1), + save_response: saveResponseSchema.optional(), }), z.strictObject({ ...stepBase, @@ -296,6 +390,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ action: z.literal("press"), locator: locatorSchema, key: z.string().min(1), + save_response: saveResponseSchema.optional(), }), z.strictObject({ ...stepBase, @@ -320,8 +415,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS), path: z .string() - // Preserve the prefix grammar without look-around or reserved class syntax. - .regex(/^\x2F(?:[^\x2F\x5C]|$)/u) + .regex(START_ORIGIN_PATH) .describe( "Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.", ), @@ -332,6 +426,12 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ .describe( "JSON request body, sent with Content-Type application/json unless a header sets one; not allowed for GET or HEAD.", ), + save: z + .array(saveEntrySchema) + .default([]) + .describe( + "Values to save from the JSON response. Later steps send one as `{saved:name}` in a request path, goto URL or JSON string, or as a header value.", + ), expected_status: z .array( z diff --git a/src/domain/environmentBundleExport.ts b/src/domain/environmentBundleExport.ts index d90ba29818..92119dccf1 100644 --- a/src/domain/environmentBundleExport.ts +++ b/src/domain/environmentBundleExport.ts @@ -105,6 +105,7 @@ export const environmentBuildReportSchema = z.strictObject({ "name-from-file", "credential-named-members", "credential-scan-incomplete", + "credential-named-saved-values", ]), message: z.string(), pointers: z diff --git a/src/domain/evidenceRecordSummary.test.ts b/src/domain/evidenceRecordSummary.test.ts index 91142ef312..b0571be4b1 100644 --- a/src/domain/evidenceRecordSummary.test.ts +++ b/src/domain/evidenceRecordSummary.test.ts @@ -53,3 +53,59 @@ it("preserves native identity independently of missing or incompatible value-flo ]), ); }); + +it.each([ + { + flow: { + available: true, + truncated: false, + omitted_operations_lower_bound: 2, + known_omitted_inputs: 3, + known_omitted_edges: 4, + operations: "historical collection representation", + }, + expected: { + status: "observed", + truncated: false, + omitted_operations_lower_bound: 2, + known_omitted_inputs: 3, + known_omitted_edges: 4, + }, + }, + { + flow: { available: false, reason: "" }, + expected: { status: "unavailable", reason: "" }, + }, + { + flow: { available: true, truncated: false, known_omitted_edges: -1 }, + expected: { status: "unknown", reason: expect.any(String) }, + }, + { + flow: { available: true, truncated: false, known_omitted_inputs: null }, + expected: { status: "unknown", reason: expect.any(String) }, + }, +])( + "projects retained flow metadata without requiring current full payloads: $flow", + ({ flow, expected }) => { + const record = createEvidence( + { path: "/fixtures/native.exe", format: "pe", sha256: "a".repeat(64) }, + { id: "provider", name: "Provider", version: "1" }, + { + operation: "analyze_function", + parameters: {}, + result: { + procedure: { address: "space:0x10", name: "entry" }, + native_value_flow: flow, + }, + }, + ); + const summary = summarizeEvidenceBundle(createEvidenceBundle([record])); + expect(summary.records[0]?.native_dossier).toEqual({ + available: true, + procedure_address: "space:0x10", + procedure_name: "entry", + value_flow: expected, + }); + expect(record.normalized_result).toMatchObject({ native_value_flow: flow }); + }, +); diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 795eddfad2..47653431f8 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { digestSchema, prefixedDigestSchema } from "./digests.js"; import { evidenceSchema, type Evidence } from "./evidence.js"; import type { EvidenceBundle } from "./evidenceBundle.js"; +import { nativeValueFlowMetadataSchema } from "./native/nativeValueFlow.js"; /** Exact retained-record selectors; selection never resolves a provider address. */ export const evidenceRecordFiltersSchema = z.strictObject({ @@ -19,16 +20,6 @@ const nativeMetadataSchema = z.object({ procedure: z.object({ address: z.string(), name: z.string().nullish() }), native_value_flow: z.unknown().optional(), }); -const valueFlowMetadataSchema = z.union([ - z.object({ - available: z.literal(true), - truncated: z.boolean(), - omitted_operations_lower_bound: countSchema.optional(), - known_omitted_inputs: countSchema.optional(), - known_omitted_edges: countSchema.optional(), - }), - z.object({ available: z.literal(false), reason: z.string() }), -]); const nativeSummarySchema = z.discriminatedUnion("available", [ z.strictObject({ @@ -88,7 +79,9 @@ const nativeSummary = ( const parsed = nativeMetadataSchema.safeParse(evidence.normalized_result); if (!parsed.success) return { available: false, reason: "not-recorded-or-incompatible" }; - const flow = valueFlowMetadataSchema.safeParse(parsed.data.native_value_flow); + const flow = nativeValueFlowMetadataSchema.safeParse( + parsed.data.native_value_flow, + ); return { available: true, procedure_address: parsed.data.procedure.address, diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 027a448080..216c55fb0e 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -1121,6 +1121,21 @@ const stringsDimension = ( ).dimensions.find(({ dimension }) => dimension === "strings_names"); describe("function comparison Swift string literals", () => { + it("retains literal comparisons when legacy dossiers omit facet declarations", () => { + const legacy = (base: "0x1000" | "0x2000", value: string) => { + const result = withLiterals(base, [value]); + delete result.facet_availability; + return result; + }; + const left = legacy("0x1000", "SleepDisabled"); + expect( + stringsDimension("1", left, "2", legacy("0x2000", "SleepDisabled")), + ).toMatchObject({ status: "unchanged" }); + expect( + stringsDimension("1", left, "2", legacy("0x2000", "Holding awake")), + ).toMatchObject({ status: "changed" }); + }); + it("treats differing decoded literals as a strings and names change", () => { expect( stringsDimension( diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index e3b19d95a0..09ce541c95 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -14,6 +14,7 @@ import { normalizeCfg, referencedStringProjection, sorted, + swiftLiteralCoverage, swiftLiteralSitesConflict, } from "./functionComparisonNormalization.js"; import type { @@ -101,7 +102,9 @@ const facetLimitations = ( strings_names: [ [ "strings_names", - dossier.referenced_strings.length + dossier.referenced_names.length, + dossier.referenced_strings.length + + dossier.referenced_names.length + + (dossier.swift_string_literals?.length ?? 0), ], ], cfg: [["cfg", dossier.basic_blocks.length]], @@ -515,15 +518,6 @@ const groupStringsByOffset = ( return groups; }; -const swiftLiteralCoverage = ( - snapshot: FunctionSnapshot, -): "absent" | "complete" | "partial" => - (snapshot.dossier.swift_string_literal_omissions ?? []).length > 0 - ? "partial" - : snapshot.dossier.swift_string_literals === undefined - ? "absent" - : "complete"; - const compareReferences = ( compared: FunctionRelationshipComparison, context: ComparisonContext, diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index 55480f2daf..d80b6895d9 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -191,9 +191,18 @@ export const swiftLiteralSitesConflict = ( return false; }; +/** Omitted literal inventory differs from an observed empty inventory. */ +export const swiftLiteralCoverage = ( + snapshot: FunctionSnapshot, +): "absent" | "complete" | "partial" => + (snapshot.dossier.swift_string_literal_omissions ?? []).length > 0 + ? "partial" + : snapshot.dossier.swift_string_literals === undefined + ? "absent" + : "complete"; + const swiftLiteralSites = (snapshot: FunctionSnapshot) => { const base = snapshot.dossier.procedure.address; - const omissions = snapshot.dossier.swift_string_literal_omissions ?? []; const site = (sourceAddress: string, registers: readonly string[]) => { const offset = relativeAddress(sourceAddress, base); return offset === null ? null : canonicalJson([offset, registers]); @@ -211,9 +220,7 @@ const swiftLiteralSites = (snapshot: FunctionSnapshot) => { ].sort(compareUnicodeCodePoints), ); } - const complete = - snapshot.dossier.swift_string_literals !== undefined && - omissions.length === 0; + const complete = swiftLiteralCoverage(snapshot) === "complete"; return { values, complete }; }; diff --git a/src/domain/gpu/gpuCodeObjects.ts b/src/domain/gpu/gpuCodeObjects.ts index af216fa0a9..5f8bd69026 100644 --- a/src/domain/gpu/gpuCodeObjects.ts +++ b/src/domain/gpu/gpuCodeObjects.ts @@ -35,6 +35,26 @@ export const gpuObjectSchema = z.strictObject({ location: gpuLocationSchema, }) .nullable(), + amd_target_metadata: z + .strictObject({ + flags: z.string().regex(/^0x[0-9a-f]{8}$/u), + processor: z.string().nullable(), + header_location: gpuLocationSchema, + note_coverage: z + .enum(["complete", "partial"]) + .describe( + "Complete only after all declared ELF note sections were examined within supported AMD metadata coverage; partial retains only examined facts.", + ), + notes: z.array( + z.strictObject({ + target: z.string().nullable(), + target_state: z.enum(["absent", "declared", "invalid"]), + location: gpuLocationSchema, + }), + ), + }) + .nullable() + .default(null), target_consistency: z.enum([ "unexamined", "single-source", @@ -207,6 +227,48 @@ export const gpuCodeObjectsSchema = gpuCodeObjectsObjectSchema.superRefine( const row = report.objects[index]!; range(row.location, ["objects", index, "location"]); range(row.metadata_location, ["objects", index, "metadata_location"]); + const amd = row.amd_target_metadata; + if (amd !== null) { + range(amd.header_location, [ + "objects", + index, + "amd_target_metadata", + "header_location", + ]); + if ( + row.kind !== "amdgpu" || + amd.header_location.container_id !== row.id || + amd.header_location.offset !== 0 || + ![52, 64].includes(amd.header_location.size) + ) + issue( + ["objects", index, "amd_target_metadata"], + "AMDGPU header provenance must start at its payload", + ); + for (let note = 0; note < amd.notes.length; note++) { + const location = amd.notes[note]!.location; + range(location, [ + "objects", + index, + "amd_target_metadata", + "notes", + note, + "location", + ]); + if (location.container_id !== row.id) + issue( + [ + "objects", + index, + "amd_target_metadata", + "notes", + note, + "location", + ], + "AMDGPU notes must use their payload coordinate space", + ); + } + } const header = row.cuda_target_header; if (header !== null) { range(header.location, [ diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index 26456d87dd..be503ebd73 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -50,6 +50,7 @@ import { type ElectronStatementFallthrough, } from "./electronCallPathBlockPrefixes.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { javascriptSourceRangeKey } from "./javascriptSourceCoordinates.js"; const callLabel = (node: t.Node): string => t.isSequenceExpression(node) @@ -57,7 +58,12 @@ const callLabel = (node: t.Node): string => : calleeName(node) || "local call"; const ipcEndpointKey = (value: ElectronIpcFinding): string => - JSON.stringify([value.side, value.operation, value.channel, value.location]); + JSON.stringify([ + value.side, + value.operation, + value.channel, + javascriptSourceRangeKey(value.location), + ]); /** Duplicate evidence already recorded for one endpoint and call site. */ interface IpcEndpointFacts { @@ -78,8 +84,10 @@ interface ReachedChain { readonly slot: number; } -const ipcInvocationKey = (location: unknown): string => - String(JSON.stringify(location)); +const ipcInvocationKey = ( + location: ElectronIpcFinding["invocation_location"], +): string => + location === undefined ? "undefined" : javascriptSourceRangeKey(location); /** A finding's facts apart from which entry invoked it. */ const ipcValueKey = (value: ElectronIpcFinding): string => @@ -131,7 +139,11 @@ const priorPath = ( /** One physical IPC operation, independent of how its channel was resolved. */ const ipcSiteKey = (value: ElectronIpcFinding): string => - JSON.stringify([value.side, value.operation, value.location]); + JSON.stringify([ + value.side, + value.operation, + javascriptSourceRangeKey(value.location), + ]); const indexIpcFinding = ( index: Map, @@ -534,7 +546,7 @@ export function* collectElectronWrappedCallPathsSteps( value.api_key !== null && value.api_status === "object-literal" && value.unknown_members === 0 - ? [JSON.stringify(value.location)] + ? [javascriptSourceRangeKey(value.location)] : [], ), ); @@ -739,7 +751,8 @@ export function* collectElectronWrappedCallPathsSteps( const channel = first.value; const location = range(node); const invocationLocation = - JSON.stringify(range(context.entryNode)) === JSON.stringify(location) + javascriptSourceRangeKey(range(context.entryNode)) === + javascriptSourceRangeKey(location) ? undefined : range(context.entryNode); const findingContext = { @@ -946,7 +959,11 @@ export function* collectElectronWrappedCallPathsSteps( ? undefined : argumentNode(node.arguments[exposure.worldIdIndex]); // Absent when complete direct syntax leaves substitution nothing to add. - const site = bridgeSiteAt(node, JSON.stringify(location), worldNode); + const site = bridgeSiteAt( + node, + javascriptSourceRangeKey(location), + worldNode, + ); // The site evaluated in its own scope is not an invocation; a traced call // that leaves this exposure unresolved keeps the syntax observation. const unresolved = (): void => { @@ -1122,7 +1139,7 @@ export function* collectElectronWrappedCallPathsSteps( value, })); const findings = accumulator.contextBridgeApis.flatMap((entry) => { - const siteKey = JSON.stringify(entry.value.location); + const siteKey = javascriptSourceRangeKey(entry.value.location); const site = sites.get(siteKey); if (site === undefined) return [entry]; sites.delete(siteKey); diff --git a/src/domain/javascript/javascriptModuleDeclaration.ts b/src/domain/javascript/javascriptModuleDeclaration.ts index b071139030..0bf60a2499 100644 --- a/src/domain/javascript/javascriptModuleDeclaration.ts +++ b/src/domain/javascript/javascriptModuleDeclaration.ts @@ -30,6 +30,17 @@ export const isTypeOnlyExportDeclaration = ( node.declaration != null && isErasedJavaScriptDeclaration(node.declaration)); +/** Runtime static module source, shared by source and captured-script consumers. */ +export const semanticStaticModuleSource = ( + node: t.Node, +): t.StringLiteral | null => { + if (t.isImportDeclaration(node)) + return isTypeOnlyImportDeclaration(node) ? null : node.source; + if (t.isExportAllDeclaration(node) || t.isExportNamedDeclaration(node)) + return isTypeOnlyExportDeclaration(node) ? null : (node.source ?? null); + return null; +}; + /** * Runtime external-module target for a value import-equals declaration; * `import type X = require("m")` is erased and declares no dependency. diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts new file mode 100644 index 0000000000..efc897f714 --- /dev/null +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -0,0 +1,115 @@ +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import * as t from "@babel/types"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; + +export const isFetchCall = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, +): boolean => + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, "fetch"); + +/** Browser request identity requires an intact intrinsic, not a method spelling. */ +export const isBrowserNetworkRequest = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, +): boolean => + isFetchCall(node, bindings) || + (t.isNewExpression(node) && + (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || + isUnmodifiedGlobalReference(node.callee, bindings, "EventSource"))) || + ((t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, [ + "navigator", + "sendBeacon", + ])); + +const isXhrOpenRequest = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || + semanticStaticPropertyName(callee.property, callee.computed) !== "open" + ) + return false; + if ( + ["XMLHttpRequest", "Object"].some((owner) => + ["open", "__proto__"].some( + (member) => + bindings.ambientWrites.covers([ + "global", + owner, + "prototype", + member, + ]) || + bindings.ambientEscapes.covers([ + "global", + owner, + "prototype", + member, + ]), + ), + ) + ) + return false; + let receiver = unwrapJavaScriptExpression(callee.object).node; + const seen = new Set(); + while (t.isIdentifier(receiver)) { + if ( + index !== undefined && + (index.referenceMayHaveChangedAt(node, receiver, ["open"]) || + index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) + ) + return false; + const binding = resolveSemanticBindingState( + bindings, + receiver, + receiver.name, + ); + if (binding === undefined || seen.has(binding.bindingId)) return false; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, bindings); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, bindings.parentsByNode) + .hasDefiniteInitializer + ) + return false; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + if ( + !t.isNewExpression(receiver) || + !isUnmodifiedGlobalReference(receiver.callee, bindings, "XMLHttpRequest") || + semanticOpaqueGlobalMayHaveChanged(callee, bindings) + ) + return false; + return index === undefined && seen.size > 0 ? undefined : true; +}; + +/** Resolve once through the shared mutation index when lexical proof needs it. */ +export const createXhrOpenRequestQuery = + (index: JavaScriptBindingIndex): ((node: t.Node) => boolean) => + (node) => { + const proof = isXhrOpenRequest(node, index.bindings); + if (proof !== undefined) return proof; + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return isXhrOpenRequest(node, index.bindings, index) === true; + }; diff --git a/src/domain/javascript/javascriptRouteFacts.ts b/src/domain/javascript/javascriptRouteFacts.ts index aae175a114..7962fa5ecf 100644 --- a/src/domain/javascript/javascriptRouteFacts.ts +++ b/src/domain/javascript/javascriptRouteFacts.ts @@ -16,7 +16,7 @@ export const javaScriptRouteSegmentSchema = z.strictObject({ origin: z.enum(["literal", "property-name", "next-app-path"]), }); export const javaScriptRouteCandidateSchema = z.strictObject({ - path: z.string().startsWith("/"), + path: z.string().regex(/^\//u), mechanism: javaScriptRouteMechanismSchema, confidence: z.enum(["low", "medium", "high"]), derivation: z.enum(["literal", "joined", "inferred"]), diff --git a/src/domain/javascript/javascriptRouteInventory.ts b/src/domain/javascript/javascriptRouteInventory.ts index f57abd8b63..e78d57ba4c 100644 --- a/src/domain/javascript/javascriptRouteInventory.ts +++ b/src/domain/javascript/javascriptRouteInventory.ts @@ -57,7 +57,7 @@ export const javaScriptRouteOccurrenceSchema = z.strictObject({ }); export const javaScriptInventoryRouteSchema = z.strictObject({ id: z.string().regex(/^jsr:[a-f0-9]{32}$/u), - path: z.string().startsWith("/"), + path: z.string().regex(/^\//u), classification: z.enum(["page", "system", "unknown"]), classification_basis: z.enum([ "next-app-page", diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 416eecf3c3..1e9598f814 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -1,3 +1,4 @@ +import { createXhrOpenRequestQuery } from "./javascriptNetworkRequests.js"; import { uniqueSortedBy } from "../canonicalOrdering.js"; import * as t from "@babel/types"; import { JavaScriptRouteScan } from "./javascriptRouteScan.js"; @@ -72,8 +73,7 @@ import { import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import { hasUncertainTypeOnlyModuleSideEffect, - isTypeOnlyExportDeclaration, - isTypeOnlyImportDeclaration, + semanticStaticModuleSource, semanticImportEqualsSpecifier, } from "./javascriptModuleDeclaration.js"; import { @@ -115,6 +115,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( bindings, ); const findings: JavaScriptFindingContext = { + xhrOpenRequest: createXhrOpenRequestQuery(index), pathIntrinsics: createStaticPathIntrinsicQuery(index), browserWindowReceiver: (node, site, suffix) => resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index), @@ -367,28 +368,16 @@ const inspectNode = ( const specifier = semanticImportEqualsSpecifier(node); if (specifier !== null) addReference(findings, { node, kind: "require", specifier }); - } else if ( - (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && - node.source !== undefined && - !(t.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) && - !(t.isExportAllDeclaration(node) && isTypeOnlyExportDeclaration(node)) - ) - addReference(findings, { - node, - kind: "static-import", - specifier: node.source.value, - }); - else if ( - t.isExportNamedDeclaration(node) && - node.source != null && - !isTypeOnlyExportDeclaration(node) - ) - addReference(findings, { - node, - kind: "static-import", - specifier: node.source.value, - }); - else if (t.isImportExpression(node)) + } else { + const moduleSource = semanticStaticModuleSource(node); + if (moduleSource !== null) + addReference(findings, { + node, + kind: "static-import", + specifier: moduleSource.value, + }); + } + if (t.isImportExpression(node)) addReference(findings, { node, kind: "dynamic-import", diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 282a7fa934..dee550ee66 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -357,8 +357,8 @@ it.each(expressionWrappers)( ) .toEqual([ ["local-window", "candidate"], - ["/local-db", null], - ["/local-cache", null], + ["/local-db", "candidate"], + ["/local-cache", "candidate"], ["changed-window", "candidate"], ["uncertain-window", "candidate"], ]); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index bf934a9d30..b7dbccda5d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -1,3 +1,7 @@ +import { + isBrowserNetworkRequest, + isFetchCall, +} from "./javascriptNetworkRequests.js"; import * as t from "@babel/types"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; @@ -491,13 +495,6 @@ export const indexVitePreloadDependencyTable = ( return escapedOrMutated ? dependencies.map(() => undefined) : dependencies; }; -const isFetchCall = ( - node: t.Node, - bindings: JavaScriptSemanticAnalysisState, -): boolean => - (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - isUnmodifiedGlobalReference(node.callee, bindings, "fetch"); - const requestConstructorUrl = ( node: t.Node, bindings: JavaScriptSemanticAnalysisState, @@ -518,7 +515,7 @@ const inspectEndpointCall = ( context: FindingContext, bindings: JavaScriptSemanticAnalysisState, ): void => { - if (isKnownNetworkRequest(node, bindings)) { + if (isKnownNetworkRequest(node, bindings, context)) { const target = argumentNode(node.arguments[0]); if (target === undefined) return; const literal = @@ -591,7 +588,10 @@ const inspectLiteralEndpointCall = ( kind: "network", value: endpoint.value, mechanism: `call:${name}`, - ...(endpoint.kind === "candidate" ? { interpretation: "candidate" } : {}), + ...(endpoint.kind === "candidate" && + context.xhrOpenRequest?.(node) !== true + ? { interpretation: "candidate" } + : {}), }); }; @@ -609,21 +609,18 @@ const isNetworkRequestSpelling = (name: string): boolean => const isKnownNetworkRequest = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, + context: FindingContext, ): boolean => { - if (isFetchCall(node, bindings)) return true; + if (isBrowserNetworkRequest(node, bindings)) return true; + const callee = unwrapJavaScriptExpression(node.callee).node; if ( - t.isNewExpression(node) && - (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || - isUnmodifiedGlobalReference(node.callee, bindings, "EventSource")) + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === "loadURL" && + context.rendererLoad?.(node.callee, node) === true ) return true; - return ( - (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - isUnmodifiedGlobalReference(node.callee, bindings, [ - "navigator", - "sendBeacon", - ]) - ); + return false; }; /** Finish queued targets after shared member-mutation facts are indexed. */ @@ -633,7 +630,7 @@ export const finishPartialNetworkEndpoints = ( index: JavaScriptBindingIndex, ): void => { for (const { node, name } of context.accumulator.pendingNetworkCalls) { - if (!isKnownNetworkRequest(node, bindings)) { + if (!isKnownNetworkRequest(node, bindings, context)) { if (!isIntrinsicMapLookup(node, bindings, index)) inspectLiteralEndpointCall(node, name, context); continue; diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index b3569e29b1..5322a7ebeb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -657,7 +657,7 @@ const isKeyedCollectionReceiver = (callee: t.Node): boolean => { ); }; -/** A literal endpoint shape or an ambiguous argument of an HTTP-method-named call. */ +/** A literal endpoint shape or an argument whose callee spelling is unproven. */ export interface JavaScriptEndpointArgument { readonly kind: "endpoint" | "candidate"; readonly value: string; @@ -675,10 +675,10 @@ export const endpointArgument = ( callee: t.Node, receiverFact?: JavaScriptOpenReceiverFact, ): JavaScriptEndpointArgument | undefined => { - const literal = ( + const candidate = ( value: string | undefined, ): JavaScriptEndpointArgument | undefined => - value === undefined ? undefined : { kind: "endpoint", value }; + value === undefined ? undefined : { kind: "candidate", value }; if (receiverFact === "window") return undefined; if ( name === "fetch" || @@ -687,17 +687,11 @@ export const endpointArgument = ( name === "EventSource" || name.endsWith(".EventSource") ) - return literal(stringValue(args[0])); + return candidate(stringValue(args[0])); if (name.endsWith(".open")) { const value = xhrOpenUrl(name, args[0], args[1], receiverFact); if (value === undefined) return undefined; - return { - kind: - stringValue(args[0]) !== undefined || isRequestUrlReference(value) - ? "endpoint" - : "candidate", - value, - }; + return { kind: "candidate", value }; } const method = ["get", "post", "put", "patch", "delete", "request"].find( (candidate) => name === candidate || name.endsWith(`.${candidate}`), @@ -717,19 +711,14 @@ export const endpointArgument = ( receiverFact === "extension-state"))) ) return undefined; - return { - kind: - method !== "get" && method !== "delete" && isRequestUrlReference(value) - ? "endpoint" - : "candidate", - value, - }; + return { kind: "candidate", value }; } - // Beacon URLs are endpoint observations, retaining the upstream API support. if (name === "sendBeacon" || name.endsWith(".sendBeacon")) - return literal(stringValue(args[0])); - if (name === "loadURL" || name.endsWith(".loadURL")) - return literal(stringValue(args[0])); + return candidate(stringValue(args[0])); + if (name === "loadURL" || name.endsWith(".loadURL")) { + const value = stringValue(args[0]); + return value === undefined ? undefined : { kind: "candidate", value }; + } return undefined; }; diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index e8b6b9bc93..6a149cb3fc 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -110,6 +110,7 @@ export interface JavaScriptFindingContext { ) => | { readonly owner: t.NewExpression; readonly path: readonly string[] } | undefined; + readonly xhrOpenRequest?: (node: t.Node) => boolean; readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean; readonly rpcScan: JavaScriptRpcScan; /** Shared bounded argv/nullish argument shapes for optional-slot signatures. */ diff --git a/src/domain/macosVersion.ts b/src/domain/macosVersion.ts new file mode 100644 index 0000000000..7e7d05dcc8 --- /dev/null +++ b/src/domain/macosVersion.ts @@ -0,0 +1,10 @@ +/** Parse the major release only when the complete macOS probe is a dotted numeric version. */ +export const macosMajorVersion = ( + version: string | undefined, +): number | undefined => { + if (version === undefined) return undefined; + const match = /^\d+(?:\.\d+)*/u.exec(version); + if (match?.[0] !== version) return undefined; + const major = Number(version.split(".")[0]); + return Number.isSafeInteger(major) ? major : undefined; +}; diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index e4a4f6cb8a..18246a7863 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -228,6 +228,16 @@ const managedMethodBodySchema = z.object({ max_stack: z.int().min(0).nullable(), init_locals: z.boolean().nullable(), local_var_sig_token: managedMetadataTokenSchema.nullable(), + local_var_signature: z + .object({ + raw_sha256: digestSchema, + has_context_dependent_types: z.boolean(), + }) + .nullable() + .default(null) + .describe( + "Validated LocalVarSig blob facts; null when absent or unrecorded. Named types, modifiers and generic variables require metadata or declaring context; equal raw digests do not establish their cross-artifact type identity.", + ), il_size: z.int().min(0), il_sha256: digestSchema .nullable() diff --git a/src/domain/managed/managedMemberComparison.ts b/src/domain/managed/managedMemberComparison.ts index d57ac227c2..dc2c9c69e1 100644 --- a/src/domain/managed/managedMemberComparison.ts +++ b/src/domain/managed/managedMemberComparison.ts @@ -275,6 +275,7 @@ export const compareManagedMembers = ( signatureResolutionExamined: hasSignatureResolution(left.result) && hasSignatureResolution(right.result), + sameArtifact: left.result.artifact.sha256 === right.result.artifact.sha256, leftAssembly: left.result.assembly, rightAssembly: right.result.assembly, leftCallEdgesByCaller: callEdgesByCaller(left.result), diff --git a/src/domain/managed/managedMemberComparisonItems.ts b/src/domain/managed/managedMemberComparisonItems.ts index 5705ee8f55..7057b6d68c 100644 --- a/src/domain/managed/managedMemberComparisonItems.ts +++ b/src/domain/managed/managedMemberComparisonItems.ts @@ -42,6 +42,7 @@ interface ComparisonItemContext { readonly scope: ManagedComparisonScope; /** Whether matching keyed signatures by resolved digests, not raw bytes. */ readonly signatureResolutionExamined: boolean; + readonly sameArtifact: boolean; readonly leftAssembly: ManagedMemberInspection["assembly"]; readonly rightAssembly: ManagedMemberInspection["assembly"]; readonly leftCallEdgesByCaller: CallEdgesByCaller; @@ -704,6 +705,30 @@ const ambiguityLimitation = ( ? "Multiple managed methods share the same structural shape; REA did not guess a token remap." : "Multiple managed fields share the same signature; REA did not guess a token remap."; +/** Local signature bytes prove identity only without metadata or declaring-context dependencies. */ +const compareLocalSignatures = ( + left: Method, + right: Method, + sameArtifact: boolean, +): { readonly changed: boolean; readonly unknown: boolean } => { + const leftPresent = left.body.local_var_sig_token !== null; + const rightPresent = right.body.local_var_sig_token !== null; + if (!leftPresent || !rightPresent) return { changed: false, unknown: false }; + // The same method row of the same authenticated artifact names the same source bytes. + if (sameArtifact && left.token === right.token) + return { changed: false, unknown: false }; + const a = left.body.local_var_signature; + const b = right.body.local_var_signature; + if ( + a === null || + b === null || + a.has_context_dependent_types || + b.has_context_dependent_types + ) + return { changed: false, unknown: true }; + return { changed: a.raw_sha256 !== b.raw_sha256, unknown: false }; +}; + export const buildMethodItems = ( matches: ManagedMethodMatches, context: ComparisonItemContext, @@ -715,6 +740,11 @@ export const buildMethodItems = ( pair.right.item, context.signatureResolutionExamined, ); + const locals = compareLocalSignatures( + pair.left.item, + pair.right.item, + context.sameArtifact, + ); const { dimensions: observedDimensions, bodyUnknown, @@ -731,10 +761,15 @@ export const buildMethodItems = ( context.leftAssembly, context.rightAssembly, ); + if (locals.changed && !observedDimensions.includes("metadata")) + observedDimensions.push("metadata"); const dimensions = [ ...observedDimensions, ...(bodyUnknown ? (["body-coverage"] as const) : []), - ...(callTargetUnknown || fieldTargetUnknown || exceptionTargetUnknown + ...(callTargetUnknown || + fieldTargetUnknown || + exceptionTargetUnknown || + locals.unknown ? (["availability"] as const) : []), ]; @@ -754,6 +789,7 @@ export const buildMethodItems = ( callTargetUnknown || fieldTargetUnknown || exceptionTargetUnknown || + locals.unknown || signatureLimitations.length > 0 ? "unknown" : "unchanged", @@ -770,6 +806,16 @@ export const buildMethodItems = ( evidence_links: [context.leftEvidenceId, context.rightEvidenceId], limitations: [ ...signatureLimitations, + ...(locals.unknown + ? [ + "Local variable signature identity is unknown because validated context-free signature facts are unavailable or the signature requires metadata or declaring context; build-local tokens and raw bytes do not establish equivalent local types across artifacts.", + ] + : []), + ...(locals.changed + ? [ + "The validated context-free LocalVarSig bytes changed; this records an encoding difference without asserting a semantic type change.", + ] + : []), ...(bodyUnknown ? [ "Method body facets are unknown because at least one side has unavailable or partial body data.", diff --git a/src/domain/managed/managedMemberComparisonMatch.ts b/src/domain/managed/managedMemberComparisonMatch.ts index beade8cefc..d94736c98e 100644 --- a/src/domain/managed/managedMemberComparisonMatch.ts +++ b/src/domain/managed/managedMemberComparisonMatch.ts @@ -52,11 +52,33 @@ interface Ambiguous { /** A declared type: a historical bare name or a tagged observed nominal digest. */ type DeclaredName = string | string[] | null; type DeclaredNameOf = (item: Method | Field) => DeclaredName; +const keyIdentity = ( + item: Method | Field, + declared: DeclaredNameOf, + kind: "method" | "field", +) => { + const declaringType = declared(item); + const signatureId = signatureIdentity(item.signature); + return { + identity: digestCanonicalValue( + [declaringType, item.name], + "Managed comparison", + ), + identityUnavailable: declaringType === null, + signatureId, + signatureKey: + declaringType === null + ? null + : digestCanonicalValue( + [`${kind}-signature`, declaringType, item.name, signatureId], + "Managed comparison", + ), + }; +}; + const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed => ({ item, - identity: identityOf({ declaring_type: declared(item), name: item.name }), - identityUnavailable: declared(item) === null, - signatureId: signatureIdentity(item.signature), + ...keyIdentity(item, declared, "method"), exactKey: item.body.status === "present" && item.body.il_sha256 !== null && @@ -71,20 +93,6 @@ const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed => ({ "Managed comparison", ) : null, - // The blob, with type operands named, is the exact signature; decoding - // only gates the shape keys. - signatureKey: - declared(item) === null - ? null - : digestCanonicalValue( - [ - "method-signature", - declared(item), - item.name, - signatureIdentity(item.signature), - ], - "Managed comparison", - ), structuralKey: item.signature.parse_status === "decoded" && item.body.status === "present" ? digestCanonicalValue( @@ -118,9 +126,7 @@ const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed => ({ const keyField = (item: Field, declared: DeclaredNameOf): Keyed => ({ item, - identity: identityOf({ declaring_type: declared(item), name: item.name }), - identityUnavailable: declared(item) === null, - signatureId: signatureIdentity(item.signature), + ...keyIdentity(item, declared, "field"), exactKey: item.signature.parse_status === "decoded" ? digestCanonicalValue( @@ -128,29 +134,9 @@ const keyField = (item: Field, declared: DeclaredNameOf): Keyed => ({ "Managed comparison", ) : null, - // Only an undecoded field reaches this round: a decoded field with the - // same signature already met its counterpart in the exact round. - signatureKey: - declared(item) === null - ? null - : digestCanonicalValue( - [ - "field-signature", - declared(item), - item.name, - signatureIdentity(item.signature), - ], - "Managed comparison", - ), - structuralKey: digestCanonicalValue( - [ - "field-structural", - item.signature.kind, - item.signature.field_type, - item.flags, - ], - "Managed comparison", - ), + // Field matching has no structural round: flags/type display strings do + // not establish a field signature identity. + structuralKey: null, }); const matchMethods = ( @@ -243,15 +229,6 @@ interface MatchByKeysInput { readonly rounds: readonly MatchRound[]; } -export const identityOf = ({ - declaring_type, - name, -}: { - readonly declaring_type: DeclaredName; - readonly name: string; -}): string => - digestCanonicalValue([declaring_type, name], "Managed comparison"); - /** Narrow a key to members of one declared type and name. */ const declaredAs = (key: (item: Keyed) => string | null) => diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index 6aa7803245..ece237a029 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -356,9 +356,14 @@ const matchPinvoke = ( managed.import_name === lookupSymbolName(symbol) && moduleRelation(symbol) === "match", ); - const unknownModule = candidates.find( - (symbol) => moduleRelation(symbol) === "unknown", + const exactUnknownModule = candidates.find( + (symbol) => + managed.import_name === lookupSymbolName(symbol) && + moduleRelation(symbol) === "unknown", ); + const unknownModule = + exactUnknownModule ?? + candidates.find((symbol) => moduleRelation(symbol) === "unknown"); const decorated = candidates.find( (symbol) => moduleRelation(symbol) === "match", ); @@ -382,7 +387,12 @@ const matchPinvoke = ( candidates, }, }; - if (unknownModule !== undefined) + // Preserve an exact-name candidate's uncertainty, but an unrelated unknown + // module must not displace an observed module match for a decorated name. + if ( + unknownModule !== undefined && + (exactUnknownModule !== undefined || decorated === undefined) + ) return { match: { status: "unresolved", diff --git a/src/domain/native/addressCoordinate.ts b/src/domain/native/addressCoordinate.ts index 1511ebc167..ad0aa8b0b9 100644 --- a/src/domain/native/addressCoordinate.ts +++ b/src/domain/native/addressCoordinate.ts @@ -1,13 +1,19 @@ -/** Parse canonical native offsets without discarding their address space. */ +import { + nativeAddressCoordinateInputSchema, + nativeAddressObservationSchema, +} from "./nativeAddress.js"; + +// Observations retain their stricter spelling contract, including encoded spaces. +const observedCoordinateSchema = nativeAddressObservationSchema.pipe( + nativeAddressCoordinateInputSchema, +); + +/** Parse observed native offsets without discarding their address space. */ export const addressCoordinate = ( value: string, ): { readonly space: string | null; readonly offset: bigint } | null => { - const separator = value.lastIndexOf(":0x"); - if (separator === 0) return null; - const space = separator < 0 ? null : value.slice(0, separator); - const offset = separator < 0 ? value : value.slice(separator + 1); - if (!/^0x[0-9a-f]+$/u.test(offset)) return null; - return { space, offset: BigInt(offset) }; + const parsed = observedCoordinateSchema.safeParse(value); + return parsed.success ? parsed.data : null; }; /** Return a bounded byte distance between canonical addresses in one space. */ diff --git a/src/domain/native/functionBody.test.ts b/src/domain/native/functionBody.test.ts index e3a4a2019d..09605dc0de 100644 --- a/src/domain/native/functionBody.test.ts +++ b/src/domain/native/functionBody.test.ts @@ -55,6 +55,17 @@ describe("provider-neutral inclusive function body evidence", () => { }).success, ).toBe(false); }); + it("rejects malformed address-space labels rather than certifying body coverage", () => { + expect( + functionBodySchema.safeParse({ + ...observed(), + ranges: [{ start: "ram space:0x1000", end: "ram space:0x1000" }], + total_bytes: 1, + span_bytes: 1, + non_contiguous: false, + }).success, + ).toBe(false); + }); it("allows an observed empty set without manufacturing entry membership", () => { expect( procedureIdentitySchema.safeParse({ diff --git a/src/domain/native/nativeExports.ts b/src/domain/native/nativeExports.ts index 532eed4f13..9b61911b36 100644 --- a/src/domain/native/nativeExports.ts +++ b/src/domain/native/nativeExports.ts @@ -1,6 +1,20 @@ import { z } from "zod"; +import { isUtf8 } from "node:buffer"; import { absoluteLocalPathStringSchema } from "../localPath.js"; +import { canonicalBase64Schema } from "../stringPatterns.js"; + +/** ELF strings declare bytes rather than an encoding; Latin-1 is only a lossless display fallback. */ +export const decodeElfExportName = (bytes: Buffer) => { + const utf8 = isUtf8(bytes); + return { + name: bytes.toString(utf8 ? "utf8" : "latin1"), + name_bytes_base64: bytes.some((byte) => byte >= 0x80) + ? bytes.toString("base64") + : null, + name_utf8_decodable: utf8, + }; +}; const uint = z.number().int().min(0).max(0xffffffff); const address = z.string().regex(/^0x[0-9a-f]+$/u); @@ -50,7 +64,7 @@ const peExportSchema = z.strictObject({ const elfExportSchema = z.strictObject({ name: z.string().min(1), /** Exact non-ASCII name bytes; ELF string tables do not declare text encoding. */ - name_bytes_base64: z.string().nullable().default(null), + name_bytes_base64: canonicalBase64Schema.nullable().default(null), /** Whether `name` was decoded as UTF-8; null means this field was not recorded in older Evidence. */ name_utf8_decodable: z.boolean().nullable().default(null), /** `st_value` as stored; on 32-bit ARM, bit 0 of a function marks Thumb code. */ @@ -65,7 +79,7 @@ const elfExportSchema = z.strictObject({ index: z.number().int().min(2).max(0x7fff), name: z.string().min(1).nullable(), /** Exact non-ASCII version-name bytes; ELF strings do not declare encoding. */ - name_bytes_base64: z.string().nullable().default(null), + name_bytes_base64: canonicalBase64Schema.nullable().default(null), /** Null means older Evidence did not retain version-name encoding facts. */ name_utf8_decodable: z.boolean().nullable().default(null), hidden: z.boolean(), @@ -143,6 +157,37 @@ export const nativeExportsSchema = z message: "Export coverage count must equal the retained export row count.", }); + if (value.format !== "elf") return; + const checkName = ( + row: { + name: string | null; + name_bytes_base64: string | null; + name_utf8_decodable: boolean | null; + }, + path: (string | number)[], + ) => { + // Historical omissions remain unknown. Complete encoding facts must agree + // with the producer's UTF-8-or-Latin-1 display interpretation. + if (row.name_bytes_base64 === null || row.name_utf8_decodable === null) + return; + const bytes = Buffer.from(row.name_bytes_base64, "base64"); + const decoded = decodeElfExportName(bytes); + if ( + decoded.name_utf8_decodable !== row.name_utf8_decodable || + decoded.name !== row.name + ) + context.addIssue({ + code: "custom", + path, + message: + "ELF name text and encoding must agree with retained source bytes", + }); + }; + value.exports.forEach((row, index) => { + checkName(row, ["exports", index]); + if (row.version !== null && typeof row.version === "object") + checkName(row.version, ["exports", index, "version"]); + }); }); export type NativeExports = z.infer; export type PeExport = z.infer; diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 91dbf7c992..13cb25c001 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -255,82 +255,120 @@ describe("Interface Builder dispatch joins", () => { ); }, ); +}); - it("chooses an instance method and matches category-qualified owner names", () => { - const ui = buildInterfaceBuilderAnalysis({ - targetSha256: "b".repeat(64), - toolVersion: "test", - documents: [ - { - relativePath: "Main.nib", - archiveSha256: "c".repeat(64), - documentKind: "nib", - raw: { - "com.apple.ibtool.document.objects": { - controller: { customClass: "BuildViewController" }, - button: { class: "UIButton" }, - }, - "com.apple.ibtool.document.connections": { - button: [ - { - type: "action", - label: "buildTapped:", - destinationId: "controller", - }, - ], +describe("Objective-C category ownership joins", () => { + it.each([ + { + className: "BuildViewController", + category: "BuilderActions", + legacy: false, + }, + { + className: "BuildViewController(Literal)", + category: undefined, + legacy: false, + }, + { className: "BuildViewController", category: undefined, legacy: true }, + ])( + "matches exact instance owner $className with category $category", + ({ className, category, legacy }) => { + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: "b".repeat(64), + toolVersion: "test", + documents: [ + { + relativePath: "Main.nib", + archiveSha256: "c".repeat(64), + documentKind: "nib", + raw: { + "com.apple.ibtool.document.objects": { + controller: { customClass: className }, + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "buildTapped:", + destinationId: "controller", + }, + ], + }, }, }, - }, - ], - limits: interfaceBuilderLimitsSchema.parse({}), - }); - const metadata = { - objc_classes: [], - objc_protocols: [], - objc_categories: [], - swift_decls: [], - objc_ivars: [], - objc_protocol_records: [], - objc_dispatch_implementations: [ - { - class_name: "BuildViewController", - selector: "buildTapped:", - method_type: "class" as const, - implementation_address: "0x9999", - location: { address: "0x9999", file_offset: 0 }, - decode: { status: "decoded" as const, reason: null }, - evidence, - }, - { - class_name: "BuildViewController(BuilderActions)", - selector: "buildTapped:", - method_type: "instance" as const, - implementation_address: "0x1000", - location: { address: "0x1000", file_offset: 0 }, - decode: { status: "decoded" as const, reason: null }, - evidence, - }, - ], - swift_conformances: [], - swift_dispatch_slots: [], - swift_symbols: [], - relative_pointers: [], - coverage: [], - db_save_result: null, - }; + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const metadata = { + objc_classes: [], + objc_protocols: [], + objc_categories: [], + swift_decls: [], + objc_ivars: [], + objc_protocol_records: [], + objc_dispatch_implementations: [ + { + class_name: "BuildViewController", + selector: "buildTapped:", + method_type: "class" as const, + implementation_address: "0x9999", + location: { address: "0x9999", file_offset: 0 }, + decode: { status: "decoded" as const, reason: null }, + evidence, + }, + { + class_name: legacy ? `${className}(BuilderActions)` : className, + ...(category === undefined ? {} : { category }), + selector: "buildTapped:", + method_type: "instance" as const, + implementation_address: "0x1000", + location: { address: "0x1000", file_offset: 0 }, + decode: legacy + ? { status: "partial" as const, reason: "symbol_name_only" } + : { status: "decoded" as const, reason: null }, + evidence: legacy + ? [ + { + ...evidence[0]!, + kind: "symbol" as const, + description: `Provider symbol -[${className}(BuilderActions) buildTapped:]`, + }, + ] + : evidence, + }, + { + // A historical or binary class identity does not declare a category. + class_name: `${className}(Legacy)`, + selector: "buildTapped:", + method_type: "instance" as const, + implementation_address: "0x2000", + location: { address: "0x2000", file_offset: 0 }, + decode: { status: "partial" as const, reason: "symbol_name_only" }, + evidence, + }, + ], + swift_conformances: [], + swift_dispatch_slots: [], + swift_symbols: [], + relative_pointers: [], + coverage: [], + db_save_result: null, + }; - const joined = joinInterfaceBuilderDispatch(ui.graph, metadata); - expect(joined.edges).toContainEqual( - expect.objectContaining({ - relation: "objc_dispatch", - resolution: "resolved", - to: "native:function:0x1000", - }), - ); - expect(joined.edges).not.toContainEqual( - expect.objectContaining({ to: "native:function:0x9999" }), - ); - }); + const joined = joinInterfaceBuilderDispatch(ui.graph, metadata); + expect(joined.edges).toContainEqual( + expect.objectContaining({ + relation: "objc_dispatch", + resolution: legacy ? "inferred" : "resolved", + to: "native:function:0x1000", + }), + ); + expect(joined.edges).not.toContainEqual( + expect.objectContaining({ to: "native:function:0x9999" }), + ); + }, + ); }); describe("placeholder dispatch joins", () => { diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 334875dcea..b51ad6f63e 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + objcImplementationClassName, nativeMetadataEvidenceSchema, nativeMetadataLocationSchema, type ObjcSwiftMetadata, @@ -351,7 +352,7 @@ export const joinInterfaceBuilderDispatch = ( candidate.method_type === "instance" && candidate.selector === selector && (className === null || - candidate.class_name.replace(/\([^)]*\)$/u, "") === className), + objcImplementationClassName(candidate) === className), ); const implementation = candidates.length === 1 ? candidates[0] : undefined; const edgeId = `dispatch:${selectorNodeId}:${className ?? owner?.id ?? "unknown"}:${selector}`; diff --git a/src/domain/native/nativeProviderOutput.test.ts b/src/domain/native/nativeProviderOutput.test.ts index daa06b75b7..c4d86e4fae 100644 --- a/src/domain/native/nativeProviderOutput.test.ts +++ b/src/domain/native/nativeProviderOutput.test.ts @@ -1,11 +1,33 @@ import { describe, expect, it } from "vitest"; -import { addressDistance } from "./addressCoordinate.js"; +import { addressCoordinate, addressDistance } from "./addressCoordinate.js"; import { parseDocuments } from "./nativeDocument.js"; import { parseRelatedAddresses } from "./nativeObservation.js"; import { parseSegments } from "./nativeSegments.js"; describe("native provider boundary values", () => { + it.each([ + "ram space:0x1", + "ram:extra:0x1", + "ram%zz:0x1", + ":0x1", + "ram%2f:0x1", + "0X1", + "0xA", + ])("leaves malformed or non-observed coordinate %s unknown", (address) => { + expect(addressCoordinate(address)).toBeNull(); + expect(addressDistance(address, address)).toBeNull(); + }); + + it("preserves encoded space identity and padded observed offsets", () => { + expect(addressCoordinate("ram%2Fbank:0x000a")).toEqual({ + space: "ram%2Fbank", + offset: 10n, + }); + expect(addressDistance("ram%2Fbank:0x000a", "ram%2Fbank:0xb")).toBe(1); + expect(addressDistance("ram%2Fbank:0xa", "ram%252Fbank:0xb")).toBeNull(); + }); + it("distinguishes measured zero, malformed, incomparable, reversed, and oversized extents", () => { expect(addressDistance("not-an-address", "0x2000")).toBeNull(); expect(addressDistance("ram:0x1000", "rom:0x2000")).toBeNull(); diff --git a/src/domain/native/nativeSearch.ts b/src/domain/native/nativeSearch.ts index 6784723c90..b5764fc509 100644 --- a/src/domain/native/nativeSearch.ts +++ b/src/domain/native/nativeSearch.ts @@ -11,7 +11,7 @@ export const nativeSearchFields = { .boolean() .default(false) .describe( - "Whether to match case; case-insensitive matching uses Unicode simple case folding, one character at a time", + "Whether to match case; case-insensitive literal matching uses Unicode simple case folding, one character at a time, except Hopper, which retains Python Unicode full case folding and can expand characters into sequences. Regex case handling follows the provider's advertised regex dialect", ), }; diff --git a/src/domain/native/nativeValueFlow.ts b/src/domain/native/nativeValueFlow.ts index 1289b0362a..00a6dd407c 100644 --- a/src/domain/native/nativeValueFlow.ts +++ b/src/domain/native/nativeValueFlow.ts @@ -81,3 +81,28 @@ export const nativeValueFlowSchema = z.discriminatedUnion("available", [ availableNativeValueFlowSchema, unavailableNativeValueFlowSchema, ]); + +/** Lightweight retained metadata; historical records may omit newer omission counts. + * Strip full flow collections without parsing or copying them. An empty recorded + * unavailable reason remains source evidence even though new producers forbid it. + */ +export const nativeValueFlowMetadataSchema = z.union([ + availableNativeValueFlowSchema + .pick({ + available: true, + truncated: true, + omitted_operations_lower_bound: true, + known_omitted_inputs: true, + known_omitted_edges: true, + }) + .partial({ + omitted_operations_lower_bound: true, + known_omitted_inputs: true, + known_omitted_edges: true, + }) + .strip(), + unavailableNativeValueFlowSchema + .pick({ available: true, reason: true }) + .extend({ reason: z.string() }) + .strip(), +]); diff --git a/src/domain/native/objcSwiftMetadata.test.ts b/src/domain/native/objcSwiftMetadata.test.ts index 7f5dfcc726..cf5c8d8067 100644 --- a/src/domain/native/objcSwiftMetadata.test.ts +++ b/src/domain/native/objcSwiftMetadata.test.ts @@ -1,8 +1,122 @@ import { describe, expect, it } from "vitest"; -import { inspectNativeDispatchMetadata } from "./objcSwiftMetadata.js"; +import { + inspectNativeDispatchMetadata, + objcImplementationClassName, +} from "./objcSwiftMetadata.js"; describe("symbol-only Objective-C implementation identity", () => { + it.each([" ", " ", "\t"])( + "interprets legacy symbol ownership with separator %j without rewriting retained facts", + (separator) => { + const metadata = inspectNativeDispatchMetadata( + [ + { + address: "0x1000", + name: `-[StoreController(Actions)${separator}buildTapped:]`, + }, + ], + 1, + ); + const current = metadata.objc_dispatch_implementations[0]!; + const { category: _category, ...record } = current; + const legacy = { ...record, class_name: "StoreController(Actions)" }; + expect(objcImplementationClassName(legacy)).toBe("StoreController"); + expect(legacy.class_name).toBe("StoreController(Actions)"); + expect(legacy.evidence).toBe(current.evidence); + expect(objcImplementationClassName({ ...legacy, evidence: [] })).toBe( + legacy.class_name, + ); + expect( + objcImplementationClassName({ + ...legacy, + evidence: [ + ...legacy.evidence, + { + ...legacy.evidence[0]!, + description: + "Provider symbol -[OtherController(Actions) buildTapped:]", + }, + ], + }), + ).toBe(legacy.class_name); + expect(objcImplementationClassName({ ...legacy, category: null })).toBe( + legacy.class_name, + ); + expect( + objcImplementationClassName({ + ...legacy, + decode: { status: "decoded", reason: null }, + }), + ).toBe(legacy.class_name); + }, + ); + + it("parses symbol category ownership once and retains the original symbol evidence", () => { + const names = [ + "-[StoreController(Actions) buildTapped:]", + "-[StoreController(OtherActions) buildTapped:]", + ]; + const result = inspectNativeDispatchMetadata( + names.map((name) => ({ address: "0x1000", name })), + 2, + ); + expect(result.objc_classes.map(({ name }) => name)).toEqual([ + "StoreController", + ]); + expect(result.objc_dispatch_implementations).toMatchObject([ + { + class_name: "StoreController", + category: "Actions", + decode: { status: "partial", reason: "symbol_name_only" }, + evidence: [{ description: `Provider symbol ${names[0]}` }], + }, + { + class_name: "StoreController", + category: "OtherActions", + evidence: [{ description: `Provider symbol ${names[1]}` }], + }, + ]); + }); + + it("retains an owner outside the category-symbol grammar without guessing a category", () => { + const result = inspectNativeDispatchMetadata( + [{ address: "0x1000", name: "-[StoreController(A)(B) buildTapped:]" }], + 1, + ); + expect(result.objc_dispatch_implementations[0]).toMatchObject({ + class_name: "StoreController(A)(B)", + }); + expect(result.objc_dispatch_implementations[0]).not.toHaveProperty( + "category", + ); + }); + + it("orders distinct category identities independently of provider inventory order", () => { + const names = [ + "-[StoreController(Actions) buildTapped:]", + "-[StoreController(OtherActions) buildTapped:]", + ]; + const inspect = (ordered: string[]) => + inspectNativeDispatchMetadata( + ordered.map((name) => ({ address: "0x1000", name })), + ordered.length, + ); + const forward = inspect(names); + const reverse = inspect([...names].reverse()); + expect(reverse).toEqual(forward); + expect( + reverse.objc_dispatch_implementations.map(({ category }) => category), + ).toEqual(["Actions", "OtherActions"]); + expect( + reverse.objc_dispatch_implementations.map(({ evidence }) => + evidence.map(({ description }) => description), + ), + ).toEqual(names.map((name) => [`Provider symbol ${name}`])); + }); +}); + +describe("symbol-only Objective-C implementation addresses", () => { it.each([ { source: "0X00002000", canonical: "0x2000", numeric: 8192 }, { @@ -145,13 +259,14 @@ describe("ObjC/Swift metadata", () => { ); }); - it("projects every accepted Swift mangling prefix and counts it", () => { + it("projects every accepted Swift symbol spelling and counts it", () => { const names = [ "$s5Store7launchyyF", "_$s5Store7launchyyF", "$S5Store7launchyyF", "_$S5Store7launchyyF", "_T05Store7launchyyF", + "__T05Store7launchyyF", "@__swiftmacro_5Store7launchfMf_", "$e5Store7launchyyF", "_$e5Store7launchyyF", @@ -175,7 +290,7 @@ describe("ObjC/Swift metadata", () => { expect(result.coverage).toContainEqual( expect.objectContaining({ facet: "swift_declarations", - examined: 9, + examined: 10, decoded: 0, }), ); diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index c5cfa26041..4781bb3acb 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -6,7 +6,7 @@ import { type BinaryTarget, } from "../binaryTargetTypes.js"; import { providerSchema } from "../evidence.js"; -import { isSwiftMangledSymbolName } from "../swiftSymbolClassification.js"; +import { isSwiftSymbolName } from "../swiftSymbolClassification.js"; import { nativeCallReferenceSchema } from "./nativeCallReference.js"; import { machoTargetSelectionSchema } from "../apple/machoTargetSelection.js"; import { @@ -194,6 +194,60 @@ type ObjcDispatchImplementation = z.infer< typeof objcDispatchImplementationSchema >; +/** Interpret category qualification only within the Objective-C method-symbol grammar. */ +const objcMethodSymbolOwner = (owner: string) => { + const categoryOwner = /^([^()]+)\(([^()]+)\)$/u.exec(owner); + return { + class_name: categoryOwner?.[1] ?? owner, + ...(categoryOwner?.[2] === undefined ? {} : { category: categoryOwner[2] }), + }; +}; + +/** The accepted provider spelling, shared by new and retained symbol records. */ +const parseObjcMethodSymbol = (symbol: string) => { + const match = /^([+-])\[([^\s\]]+)\s+([^\]]+)\]$/u.exec(symbol); + if (match?.[1] === undefined || !match[2] || !match[3]) return undefined; + return { + ...objcMethodSymbolOwner(match[2]), + reported_owner: match[2], + selector: match[3], + method_type: match[1] === "+" ? ("class" as const) : ("instance" as const), + }; +}; + +/** Retained symbol-only records predate separate category ownership. */ +export const objcImplementationClassName = ( + implementation: ObjcDispatchImplementation, +): string => { + if ( + implementation.category !== undefined || + implementation.decode.reason !== "symbol_name_only" + ) + return implementation.class_name; + const symbols = implementation.evidence.filter( + ({ kind }) => kind === "symbol", + ); + // Conflicting or absent source facts cannot establish category qualification. + if ( + symbols.length === 0 || + symbols.some((item) => { + const prefix = "Provider symbol "; + if (!item.description.startsWith(prefix)) return true; + const parsed = parseObjcMethodSymbol( + item.description.slice(prefix.length), + ); + return ( + parsed === undefined || + parsed.reported_owner !== implementation.class_name || + parsed.selector !== implementation.selector || + parsed.method_type !== implementation.method_type + ); + }) + ) + return implementation.class_name; + return objcMethodSymbolOwner(implementation.class_name).class_name; +}; + /** Project class declarations without selecting an ambiguous or unrepresentable address. */ export const projectObjcClassMethods = ( implementations: readonly ObjcDispatchImplementation[], @@ -517,13 +571,21 @@ export const inspectNativeDispatchMetadata = ( const classSymbol = objcClassSymbol(entry.name); if (classSymbol !== undefined) classNames.add(classSymbol.name); - const methodMatch = /^([+-])\[([^\s\]]+)\s+([^\]]+)\]$/u.exec(entry.name); - if (methodMatch?.[1] !== undefined && methodMatch[2] && methodMatch[3]) { - const methodType = methodMatch[1] === "+" ? "class" : "instance"; - const selector = methodMatch[3]; + const method = parseObjcMethodSymbol(entry.name); + if (method !== undefined) { + const { + method_type: methodType, + selector, + class_name: className, + category, + } = method; + // The provider's method-symbol grammar owns category qualification. Binary + // class names and historical records are identities, not display strings + // for downstream consumers to split. Keep the original symbol in Evidence. const key = parsedAddress.success ? compositeKey([ - methodMatch[2], + className, + category ?? null, selector, methodType, parsedAddress.data, @@ -534,7 +596,8 @@ export const inspectNativeDispatchMetadata = ( if (previous !== undefined) previous.evidence.push(...evidence); else implementations.set(key, { - class_name: methodMatch[2], + class_name: className, + ...(category === undefined ? {} : { category }), selector, method_type: methodType, implementation_address: address, @@ -542,10 +605,10 @@ export const inspectNativeDispatchMetadata = ( decode: { status: "partial", reason: "symbol_name_only" }, evidence, }); - classNames.add(methodMatch[2]); + classNames.add(className); } - if (isSwiftMangledSymbolName(entry.name) || entry.name.startsWith("_Tt")) + if (isSwiftSymbolName(entry.name)) swiftSymbols.push({ mangled_name: entry.name, address, @@ -583,12 +646,14 @@ export const inspectNativeDispatchMetadata = ( left.selector, left.method_type, left.implementation_address ?? "", + left.category ?? null, ]), compositeKey([ right.class_name, right.selector, right.method_type, right.implementation_address ?? "", + right.category ?? null, ]), ), ); diff --git a/src/domain/peOptionalHeader.ts b/src/domain/peOptionalHeader.ts index b65e706ced..4500b288b5 100644 --- a/src/domain/peOptionalHeader.ts +++ b/src/domain/peOptionalHeader.ts @@ -8,11 +8,14 @@ export interface PeDataDirectory { readonly offset: number; } -/** Structural facts copied from one declared PE optional header. */ -export interface PeOptionalHeaderFixedFields { +export interface PeOptionalHeaderIdentity { readonly magic: 0x10b | 0x20b; readonly bits: 32 | 64; readonly format: "pe32" | "pe32-plus"; +} + +/** Structural facts copied from one declared PE optional header. */ +export interface PeOptionalHeaderFixedFields extends PeOptionalHeaderIdentity { readonly imageBase: bigint; readonly sectionAlignment: number; readonly fileAlignment: number; @@ -37,10 +40,10 @@ export interface PeOptionalHeaderFailure { readonly message: string; } -/** Decode mapping fields without requiring a directory count or table. */ -export const readPeOptionalHeaderFixedFields = ( +/** Decode format identity without requiring mapping fields or directories. */ +export const readPeOptionalHeaderIdentity = ( bytes: Uint8Array, -): Result => { +): Result => { if (bytes.byteLength < 2) return err({ kind: "malformed", @@ -56,6 +59,16 @@ export const readPeOptionalHeaderFixedFields = ( message: "Only PE32 and PE32+ image optional headers are supported", }); const bits = magic === 0x20b ? 64 : 32; + return ok({ magic, bits, format: bits === 64 ? "pe32-plus" : "pe32" }); +}; + +/** Decode mapping fields without requiring a directory count or table. */ +export const readPeOptionalHeaderFixedFields = ( + bytes: Uint8Array, +): Result => { + const identity = readPeOptionalHeaderIdentity(bytes); + if (!identity.ok) return identity; + const { bits } = identity.value; const directoryOffset = bits === 64 ? 112 : 96; if (bytes.byteLength < 64) return err({ @@ -63,10 +76,9 @@ export const readPeOptionalHeaderFixedFields = ( reason: "truncated-fixed-fields", message: "truncated PE optional header", }); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); return ok({ - magic, - bits, - format: bits === 64 ? "pe32-plus" : "pe32", + ...identity.value, imageBase: bits === 64 ? view.getBigUint64(24, true) diff --git a/src/domain/process/processCapture.ts b/src/domain/process/processCapture.ts index c859a7456e..ece9d5667a 100644 --- a/src/domain/process/processCapture.ts +++ b/src/domain/process/processCapture.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { observedSymlinkTarget } from "./symlinkTarget.js"; import { utcDateTimeSchema } from "../offsetDateTime.js"; import { jsonValueSchema } from "../jsonValue.js"; @@ -80,7 +81,9 @@ export type FileState = FileStateIdentity & | { readonly type: "symlink"; readonly sha256: null; - readonly symlink_target: string; + readonly symlink_target: string | null; + /** Exact stored target bytes; omitted by legacy captures. */ + readonly symlink_target_hex?: string | undefined; } | { readonly type: "directory" | "other"; @@ -346,12 +349,35 @@ const fileStateSchema = z.discriminatedUnion("type", [ .nullable(), symlink_target: z.null(), }), - z.object({ - ...fileStateShape, - type: z.literal("symlink"), - sha256: z.null(), - symlink_target: z.string(), - }), + z + .object({ + ...fileStateShape, + type: z.literal("symlink"), + sha256: z.null(), + symlink_target: z.string().nullable(), + symlink_target_hex: z + .string() + .regex(/^(?:[a-f0-9]{2})+$/u) + .optional(), + }) + .superRefine((file, context) => { + if (file.symlink_target_hex === undefined) { + if (file.symlink_target === null) + context.addIssue({ + code: "custom", + message: "An unknown symlink target requires its exact bytes", + }); + return; + } + const { text } = observedSymlinkTarget( + Buffer.from(file.symlink_target_hex, "hex"), + ); + if (file.symlink_target !== text) + context.addIssue({ + code: "custom", + message: "Symlink target text contradicts its exact bytes", + }); + }), z.object({ ...fileStateShape, type: z.enum(["directory", "other"]), diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index 325cfe37ed..3e8820c82f 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -137,6 +137,50 @@ it.each([ expect(parseProcessCapture(capture)).toEqual(capture); }); +it("preserves distinct byte-name omissions at one directory through finalized capture parsing", () => { + const capture = captureWithEnumerationOmission({ + path: "root_0:.", + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: "ff", + }); + const coverage = capture.truncation_details.filesystem_after; + coverage.enumeration_omissions.push({ + path: "root_0:.", + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: "fe", + }); + expect(parseProcessCapture(capture)).toEqual(capture); + coverage.enumeration_omissions.push({ + path: "root_0:.", + reason: "identity_changed", + system_code: null, + }); + coverage.enumeration_reasons.push("identity_changed"); + expect(parseProcessCapture(capture)).toEqual(capture); + // Re-reading the same raw basename is not a distinct omission. + coverage.enumeration_omissions.push({ + path: "root_0:.", + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: "ff", + }); + expect(() => parseProcessCapture(capture)).toThrow("enumeration_omissions"); +}); + +it("requires raw-name evidence for an unrepresentable-name coverage reason", () => { + const capture = captureWithEnumerationOmission({ + path: "root_0:.", + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: "ff", + }); + expect(parseProcessCapture(capture)).toEqual(capture); + capture.truncation_details.filesystem_after.enumeration_omissions = []; + expect(() => parseProcessCapture(capture)).toThrow("enumeration_omissions"); +}); + it.each([ { path: "root_0:.", reason: "entry_unavailable", system_code: null }, { path: "root_0:.", reason: "identity_changed", system_code: "ENOENT" }, diff --git a/src/domain/process/processCaptureCoverage.ts b/src/domain/process/processCaptureCoverage.ts index 50a020c66f..e51054528b 100644 --- a/src/domain/process/processCaptureCoverage.ts +++ b/src/domain/process/processCaptureCoverage.ts @@ -28,15 +28,24 @@ export const filesystemCoverageSchema = z.strictObject({ z.enum([ "files_limit", "depth_limit", + "unrepresentable_name", ...enumerationOmissionReasonSchema.options, ]), ), enumeration_omissions: z.array( - z.strictObject({ - path: z.string(), - reason: enumerationOmissionReasonSchema, - system_code: z.string().nullable(), - }), + z.union([ + z.strictObject({ + path: z.string(), + reason: enumerationOmissionReasonSchema, + system_code: z.string().nullable(), + }), + z.strictObject({ + path: z.string(), + reason: z.literal("unrepresentable_name"), + system_code: z.null(), + raw_name_hex: z.string().regex(/^(?:[0-9a-f]{2})+$/u), + }), + ]), ), unobserved_subtrees: z .array( diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index bb18de49d2..ef1b6239de 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -375,8 +375,17 @@ const validateCoverage = ( ? omission.system_code !== null : omission.system_code === null), ), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason"); - require(new Set(coverage.enumeration_omissions.map(({ path }) => path)) - .size === coverage.enumeration_omissions.length && + require(new Set( + coverage.enumeration_omissions.map((omission) => + omission.reason === "unrepresentable_name" + ? JSON.stringify([ + omission.path, + omission.reason, + omission.raw_name_hex, + ]) + : JSON.stringify([omission.path]), + ), + ).size === coverage.enumeration_omissions.length && (!coverage.enumeration_reasons.includes("entry_unavailable") || coverage.enumeration_omissions.some( ({ reason }) => reason === "entry_unavailable", @@ -385,6 +394,10 @@ const validateCoverage = ( coverage.enumeration_omissions.some( ({ reason }) => reason === "symlink_not_followed", )) && + (!coverage.enumeration_reasons.includes("unrepresentable_name") || + coverage.enumeration_omissions.some( + ({ reason }) => reason === "unrepresentable_name", + )) && coverage.enumeration_omissions.every((omission) => { if (!coverage.enumeration_reasons.includes(omission.reason)) return false; diff --git a/src/domain/process/processComparison.ts b/src/domain/process/processComparison.ts index b39b37b66b..990f8eb0de 100644 --- a/src/domain/process/processComparison.ts +++ b/src/domain/process/processComparison.ts @@ -125,9 +125,6 @@ const terminalObservations = (capture: ProcessCapture): readonly unknown[] => return left.sequence - right.sequence; }); -const filesystemObservations = (capture: ProcessCapture): readonly unknown[] => - capture.filesystem_checkpoints; - const sameJsonValue = (left: unknown, right: unknown): boolean => { const encodedLeft = canonicalize(left); return encodedLeft !== undefined && encodedLeft === canonicalize(right); @@ -146,7 +143,7 @@ export const processDimensionObservations = ( case "exit": return [{ ...capture.exit, settlement: capture.settlement }]; case "filesystem": - return filesystemObservations(capture); + return capture.filesystem_checkpoints; case "process": return capture.process_samples; } @@ -392,52 +389,33 @@ const compareDimensions = ( sameNormalization: boolean, ): ComparisonDimensions => { const classify = ( - scope: ProcessCapture["residual_unknowns"][number]["scope"], - leftValues: readonly unknown[], - rightValues: readonly unknown[], + dimension: ProcessComparisonDimension, source: ProcessObservationSource, ): ComparisonStatus => !sameNormalization || - hasUnknown(left, scope) || - hasUnknown(right, scope) || + hasUnknown(left, dimension) || + hasUnknown(right, dimension) || processSourceTruncated(left, source) || processSourceTruncated(right, source) ? "unknown" - : classifyCollection(leftValues, rightValues); + : classifyCollection( + processDimensionObservations(left, dimension), + processDimensionObservations(right, dimension), + ); return { - terminal: classify( - "terminal", - terminalObservations(left), - terminalObservations(right), - "terminal_rendered", - ), - interaction: classify( - "interaction", - left.interaction_events, - right.interaction_events, - "interaction", - ), + terminal: classify("terminal", "terminal_rendered"), + interaction: classify("interaction", "interaction"), exit: hasUnknown(left, "exit") || hasUnknown(right, "exit") ? "unknown" : sameJsonValue( - { exit: left.exit, settlement: left.settlement }, - { exit: right.exit, settlement: right.settlement }, + processDimensionObservations(left, "exit"), + processDimensionObservations(right, "exit"), ) ? "unchanged" : "changed", - filesystem: classify( - "filesystem", - filesystemObservations(left), - filesystemObservations(right), - "filesystem", - ), - process: classify( - "process", - left.process_samples, - right.process_samples, - "process", - ), + filesystem: classify("filesystem", "filesystem"), + process: classify("process", "process"), }; }; diff --git a/src/domain/process/processTraceComparison.test.ts b/src/domain/process/processTraceComparison.test.ts index da3767aedf..5f43a3a7ef 100644 --- a/src/domain/process/processTraceComparison.test.ts +++ b/src/domain/process/processTraceComparison.test.ts @@ -14,7 +14,10 @@ import { } from "./processTraceComparison.fixture.js"; import { compareProcessCaptures } from "./processComparison.js"; -import { compareProcessTraces } from "./processTraceComparison.js"; +import { + compareProcessTraces, + processTraceOutcomesDiffer, +} from "./processTraceComparison.js"; const traced = ( left: ReturnType, @@ -65,9 +68,14 @@ describe("generic capture trace comparison", () => { suffix: [], }, }; - expect(compareProcessTraces(left, right, concurrent)).toMatchObject({ + const concurrentComparison = compareProcessTraces(left, right, concurrent); + expect(concurrentComparison).toMatchObject({ verdict: "equivalent", }); + expect(processTraceOutcomesDiffer(concurrentComparison)).toBe(true); + expect( + processTraceOutcomesDiffer(compareProcessTraces(left, left, concurrent)), + ).toBe(false); }); it("rejects malformed result contracts", () => { diff --git a/src/domain/process/processTraceComparison.ts b/src/domain/process/processTraceComparison.ts index 08a36a030b..103b383c79 100644 --- a/src/domain/process/processTraceComparison.ts +++ b/src/domain/process/processTraceComparison.ts @@ -25,14 +25,10 @@ export const compareProcessTraces = ( ? null : { side: "right" as const, ...rightEvaluation.diagnostic } : { side: "left" as const, ...leftEvaluation.diagnostic }; - const outcomesDiffer = - leftEvaluation.result.status !== rightEvaluation.result.status || - JSON.stringify( - leftEvaluation.result.raw_trace.map(({ event_id }) => event_id), - ) !== - JSON.stringify( - rightEvaluation.result.raw_trace.map(({ event_id }) => event_id), - ); + const outcomesDiffer = processTraceOutcomesDiffer({ + left: leftEvaluation.result, + right: rightEvaluation.result, + }); return processTraceComparisonResultSchema.parse({ verdict: leftEvaluation.result.status === "unknown" || @@ -52,8 +48,11 @@ export const compareProcessTraces = ( /** Whether trace conformance or raw declared order differs across captures. */ export const processTraceOutcomesDiffer = ( - comparison: ProcessTraceComparisonResult, + comparison: Pick, ): boolean => comparison.left.status !== comparison.right.status || - JSON.stringify(comparison.left.raw_trace.map(({ event_id }) => event_id)) !== - JSON.stringify(comparison.right.raw_trace.map(({ event_id }) => event_id)); + comparison.left.raw_trace.length !== comparison.right.raw_trace.length || + comparison.left.raw_trace.some( + ({ event_id }, index) => + event_id !== comparison.right.raw_trace[index]?.event_id, + ); diff --git a/src/domain/process/symlinkTarget.ts b/src/domain/process/symlinkTarget.ts new file mode 100644 index 0000000000..2f13596fea --- /dev/null +++ b/src/domain/process/symlinkTarget.ts @@ -0,0 +1,23 @@ +import type { FileState } from "./processCapture.js"; + +/** Preserve stored bytes; undecodable targets have no invented text spelling. */ +export const observedSymlinkTarget = ( + raw: Buffer, +): { text: string | null; hex: string } => { + const decoded = raw.toString("utf8"); + return { + text: Buffer.from(decoded, "utf8").equals(raw) ? decoded : null, + hex: raw.toString("hex"), + }; +}; + +/** Old replacement characters cannot establish the target's original bytes. */ +export const symlinkTargetIdentity = ( + file: Extract, +): string | null => { + if (file.symlink_target_hex !== undefined) return file.symlink_target_hex; + const text = file.symlink_target; + if (text === null || text.includes("\ufffd")) return null; + const raw = Buffer.from(text, "utf8"); + return raw.toString("utf8") === text ? raw.toString("hex") : null; +}; diff --git a/src/domain/providerCleanupError.ts b/src/domain/providerCleanupError.ts index b7fc3f1af6..1b2200a019 100644 --- a/src/domain/providerCleanupError.ts +++ b/src/domain/providerCleanupError.ts @@ -1,6 +1,9 @@ import type { JsonValue } from "./jsonValue.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; -import type { AnalysisErrorOptions } from "./analysisErrorBase.js"; +import type { + AnalysisErrorOptions, + AnalysisPartialObservation, +} from "./analysisErrorBase.js"; /** Provider resources could not be proven closed after bounded cleanup. */ export class ProviderCleanupError extends ProviderAdapterError { @@ -8,6 +11,9 @@ export class ProviderCleanupError extends ProviderAdapterError { override readonly userMessage = "Provider cleanup could not be fully confirmed. Review the reported local resources before opening another target."; + /** Producer-attested completed output; ordinary partial observations cannot recover success. */ + readonly completedObservation: AnalysisPartialObservation | undefined; + constructor( providerId: string, resources: readonly string[], @@ -16,13 +22,19 @@ export class ProviderCleanupError extends ProviderAdapterError { options?: Omit & { readonly operation?: string; readonly remediationAction?: string; + readonly completedObservation?: AnalysisPartialObservation; }, ) { - const { operation = "close_binary", ...errorOptions } = options ?? {}; + const { + operation = "close_binary", + completedObservation, + ...errorOptions + } = options ?? {}; super(providerId, operation, { ...errorOptions, diagnostics, }); + this.completedObservation = completedObservation; this.setCleanupResources(resources); } } diff --git a/src/domain/savedAnalysisSnapshot.ts b/src/domain/savedAnalysisSnapshot.ts new file mode 100644 index 0000000000..5fe34785b5 --- /dev/null +++ b/src/domain/savedAnalysisSnapshot.ts @@ -0,0 +1,36 @@ +import type { + AnalysisError, + AnalysisPartialObservation, +} from "./analysisErrorBase.js"; + +/** Receipt for an atomically published analysis snapshot. */ +export interface SavedAnalysisSnapshot { + readonly path: string; + readonly bytes: number; + /** Eligible primitive query bindings. */ + readonly primitive_entries: number; + /** Eligible composed workflow bindings, separate from primitive queries. */ + readonly workflow_entries: number; + /** Includes retained observations without a replay binding. */ + readonly evidence_records: number; +} + +/** Publication remains committed even when subsequent lifecycle cleanup fails. */ +export interface SavedAnalysisSnapshotPartialObservation { + readonly saved_snapshot: SavedAnalysisSnapshot; + readonly cleanup_observation?: AnalysisPartialObservation; +} + +/** Attach publication facts without discarding a cleanup producer's own observation. */ +export const retainSavedAnalysisSnapshot = ( + error: AnalysisError, + receipt: SavedAnalysisSnapshot, +): AnalysisError => { + error.partialObservation = { + saved_snapshot: Object.freeze({ ...receipt }), + ...(error.partialObservation === undefined + ? {} + : { cleanup_observation: error.partialObservation }), + }; + return error; +}; diff --git a/src/domain/swiftSymbolClassification.ts b/src/domain/swiftSymbolClassification.ts index d0cc288104..7cbb27e18e 100644 --- a/src/domain/swiftSymbolClassification.ts +++ b/src/domain/swiftSymbolClassification.ts @@ -101,6 +101,16 @@ const SWIFT_MANGLING_PREFIXES = [ export const isSwiftMangledSymbolName = (name: string): boolean => SWIFT_MANGLING_PREFIXES.some((prefix) => name.startsWith(prefix)); +/** + * A Swift symbol carries either a demangler mangling or a name the category + * decoder already accepts, including visible runtime class names (`_Tt…`) and + * the optional leading underscore of legacy encodings (`__T0…`). Whether the + * nominal category can be decoded is a separate question, so it must not decide + * whether the symbol is Swift at all. + */ +export const isSwiftSymbolName = (name: string): boolean => + isSwiftMangledSymbolName(name) || classifySwiftSymbol(name) !== undefined; + const identifierEnd = ( mangling: string, offset: number, diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index 66b3264501..039d04e17d 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -7,7 +7,7 @@ import type { } from "./native/objcSwiftMetadata.js"; import { classifySwiftSymbol, - isSwiftMangledSymbolName, + isSwiftSymbolName, type SwiftTypeCategory, } from "./swiftSymbolClassification.js"; @@ -76,16 +76,6 @@ export const discoverObjcProtocols = ( }; }; -/** - * A Swift symbol carries either a demangler mangling or a name the category - * decoder already accepts, including visible runtime class names (`_Tt…`) and - * the optional leading underscore of legacy encodings (`__T0…`). Whether the - * nominal category can be decoded is a separate question, so it must not decide - * whether the symbol is Swift at all. - */ -const isSwiftSymbolName = (name: string): boolean => - isSwiftMangledSymbolName(name) || classifySwiftSymbol(name) !== undefined; - /** Categorize procedure names using Swift manglings at their exact addresses. */ export const categorizeSwiftTypes = ( procedures: readonly AddressedName[], diff --git a/src/domain/wasm/wasmArtifact.ts b/src/domain/wasm/wasmArtifact.ts index 08fb0f4318..b0d3e8d479 100644 --- a/src/domain/wasm/wasmArtifact.ts +++ b/src/domain/wasm/wasmArtifact.ts @@ -174,20 +174,20 @@ export type InspectWasmArtifactInput = z.infer< >; export type WasmArtifact = z.infer; -/** A WASM observation must identify the selected file path and byte digest. */ +/** Semantic WASM subject identity excludes delivery-only local paths. */ export type WasmArtifactSubject = Pick< EvidenceSubjectTarget, - "path" | "sha256" | "format" + "sha256" | "format" >; type WasmArtifactFileSubject = WasmArtifactSubject & { readonly format: "file"; }; +/** Match semantic artifact identity; local subject paths are delivery metadata. */ export const wasmArtifactMatchesSubject = ( artifact: WasmArtifact["artifact"], subject: WasmArtifactSubject | null | undefined, ): subject is WasmArtifactFileSubject => subject != null && - subject.path === artifact.path && subject.sha256 === artifact.sha256 && subject.format === "file"; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index e8bbb3cadb..58fe478171 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -16,6 +16,30 @@ import { createWebTextArtifact } from "./webContentArtifact.js"; const origin = "https://app.example.test"; +it("shares runtime module declarations with source analysis while retaining inline type side effects", () => { + const source = ` + import type {T} from "./erased-import"; + export type {T} from "./erased-export"; + export type * from "./erased-all"; + import {type X} from "./inline-import"; + export {type Y} from "./inline-export"; + import ""; + export * from "./runtime"; + export const local = 1; + `; + const expected = ["./inline-import", "./inline-export", "", "./runtime"]; + const result = analyzeWebBundleFixture(inspection(source)); + expect(result.completeness.parse_failures).toBe(0); + expect( + result.observations.chunks.edges.map(({ specifier }) => specifier), + ).toEqual(expected); + expect( + analyzeJavaScriptStaticSource(source) + .references.filter(({ kind }) => kind === "static-import") + .map(({ specifier }) => specifier), + ).toEqual(expected); +}); + it("retains standalone GraphQL Source operations in captured scripts", () => { const result = analyzeWebBundleFixture( inspection(` @@ -561,12 +585,17 @@ describe("web bundle source-map coverage", () => { }); describe("web bundle static-analysis parity", () => { - it("recognizes endpoints through nested dynamic receivers like static analysis", () => { + it("qualifies network spellings through nested dynamic receivers", () => { const result = analyzeWebBundleFixture( inspection('window[recv].fetch("/nested-fetch");'), ); - expect(result.observations.endpoints).toContainEqual( - expect.objectContaining({ value: "/nested-fetch" }), + expect(result.observations.endpoints).toEqual([]); + expect(result.inferences).toContainEqual( + expect.objectContaining({ + kind: "endpoint_candidate", + value: "/nested-fetch", + confidence: "low", + }), ); }); @@ -604,11 +633,17 @@ describe("web bundle static-analysis parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual(["/shadowed-parent-xhr"]); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value) + .sort(), ).toEqual([ "/dav/", "/dynamic-method", - "/shadowed-parent-xhr", "/xhr-get", + "api/relative", "https://xhr.example.test/submit", ]); expect(result.inferences).toContainEqual( @@ -630,9 +665,9 @@ describe("web bundle API classification parity", () => { indexedDB.open("GET", "/storage-named-xhr"); `; expect( - analyzeWebBundleFixture(inspection(source)).observations.endpoints.map( - ({ value }) => value, - ), + analyzeWebBundleFixture(inspection(source)) + .inferences.filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), ).toEqual(["/storage-named-xhr"]); const analysis = analyzeJavaScriptStaticSource(source); expect(analysis.endpoints.map(({ value }) => value)).toEqual([ @@ -662,7 +697,7 @@ describe("web bundle API classification parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["/api", "/dav"]); + ).toEqual(["/api"]); }); it("classifies optional and one-hop aliased open calls", () => { @@ -709,7 +744,7 @@ describe("web bundle API classification parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["/search", "/v1/headers"]); + ).toEqual([]); expect( result.inferences .filter(({ kind }) => kind === "endpoint_candidate") @@ -717,7 +752,9 @@ describe("web bundle API classification parity", () => { .sort(), ).toEqual([ "../search", + "/search", "/users", + "/v1/headers", "cache-key", "https://api.example.test/headers", "https://api.example.test/users", @@ -815,13 +852,12 @@ describe("web bundle explicit request reference forms", () => { caches.open(name, "#cache-name"); fs.open(path, "r"); `; - const expected = [ + const expected: string[] = []; + const expectedCandidates = [ "?view=1", "#view=1", "#literal=1", "https:api.example.test/xhr", - ].sort(); - const expectedCandidates = [ ...references, ...ambiguous, "POST", @@ -870,7 +906,7 @@ describe("web bundle endpoint candidate classification", () => { const result = analyzeWebBundleFixture(inspection(source)); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["login", "ping"]); + ).toEqual(["login"]); const candidates = result.inferences.filter( ({ kind }) => kind === "endpoint_candidate", ); @@ -881,6 +917,7 @@ describe("web bundle endpoint candidate classification", () => { "X", "login", "passwordControl", + "ping", "token", "users", ]); @@ -1087,12 +1124,17 @@ describe("web bundle configuration and navigation parity", () => { }); }); - it("recognizes loadURL endpoints like static analysis", () => { + it("keeps loadURL spelling without Electron receiver proof as a candidate", () => { const result = analyzeWebBundleFixture( inspection('window.win.loadURL("https://embedded.test/app");'), ); - expect(result.observations.endpoints).toContainEqual( - expect.objectContaining({ value: "https://embedded.test/app" }), + expect(result.observations.endpoints).toEqual([]); + expect(result.inferences).toContainEqual( + expect.objectContaining({ + kind: "endpoint_candidate", + value: "https://embedded.test/app", + confidence: "low", + }), ); }); }); @@ -1828,7 +1870,6 @@ it("retains direct and literal computed call names", () => { expect(result.observations.endpoints.map(({ value }) => value)).toEqual([ "/direct", "/literal", - "/nested-receiver", ]); expect( result.observations.chunks.edges.map(({ specifier }) => specifier), @@ -2084,3 +2125,61 @@ it("retains captured Next script identity while deriving decoded route patterns" ]), ); }); + +it("qualifies local browser API spellings while preserving intrinsic requests", () => { + const result = analyzeWebBundleFixture( + inspection(` + const local = { fetch(x) { return x; }, sendBeacon(x) { return x; }, EventSource(x) { return x; } }; + local.fetch('/local-fetch'); local.sendBeacon('/local-beacon'); local.EventSource('/local-events'); + const other = { open(a,b) { return b; }, post(x) { return x; }, put(x) { return x; }, patch(x) { return x; }, request(x) { return x; } }; + other.open('GET', '/local-open'); other.post('/local-post'); other.put('/local-put'); other.patch('/local-patch'); other.request('/local-request'); + fetch('/intrinsic-fetch'); navigator.sendBeacon('/intrinsic-beacon'); new EventSource('/intrinsic-events'); + `), + ); + expect( + result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual(["/intrinsic-beacon", "/intrinsic-events", "/intrinsic-fetch"]); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value) + .sort(), + ).toEqual([ + "/local-beacon", + "/local-events", + "/local-fetch", + "/local-open", + "/local-patch", + "/local-post", + "/local-put", + "/local-request", + ]); +}); + +it.each([ + ["", true], + ["xhr.open = replacement;", false], + ["XMLHttpRequest.prototype.open = replacement;", false], + ["const alias = xhr; alias.open = replacement;", false], + ["mutate(xhr);", false], +])( + "preserves XHR receiver proof across visible mutation: %s", + (mutation, observed) => { + const source = `const xhr = new XMLHttpRequest(); ${mutation} xhr.open('GET', '/xhr');`; + const result = analyzeWebBundleFixture(inspection(source)); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + observed ? ["/xhr"] : [], + ); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), + ).toEqual(observed ? [] : ["/xhr"]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toContainEqual( + expect.objectContaining({ + value: "/xhr", + ...(observed ? {} : { interpretation: "candidate" }), + }), + ); + }, +); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 09425f4bc5..68ea3cb9af 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -1,3 +1,8 @@ +import { + isBrowserNetworkRequest, + createXhrOpenRequestQuery, +} from "./javascript/javascriptNetworkRequests.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascript/javascriptSemanticState.js"; import type { AnalysisAccumulator, IncludedScript, @@ -9,9 +14,11 @@ import type { JavaScriptRouteSourceFacts } from "./javascript/javascriptRouteFac import { sanitizeEndpointCandidate } from "./browserObservation.js"; import { compositeKey } from "./unicodeCodePointOrder.js"; import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; -import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; +import { + traverseJavaScriptAst, + completeSemanticSteps, +} from "./javascript/javascriptSemanticTraversal.js"; import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js"; -import { completeSemanticSteps } from "./javascript/javascriptSemanticTraversal.js"; import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js"; import { isIntrinsicMapLookup } from "./javascript/javascriptSemanticMapOperations.js"; import { @@ -36,6 +43,7 @@ import { isUrlLikeModuleSpecifier, } from "./webBundleAnalyzerAst.js"; import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js"; +import { semanticStaticModuleSource } from "./javascript/javascriptModuleDeclaration.js"; import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js"; type BundleObservations = WebBundleAnalysis["observations"]; @@ -86,6 +94,7 @@ export const analyzeScript = ( } return isIntrinsicMapLookup(node, index.bindings, index) === true; }; + const isXhrRequest = createXhrOpenRequestQuery(index); const rpc = createJavaScriptRpcScan( script.source.artifact.text, receivers.rpcClientRoots, @@ -95,7 +104,15 @@ export const analyzeScript = ( enter: (node, parent) => { accumulator.visitedNodes += 1; routeScan.inspect(node, parent); - inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup); + inspectNode( + script, + node, + accumulator, + openReceiverFacts, + isMapLookup, + index.bindings, + isXhrRequest, + ); const endpoint = rpc.inspect(node); if (endpoint !== undefined) addFinding({ @@ -131,22 +148,14 @@ const inspectNode = ( isMapLookup: ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, ) => boolean, + bindings: JavaScriptSemanticAnalysisState, + isXhrRequest: (node: t.Node) => boolean, ): void => { - if ( - (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && - node.source !== undefined - ) + const moduleSource = semanticStaticModuleSource(node); + if (moduleSource !== null) addEdge({ script, - specifier: node.source.value, - kind: "static_import", - node, - accumulator, - }); - else if (t.isExportNamedDeclaration(node) && node.source != null) - addEdge({ - script, - specifier: node.source.value, + specifier: moduleSource.value, kind: "static_import", node, accumulator, @@ -167,7 +176,15 @@ const inspectNode = ( t.isOptionalCallExpression(node) || t.isNewExpression(node) ) - inspectCall(script, node, accumulator, openReceiverFacts, isMapLookup); + inspectCall( + script, + node, + accumulator, + openReceiverFacts, + isMapLookup, + bindings, + isXhrRequest, + ); }; const inspectCall = ( @@ -178,6 +195,8 @@ const inspectCall = ( isMapLookup: ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, ) => boolean, + bindings: JavaScriptSemanticAnalysisState, + isXhrRequest: (node: t.Node) => boolean, ): void => { const name = calleeName(node.callee); const first = stringValue(node.arguments[0]); @@ -213,8 +232,13 @@ const inspectCall = ( node.callee, openReceiverFacts.get(node.start ?? -1), ); + const mapLookup = isMapLookup(node); + const knownRequest = + isBrowserNetworkRequest(node, bindings) || isXhrRequest(node); const networkEndpoint = - endpoint !== undefined && !isMapLookup(node) ? endpoint : undefined; + endpoint !== undefined && !mapLookup + ? { ...endpoint, kind: knownRequest ? "endpoint" : endpoint.kind } + : undefined; if (networkEndpoint?.kind === "candidate") addEndpointCandidate( script, diff --git a/src/domain/webModuleImports.ts b/src/domain/webModuleImports.ts index c94b98e426..a8162030ee 100644 --- a/src/domain/webModuleImports.ts +++ b/src/domain/webModuleImports.ts @@ -6,8 +6,7 @@ import type { WebModuleImport } from "./webModuleTrace.js"; import type { ExportedWebScript } from "./webScriptExport.js"; import { hasUncertainTypeOnlyModuleSideEffect, - isTypeOnlyExportDeclaration, - isTypeOnlyImportDeclaration, + semanticStaticModuleSource, } from "./javascript/javascriptModuleDeclaration.js"; /** Collect module declarations, omitting whole-statement type-only forms. */ @@ -60,20 +59,12 @@ export const collectWebModuleImports = ( }; traverseJavaScriptAst(ast, { enter: (node) => { - if (node.type === "ImportDeclaration") { - if (isTypeOnlyImportDeclaration(node)) return; - add("static-import", node.source); - } else if ( - node.type === "ExportAllDeclaration" || - node.type === "ExportNamedDeclaration" - ) { - if ( - isTypeOnlyExportDeclaration(node) || - node.source === null || - node.source === undefined - ) - return; - add("re-export", node.source); + const moduleSource = semanticStaticModuleSource(node); + if (moduleSource !== null) { + add( + node.type === "ImportDeclaration" ? "static-import" : "re-export", + moduleSource, + ); } else if (node.type === "ImportExpression") add("dynamic-import", node.source); }, diff --git a/src/domain/webScriptExportPlan.ts b/src/domain/webScriptExportPlan.ts index 01115c6b1b..d755602b78 100644 --- a/src/domain/webScriptExportPlan.ts +++ b/src/domain/webScriptExportPlan.ts @@ -17,6 +17,12 @@ export interface PlannedWebScript { readonly record: ExportedWebScript; } +/** Stable isolated identity shared by planning and destination collision fallback. */ +export const isolatedWebScriptPath = ( + source: CapturedWebScript["source"], + index: number, +): string => `isolated/source-${index + 1}-${hash(JSON.stringify(source))}.js`; + /** Preserve safe URL layouts only when every captured path is unambiguous. */ export const planWebScriptExport = ( scripts: readonly CapturedWebScript[], @@ -45,9 +51,7 @@ export const planWebScriptExport = ( url: script.url, content: { state: "exported", - relative_path: - path ?? - `isolated/source-${index + 1}-${hash(JSON.stringify(script.source))}.js`, + relative_path: path ?? isolatedWebScriptPath(script.source, index), layout: path === undefined ? "isolated" : "url-path", layout_reason: reason, sha256: script.content.sha256, diff --git a/src/domain/windowsNativeArtifact.test.ts b/src/domain/windowsNativeArtifact.test.ts index 248469d7a7..e7bc10621f 100644 --- a/src/domain/windowsNativeArtifact.test.ts +++ b/src/domain/windowsNativeArtifact.test.ts @@ -7,11 +7,12 @@ import { } from "./windowsNativeArtifact.js"; const fixture = () => { - const bytes = Buffer.alloc(128); + const bytes = Buffer.alloc(152); bytes.write("MZ"); bytes.writeUInt32LE(64, 0x3c); bytes.write("PE\0\0", 64, "ascii"); bytes.writeUInt16LE(0x8664, 68); + bytes.writeUInt16LE(64, 84); bytes.writeUInt16LE(0x20b, 88); const manifest = { packageVersion: "1.0.0", @@ -116,3 +117,26 @@ it.each(["offset", "signature", "machine", "optional magic"])( }); }, ); + +it.each([0, 1, 65, 0xffff])( + "rejects an unavailable declared optional header of %i bytes", + (length) => { + const { bytes } = fixture(); + bytes.writeUInt16LE(length, 84); + expect(createWindowsNativeManifest("1.0.0", bytes)).toMatchObject({ + ok: false, + error: { message: expect.stringContaining("PE32+") }, + }); + }, +); + +it.each([2, 32, 63, 64])( + "admits PE32+ identity in a present declared header of %i bytes", + (length) => { + const { bytes } = fixture(); + bytes.writeUInt16LE(length, 84); + expect(createWindowsNativeManifest("1.0.0", bytes)).toMatchObject({ + ok: true, + }); + }, +); diff --git a/src/domain/windowsNativeArtifact.ts b/src/domain/windowsNativeArtifact.ts index 0412340549..cdb943cbec 100644 --- a/src/domain/windowsNativeArtifact.ts +++ b/src/domain/windowsNativeArtifact.ts @@ -1,6 +1,7 @@ import { observeMzWindowsHeader } from "./dosMz.js"; import { createHash } from "node:crypto"; import { readPeCoffHeader } from "./peCoffHeader.js"; +import { readPeOptionalHeaderIdentity } from "./peOptionalHeader.js"; import { err, ok, type Result } from "./result.js"; /** Fixed compatibility contract shared by the artifact producer and readers. */ @@ -99,14 +100,21 @@ const validateWindowsX64Artifact = ( ), ); const offset = observed.offset; - if (offset > bytes.length - 26 || bytes.readUInt16LE(offset + 24) !== 0x20b) + const coff = readPeCoffHeader(bytes.subarray(offset + 4, offset + 24)); + if ( + !coff.ok || + coff.value.machine !== 0x8664 || + coff.value.optionalHeaderSize > bytes.length - offset - 24 + ) return err( new WindowsNativeArtifactError( "Windows native artifact is not a Windows x64 PE32+ image", ), ); - const coff = readPeCoffHeader(bytes.subarray(offset + 4, offset + 24)); - if (!coff.ok || coff.value.machine !== 0x8664) + const optional = readPeOptionalHeaderIdentity( + bytes.subarray(offset + 24, offset + 24 + coff.value.optionalHeaderSize), + ); + if (!optional.ok || optional.value.bits !== 64) return err( new WindowsNativeArtifactError( "Windows native artifact is not a Windows x64 PE32+ image", diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index a2851adc45..dbd61e83f3 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { err, ok, type Result } from "../domain/result.js"; + import { appendManagedParseIssues, managedFailure, @@ -575,11 +577,14 @@ const readTypeSignature = ( { layout, operands, + onElement, }: { /** Validate each type operand against these tables, as specs require. */ readonly layout?: ManagedMetadataLayout; /** Collect each type operand, to resolve it by name. */ readonly operands?: TypeOperand[]; + /** Observe parsed element kinds without reinterpreting signature bytes. */ + readonly onElement?: (kind: number) => void; } = {}, ): { readonly value: string; @@ -595,6 +600,7 @@ const readTypeSignature = ( const kind = blob[cursor]; if (kind === undefined) return malformedSignature("truncated type signature", cursor); + onElement?.(kind); if (kind === 0x41) { const signature = stack.at(-1); if (signature?.kind !== "list" || !signature.signatureItems) @@ -787,8 +793,8 @@ const readTypeSignature = ( } }; -/** Validate a StandAloneSig LocalVarSig without projecting local type identities. */ -export const validateLocalSignature = ( +/** Read validated LocalVarSig identity facts without projecting local type identities. */ +export const readLocalSignatureFacts = ( layout: ManagedMetadataLayout, blob: Buffer, { @@ -796,7 +802,10 @@ export const validateLocalSignature = ( blobOffset, }: { readonly scope: string; readonly blobOffset: number }, budget: ManagedDecodeBudget, -): ManagedParseIssue | null => { +): Result< + NonNullable, + ManagedParseIssue +> => { budget.admitTransient(blob.length * 256, blobOffset); try { if (blob[0] !== 0x07) @@ -808,13 +817,24 @@ export const validateLocalSignature = ( 1, ); let cursor = count.next; + let contextDependent = false; for (let index = 0; index < count.value; index += 1) { - const local = readTypeSignature(blob, cursor, "local", { layout }); + const local = readTypeSignature(blob, cursor, "local", { + layout, + onElement: (kind) => { + // Named types, generic variables and modifiers require metadata or declaring context. + if ([0x11, 0x12, 0x13, 0x15, 0x1e, 0x1f, 0x20].includes(kind)) + contextDependent = true; + }, + }); cursor = local.next; } if (cursor !== blob.length) malformedSignature("Trailing LocalVarSig data", cursor); - return null; + return ok({ + raw_sha256: sha256Bytes(blob), + has_context_dependent_types: contextDependent, + }); } catch (cause: unknown) { if ( !(cause instanceof ManagedReaderFailure) || @@ -823,14 +843,14 @@ export const validateLocalSignature = ( cause.issue.code !== "unsupported-signature") ) throw cause; - return { + return err({ ...cause.issue, scope, offset: cause.issue.offset === null ? blobOffset : blobOffset + cause.issue.offset, - }; + }); } }; diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index 6e50f50bf8..caf02fcd0c 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -21,7 +21,7 @@ import { sha256Bytes, } from "./ManagedMetadataHeaps.js"; import { - validateLocalSignature, + readLocalSignatureFacts, isParsedTokenInstruction, } from "./ManagedMemberInspectorCore.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; @@ -197,6 +197,7 @@ export const methodBody = ( decoded.instructionStarts, ) : null; + let localVarSignature: ManagedMethodBody["local_var_signature"] = null; let localSigIssue: string | null = null; let localSigFailure: "malformed" | "partial" | null = null; const localSignature = @@ -228,7 +229,7 @@ export const methodBody = ( cursor.readIndex(context.metadataLayout.blobIndexSize), context.metadataLayout.blob?.size ?? 0, ); - const signatureIssue = validateLocalSignature( + const signatureFacts = readLocalSignatureFacts( context.metadataLayout, blob, { @@ -237,7 +238,9 @@ export const methodBody = ( }, context.budget, ); - if (signatureIssue !== null) { + if (signatureFacts.ok) localVarSignature = signatureFacts.value; + else { + const signatureIssue = signatureFacts.error; localSigIssue = signatureIssue.detail; localSigFailure = signatureIssue.code === "unsupported-signature" @@ -269,6 +272,7 @@ export const methodBody = ( max_stack: header.maxStack, init_locals: (header.flags & 0x10) !== 0, local_var_sig_token: localSignature?.token ?? null, + local_var_signature: localVarSignature, il_size: header.ilSize, il_sha256: sha256Bytes(il), normalized_il_sha256: @@ -352,6 +356,7 @@ const emptyMethodBody = ( max_stack: null, init_locals: null, local_var_sig_token: null, + local_var_signature: null, il_size: 0, il_sha256: null, normalized_il_sha256: null, diff --git a/src/electron/ElectronFusePath.ts b/src/electron/ElectronFusePath.ts index 013d92fb35..0a03bff64f 100644 --- a/src/electron/ElectronFusePath.ts +++ b/src/electron/ElectronFusePath.ts @@ -210,7 +210,21 @@ const resolveInside = async ( nodes.push({ path: absolute, metadata }); hops += 1; if (hops > MAX_SYMLINK_HOPS) return { kind: "loop" }; - const target = await readlink(absolute); + const rawTarget = await readlink(absolute, { encoding: "buffer" }); + let target: string; + try { + // A replacement character can alias a different, valid sibling name. + // Preserve a literal UTF-8 BOM too: it is part of a filesystem name. + target = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(rawTarget); + } catch { + throw new ElectronFusePathFailure( + "invalid_value", + `Framework symlink target is not valid UTF-8 (raw target bytes: ${rawTarget.toString("hex")}): ${absolute}`, + ); + } // Lexical only: another alias of this root (/tmp vs /private/tmp) // is still an escape. Later hops stay lstat- and containment-checked. const absoluteTarget = isAbsolute(target); @@ -360,8 +374,10 @@ const isMissing = (cause: unknown): boolean => "code" in cause && ["ENOENT", "ENOTDIR"].includes(String(cause.code)); -/** Split a relative link target using the local filesystem's separator rules. */ -const symlinkTargetSegments = (target: string): readonly string[] => - target - .split(process.platform === "win32" ? /[\\/]/u : "/") - .filter((part) => part !== ""); +/** Preserve a link target's terminal separator as a directory requirement. */ +const symlinkTargetSegments = (target: string): readonly string[] => { + const separator = process.platform === "win32" ? /[\\/]/u : /\//u; + const parts = target.split(separator).filter((part) => part !== ""); + if (separator.test(target.slice(-1))) parts.push("."); + return parts; +}; diff --git a/src/electron/ElectronFuseProvider.test.ts b/src/electron/ElectronFuseProvider.test.ts index aad51e7427..af36e1696c 100644 --- a/src/electron/ElectronFuseProvider.test.ts +++ b/src/electron/ElectronFuseProvider.test.ts @@ -1,6 +1,13 @@ import { resolveElectronFuseTarget } from "./ElectronFusePath.js"; import { RegularFileChangedError } from "../filesystem/RegularFile.js"; -import { mkdir, symlink, writeFile, truncate, rename } from "node:fs/promises"; +import { + mkdir, + symlink, + writeFile, + truncate, + rename, + stat, +} from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -34,6 +41,62 @@ const inspect = async (path: string) => { return result.value; }; +it.skipIf(process.platform === "win32")( + "rejects undecodable symlink targets rather than selecting a replacement sibling", + async () => { + const root = await createTestTempDirectory("rea-electron-link-encoding-"); + const framework = join(root, "Electron Framework.framework"); + await mkdir(framework); + await writeFile(join(framework, "\uFFFD"), thinFuseImage([0x31]).bytes); + const alias = join(framework, "alias"); + await symlink(Buffer.from([0xff]), alias); + await expect(stat(alias)).rejects.toMatchObject({ code: "ENOENT" }); + const result = await provider.inspect({ path: alias }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + issues: [ + { + reason: "invalid_value", + message: expect.stringContaining("not valid UTF-8"), + }, + ], + }); + const validName = "\uFEFFbinary"; + await writeFile(join(framework, validName), thinFuseImage([0x31]).bytes); + const validAlias = join(framework, "valid-alias"); + await symlink(validName, validAlias); + expect( + (await inspect(validAlias)).slices[0]?.sentinels[0]?.fuses[0]?.state, + ).toBe("enabled"); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects framework links whose terminal separator requires a directory", + async () => { + const root = await createTestTempDirectory("rea-electron-link-directory-"); + const framework = join(root, "Electron Framework.framework"); + await mkdir(join(framework, "Versions", "A"), { recursive: true }); + await writeFile( + join(framework, "Versions", "A", "Electron Framework"), + thinFuseImage([0x31]).bytes, + ); + for (const suffix of ["/", "/."]) { + const alias = join(framework, `alias${suffix.length}`); + await symlink(`Versions/A/Electron Framework${suffix}`, alias); + await expect(stat(alias)).rejects.toMatchObject({ code: "ENOTDIR" }); + const result = await provider.inspect({ path: alias }); + expect(result.ok).toBe(false); + } + await symlink("A/", join(framework, "Versions", "Current")); + const validAlias = join(framework, "Electron Framework"); + await symlink("Versions/Current/Electron Framework", validAlias); + const report = await inspect(validAlias); + expect(report.slices[0]?.sentinels[0]?.fuses[0]?.state).toBe("enabled"); + }, +); + it("preserves the hidden ARM64 marker through fuse scanning and reporting", async () => { const root = await createTestTempDirectory( "rea-electron-fuses-hidden-arm64-", diff --git a/src/electron/ElectronFuseReader.ts b/src/electron/ElectronFuseReader.ts index 8c3ed9e037..8b719a39b6 100644 --- a/src/electron/ElectronFuseReader.ts +++ b/src/electron/ElectronFuseReader.ts @@ -276,33 +276,12 @@ const identifyMacho = async ( architecture: slice.architecture, ...(slice.hidden === undefined ? {} : { hidden: slice.hidden }), })); - assertNonOverlappingSlices(slices); return { format, slices, }; }; -/** A FAT architecture owns a distinct byte range; overlap is malformed. */ -const assertNonOverlappingSlices = (ranges: readonly SliceRange[]): void => { - const ordered = [...ranges].sort( - (left, right) => left.fileOffset - right.fileOffset, - ); - for (let index = 1; index < ordered.length; index += 1) { - const previous = ordered[index - 1]; - const current = ordered[index]; - if ( - previous !== undefined && - current !== undefined && - current.fileOffset < previous.fileOffset + previous.size - ) - throw new ElectronFuseFormatFailure( - "malformed", - `Mach-O slices ${previous.index} and ${current.index} overlap.`, - ); - } -}; - const scanSlices = async ( handle: FileHandle, path: string, diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index ce9b976f53..bc55f68fe4 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -207,3 +207,33 @@ export const sameRegularFileState = ( expected.ctimeNs === observed.ctimeNs : expected.mtimeMs === observed.mtimeMs && expected.ctimeMs === observed.ctimeMs); + +/** Validate an admitted descriptor and selected name at the same precision as admission. */ +export const assertStableRegularFileIdentity = async ( + file: FileHandle, + path: string, + before: Stats | BigIntStats, + options: { + readonly symlinks: "follow" | "reject"; + readonly signal?: AbortSignal; + }, +): Promise => { + assertRegularFileActive(options.signal); + const bigint = typeof before.dev === "bigint"; + const named = async () => { + try { + const lookup = options.symlinks === "follow" ? stat : lstat; + return bigint ? await lookup(path, { bigint: true }) : await lookup(path); + } catch (cause: unknown) { + if (isSelectedRegularFileError(cause)) + throw new RegularFileChangedError(path, { cause }); + throw cause; + } + }; + if ( + !sameRegularFileState(before, await file.stat({ bigint })) || + !sameRegularFileState(before, await named()) + ) + throw new RegularFileChangedError(path); + assertRegularFileActive(options.signal); +}; diff --git a/src/filesystem/StableFileHash.ts b/src/filesystem/StableFileHash.ts index e8f1fa8659..1fa92d4c7a 100644 --- a/src/filesystem/StableFileHash.ts +++ b/src/filesystem/StableFileHash.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { stat } from "node:fs/promises"; import { withOwnedRegularFile, @@ -10,7 +9,7 @@ import { RegularFileChangedError, RegularFileOperationCancelled, assertRegularFileActive, - sameRegularFileState, + assertStableRegularFileIdentity, regularFileState, type RegularFileState, } from "./RegularFile.js"; @@ -122,17 +121,11 @@ async function readStableFile( position += read.bytesRead; } assertRegularFileActive(options.signal); - const [opened, currentPath] = await Promise.all([ - handle.stat(), - stat(path), - ]); - if ( - position !== initial.size || - !sameRegularFileState(initial, opened) || - !sameRegularFileState(initial, currentPath) - ) - throw new RegularFileChangedError(path); - assertRegularFileActive(options.signal); + if (position !== initial.size) throw new RegularFileChangedError(path); + await assertStableRegularFileIdentity(handle, path, initial, { + symlinks: "follow", + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); const fingerprint = { sha256: hash.digest("hex"), bytes: position }; return includePrefix ? { ...fingerprint, prefix, state: regularFileState(initial) } diff --git a/src/filesystem/readStableFileBytes.ts b/src/filesystem/readStableFileBytes.ts index 2798bcd3cb..a3bf6f6709 100644 --- a/src/filesystem/readStableFileBytes.ts +++ b/src/filesystem/readStableFileBytes.ts @@ -1,9 +1,8 @@ -import type { BigIntStats } from "node:fs"; -import { lstat, stat, type FileHandle } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { assertRegularFileActive, RegularFileChangedError, - sameRegularFileState, + assertStableRegularFileIdentity, } from "./RegularFile.js"; /** Resource refusal carries the observed extent before allocating file bytes. */ @@ -49,7 +48,7 @@ export const readStableFileBytes = async ( const extra = Buffer.alloc(1); if ((await file.read(extra, 0, 1, bytes.length)).bytesRead !== 0) throw new RegularFileChangedError(path); - await assertStableIdentity(file, path, before, options); + await assertStableRegularFileIdentity(file, path, before, options); return bytes; }; @@ -70,7 +69,7 @@ export const readStableFilePrefix = async ( before.size < BigInt(maximumBytes) ? before.size : BigInt(maximumBytes), ); const bytes = await readExtent(file, path, length, options.signal); - await assertStableIdentity(file, path, before, options); + await assertStableRegularFileIdentity(file, path, before, options); return { bytes, size: before.size }; }; @@ -97,35 +96,3 @@ const readExtent = async ( assertRegularFileActive(signal); return bytes; }; - -const assertStableIdentity = async ( - file: FileHandle, - path: string, - before: BigIntStats, - options: StableReadOptions, -): Promise => { - assertRegularFileActive(options.signal); - const named = async () => { - try { - return await (options.symlinks === "follow" ? stat : lstat)(path, { - bigint: true, - }); - } catch (cause: unknown) { - if ( - cause instanceof Error && - "code" in cause && - (cause.code === "ENOENT" || - cause.code === "ENOTDIR" || - cause.code === "ELOOP") - ) - throw new RegularFileChangedError(path, { cause }); - throw cause; - } - }; - if ( - !sameRegularFileState(before, await file.stat({ bigint: true })) || - !sameRegularFileState(before, await named()) - ) - throw new RegularFileChangedError(path); - assertRegularFileActive(options.signal); -}; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index f71e17ca15..dde5cf30da 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -1,7 +1,7 @@ import { safeParseUtf8Json } from "../domain/safeJson.js"; import { createHash } from "node:crypto"; import { createWriteStream } from "node:fs"; -import { chmod, lstat, opendir, readlink, realpath } from "node:fs/promises"; +import { chmod, lstat, opendir, readlink } from "node:fs/promises"; import { isAbsolute, join, relative, sep } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -113,17 +113,10 @@ export class FirmwareFileAccess { async #snapshot(request: FirmwareRequest, root: string, signal: AbortSignal) { const sourcePath = absoluteCallerPath(request.input.path); - const source = await realpath(sourcePath).catch((cause: unknown) => { - throw firmwareSourceAdmissionFailure( - request.operation, - sourcePath, - cause, - ); - }); let sourceAdmitted = false; const fullPath = join(root, "firmware.bin"); const copied = await withArtifactRegularFile( - source, + sourcePath, this.#resources, signal, async ({ handle, initial: stat }) => { @@ -150,6 +143,7 @@ export class FirmwareFileAccess { ); return { size, sha256: hash.digest("hex") }; }, + "follow", ).catch((cause: unknown) => { // Destination writes and reads after admission retain their own failure. throw sourceAdmitted diff --git a/src/firmware/FirmwareReports.test.ts b/src/firmware/FirmwareReports.test.ts index 0560dc56be..c108cf22e6 100644 --- a/src/firmware/FirmwareReports.test.ts +++ b/src/firmware/FirmwareReports.test.ts @@ -706,46 +706,79 @@ describe("firmware chunk dependency failures", () => { }); describe("Unblob multi-file extraction diagnostics", () => { - it("names a missing extractor dependency and stage while retaining the raw report", () => { - const dependencyFailure = { - __typename__: "ExtractorDependencyNotFoundReport", - severity: "ERROR", - dependencies: ["sasquatch"], - }; - const record = root(); - const normalized = normalizeUnblobReport( - [ + it.each([ + { dependencies: ["sasquatch"], names: ["sasquatch", "nested-tool"] }, + { dependencies: ["sasquatch", ""], names: ["sasquatch", "nested-tool"] }, + { dependencies: [], names: ["nested-tool"] }, + ])( + "retains nested dependency diagnostics beneath $dependencies", + ({ dependencies, names }) => { + const dependencyFailure = { + __typename__: "ExtractorDependencyNotFoundReport", + severity: "ERROR", + dependencies, + nested_reports: [ + { + __typename__: "ExtractorDependencyNotFoundReport", + dependencies: ["nested-tool", "nested-tool"], + }, + ], + }; + const record = root(); + const normalized = normalizeUnblobReport( + [ + { + ...record, + reports: [ + ...record.reports.filter( + (item) => item.__typename__ !== "UnknownChunkReport", + ), + { + __typename__: "ChunkReport", + id: "gzip-id", + start_offset: 0, + end_offset: 20, + size: 20, + handler_name: "gzip", + is_encrypted: false, + extraction_reports: [dependencyFailure], + }, + ], + }, + ], + context, + ); + expect(normalized.coverage).toBe("partial"); + expect(normalized.diagnostics).toEqual([ { - ...record, - reports: [ - ...record.reports.filter( - (item) => item.__typename__ !== "UnknownChunkReport", - ), - { - __typename__: "ChunkReport", - id: "gzip-id", - start_offset: 0, - end_offset: 20, - size: 20, - handler_name: "gzip", - is_encrypted: false, - extraction_reports: [dependencyFailure], - }, - ], + input_path: "$input", + stage: "gzip", + missing_dependencies: names, + report: dependencyFailure, }, - ], - context, - ); - expect(normalized.coverage).toBe("partial"); - expect(normalized.diagnostics).toEqual([ - { - input_path: "$input", - stage: "gzip", - missing_dependencies: ["sasquatch"], - report: dependencyFailure, - }, - ]); - }); + ]); + expect(normalized.chunk_missing_dependencies).toEqual([ + ...(dependencies.includes("sasquatch") + ? [ + { + input_path: "$input", + provider_id: "gzip-id", + dependencies: ["sasquatch"], + }, + ] + : []), + ...(dependencies.length === 0 || dependencies.includes("") + ? [ + { + input_path: "$input", + provider_id: "gzip-id", + dependencies: [], + }, + ] + : []), + ]); + }, + ); }); describe("Unblob task derivation identities", () => { @@ -1257,7 +1290,7 @@ it.each(["task", "chunk", "multi-file"] as const)( { __typename__: "ExtractorDependencyNotFoundReport", severity: "ERROR", - dependencies: ["z-tool", "a-tool", "z-tool"], + dependencies: ["z-tool", "", "a-tool", "z-tool"], producer_detail: "named source declaration", }, { diff --git a/src/firmware/FirmwareReports.ts b/src/firmware/FirmwareReports.ts index 4120375865..fe1c68758c 100644 --- a/src/firmware/FirmwareReports.ts +++ b/src/firmware/FirmwareReports.ts @@ -144,11 +144,6 @@ const known = new Set([ "MultiFileReport", ]); -const dependencyFailure = z.object({ - __typename__: z.literal("ExtractorDependencyNotFoundReport"), - dependencies: z.array(z.string().min(1)).min(1), -}); - /** Collect the named extractors from any dependency report nested in one value. */ const diagnosticDependencyNames = (value: unknown): string[] => { // Producer-controlled name volume must not make deduplication quadratic; @@ -160,11 +155,16 @@ const diagnosticDependencyNames = (value: unknown): string[] => { return; } if (typeof node !== "object" || node === null) return; - const failure = dependencyFailure.safeParse(node); + const failure = missingDependency.safeParse(node); if (failure.success) { - for (const dependency of failure.data.dependencies) found.add(dependency); - return; + // Empty names carry unknown identity, not evidence against the known + // names in the same declaration. Ownership facts retain that unknown; + // diagnostics project only names and keep the complete source report. + for (const dependency of failure.data.dependencies) + if (dependency !== "") found.add(dependency); } + // A declaration may also carry nested producer diagnostics. Its own + // admission does not determine whether those independent reports exist. for (const child of Object.values(node)) visit(child); }; visit(value); diff --git a/src/firmware/FirmwareSourceAdmission.test.ts b/src/firmware/FirmwareSourceAdmission.test.ts new file mode 100644 index 0000000000..fd17ba11db --- /dev/null +++ b/src/firmware/FirmwareSourceAdmission.test.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { firmwareRequestSchema } from "../domain/firmware/firmwareAnalysis.js"; +import { FirmwareFileAccess } from "./FirmwareFiles.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +const workspace = async () => { + const root = await mkdtemp(join(tmpdir(), "rea-firmware-source-")); + roots.push(root); + const staging = join(root, "stage"); + await mkdir(staging); + return { root, staging }; +}; + +it("honors cancellation before source admission without creating a snapshot", async () => { + const { root, staging } = await workspace(); + const path = join(root, "firmware"); + await writeFile(path, "firmware"); + const request = firmwareRequestSchema.parse({ + operation: "inspect_firmware_regions", + input: { path }, + }); + const cancellation = new AbortController(); + cancellation.abort(new Error("cancel source admission")); + const files = new FirmwareFileAccess(); + try { + await expect( + files.snapshot(request, staging, cancellation.signal), + ).rejects.toMatchObject({ reason: "cancelled" }); + await expect(readFile(join(staging, "firmware.bin"))).rejects.toMatchObject( + { code: "ENOENT" }, + ); + } finally { + await files.close(); + } +}); + +it.skipIf(process.platform === "win32")( + "rejects file/.. traversal before creating a firmware snapshot", + async () => { + const { root, staging } = await workspace(); + await writeFile(join(root, "blocker"), "regular file"); + await writeFile(join(root, "firmware"), "decoy firmware"); + const path = `${root}/blocker/../firmware`; + await expect(readFile(path)).rejects.toMatchObject({ code: "ENOTDIR" }); + const request = firmwareRequestSchema.parse({ + operation: "inspect_firmware_regions", + input: { path }, + }); + const files = new FirmwareFileAccess(); + try { + await expect( + files.snapshot(request, staging, new AbortController().signal), + ).rejects.toMatchObject({ _tag: "AnalysisInputError" }); + await expect( + readFile(join(staging, "firmware.bin")), + ).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await files.close(); + } + }, +); + +// POSIX parent traversal and symlink creation require host filesystem support; +// Windows does not guarantee either traversal semantics or symlink privileges. +it.skipIf(process.platform === "win32").each(["parent", "final"] as const)( + "snapshots the filesystem-selected %s symlink target and preserves its selected path", + async (kind) => { + const { root, staging } = await workspace(); + await mkdir(join(root, "physical", "child"), { recursive: true }); + const bytes = Buffer.from("selected firmware bytes"); + await writeFile(join(root, "physical", "firmware"), bytes); + await writeFile(join(root, "firmware"), "lexical decoy"); + await symlink(join(root, "physical", "child"), join(root, "link"), "dir"); + await symlink( + join(root, "physical", "firmware"), + join(root, "alias"), + "file", + ); + const path = + kind === "parent" ? `${root}/link/../firmware` : join(root, "alias"); + const request = firmwareRequestSchema.parse({ + operation: "inspect_firmware_regions", + input: { path }, + }); + const files = new FirmwareFileAccess(); + try { + const snapshot = await files.snapshot( + request, + staging, + new AbortController().signal, + ); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + expect(snapshot.subject).toEqual({ path, sha256, format: "file" }); + expect(snapshot.selection).toEqual({ + offset: 0, + length: bytes.length, + sha256, + }); + expect((await readFile(snapshot.inputPath)).equals(bytes)).toBe(true); + } finally { + await files.close(); + } + }, +); diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index fe9226e43c..185979e052 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -1,3 +1,9 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../artifacts/AnalysisCleanupLease.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js"; import { createHash } from "node:crypto"; import { chmod, mkdtemp, open as openFile, rm, stat } from "node:fs/promises"; @@ -28,7 +34,10 @@ import { } from "../domain/analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisPartialObservation, +} from "../domain/analysisErrorBase.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -367,8 +376,11 @@ export class FlutterBuildProvider { >(); private readonly removeRoot: (path: string) => Promise; private readonly cleanupFailures = new Map(); + private readonly rootLeases = new Map(); + private readonly operations = new ArtifactResourceScope(); constructor(options: FlutterProviderOptions = {}) { + this.operations.addCloseHook(() => this.closeRoots()); this.removeRoot = options.removeRoot ?? ((path) => rm(path, { recursive: true, force: true })); @@ -393,6 +405,13 @@ export class FlutterBuildProvider { throw mapOwnedIoFailure(operation, cause, null); } this.ownedRoots.set(root, new Set()); + this.rootLeases.set( + root, + new AnalysisCleanupLease(async () => { + if (!(await this.cleanupRoot(root))) + throw this.cleanupError(root, operation); + }, [root]), + ); const snapshotPath = join(root, "input.apk"); const readSource = async (read: () => Promise): Promise => { try { @@ -486,10 +505,18 @@ export class FlutterBuildProvider { const sha256 = await sha256File(operation, snapshotPath, signal); return { root, path: snapshotPath, bytes: info.size, sha256 }; } catch (cause) { - await this.discardRoot(root); - if (signal?.aborted === true) - throw new AnalysisCancelledError(operation, { cause }); - throw mapOwnedIoFailure(operation, cause, root); + const primary = + signal?.aborted === true + ? new AnalysisCancelledError(operation, { cause: signal.reason }) + : mapOwnedIoFailure(operation, cause, root); + const released = await this.discardRoot(root); + if (!released) + throw this.withCleanup( + primary, + this.cleanupError(root, operation), + operation, + ); + throw primary; } } @@ -518,6 +545,18 @@ export class FlutterBuildProvider { /** Close every owned handle before removing its root; retain any failure. */ private async discardRoot(root: string): Promise { + const lease = this.rootLeases.get(root); + if (lease === undefined) return true; + try { + await lease.release(); + this.rootLeases.delete(root); + return true; + } catch { + return false; + } + } + + private async cleanupRoot(root: string): Promise { const resources = this.ownedRoots.get(root); if (resources === undefined) return true; const attempts = await Promise.allSettled( @@ -551,29 +590,23 @@ export class FlutterBuildProvider { }; } - /** Retain failed snapshot cleanup ownership and retry it on close. */ - async close(): Promise { - const failures: { - readonly root: string; - readonly causes: readonly unknown[]; - }[] = []; - for (const root of [...this.ownedRoots.keys()]) { - if (!(await this.discardRoot(root))) - failures.push({ root, causes: this.cleanupFailures.get(root)! }); - } - if (failures.length > 0) - throw new ProviderCleanupError( - FLUTTER_PROVIDER_IDENTITY.id, - failures.map(({ root }) => root), - { - reason: failures - .flatMap(({ causes }) => - causes.map((cause) => - cause instanceof Error ? cause.message : String(cause), - ), - ) - .join("; "), - retained_roots: failures.map(({ root, causes }) => ({ + private cleanupError( + root: string, + operation: string, + completedObservation?: AnalysisPartialObservation, + ): ProviderCleanupError { + const causes = this.cleanupFailures.get(root) ?? []; + const error = new ProviderCleanupError( + FLUTTER_PROVIDER_IDENTITY.id, + [root], + { + reason: causes + .map((cause) => + cause instanceof Error ? cause.message : String(cause), + ) + .join("; "), + retained_roots: [ + { path: root, failures: causes.map((cause) => ({ name: cause instanceof Error ? cause.name : "UnknownError", @@ -584,28 +617,104 @@ export class FlutterBuildProvider { ? { code: cause.code } : {}), })), - })), + }, + ], + }, + { + cause: new AggregateError(causes, "Flutter snapshot cleanup failed"), + operation, + ...(completedObservation === undefined ? {} : { completedObservation }), + }, + ); + const lease = this.rootLeases.get(root); + if (lease !== undefined) new AnalysisCleanupLeaseGroup(error, [lease]); + return error; + } + + private withCleanup( + primary: AnalysisError, + cleanup: AnalysisError, + operation: string, + ): AnalysisError { + return analysisErrorWithCleanupFailure( + primary, + cleanup, + operation, + ).bindCleanupDependencies([primary, cleanup]); + } + + /** Stop admission and drain active snapshots before attempting retained cleanup. */ + async close(): Promise { + await this.operations.close(); + } + + private async closeRoots( + roots = [...this.ownedRoots.keys()], + operation = "close_flutter_build", + ): Promise { + const failures: AnalysisError[] = []; + for (const root of roots) { + if (!(await this.discardRoot(root))) + failures.push(this.cleanupError(root, operation)); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new ProviderCleanupError( + FLUTTER_PROVIDER_IDENTITY.id, + failures.flatMap((failure) => failure.cleanupResources), + { + reason: failures + .map((failure) => failure.diagnostics?.reason ?? failure.message) + .join("; "), + retained_roots: failures.flatMap((failure) => { + const roots = failure.diagnostics?.retained_roots; + return Array.isArray(roots) ? roots : []; + }), }, { - cause: new AggregateError( - failures.flatMap(({ causes }) => causes), - "Flutter snapshot cleanup failed", - ), - operation: "close_flutter_build", + cause: new AggregateError(failures), + operation, }, - ); + ).bindCleanupDependencies(failures); } async execute( request: FlutterRequest, options?: ExecutionOptions, ): Promise> { - if (options?.signal?.aborted === true) - return err( - new AnalysisCancelledError(request.operation, { - cause: options.signal.reason, - }), + try { + return await this.operations.run(() => + this.executeOwned(request, options), ); + } catch (cause) { + return err(mapOwnedIoFailure(request.operation, cause, null)); + } + } + + private async executeOwned( + request: FlutterRequest, + options?: ExecutionOptions, + ): Promise> { + const cancellation = (): AnalysisCancelledError | undefined => + options?.signal?.aborted === true + ? new AnalysisCancelledError(request.operation, { + cause: options.signal.reason, + }) + : undefined; + const beforeCleanup = cancellation(); + if (beforeCleanup !== undefined) return err(beforeCleanup); + // Only failed roots are safe to retry here: concurrent admitted snapshots + // retain their own live resources until their operations finish. + try { + await this.closeRoots( + [...this.cleanupFailures.keys()], + request.operation, + ); + } catch (cause) { + return err(mapOwnedIoFailure(request.operation, cause, null)); + } + const afterCleanup = cancellation(); + if (afterCleanup !== undefined) return err(afterCleanup); const { path } = request.input; const signal = options?.signal; let snapshot: OwnedSnapshot | undefined; @@ -640,27 +749,50 @@ export class FlutterBuildProvider { result = err( mapOwnedIoFailure(request.operation, cause, snapshot?.root ?? null), ); - } finally { - if (snapshot !== undefined) await this.discardRoot(snapshot.root); } - // Cancellation observed after the awaited snapshot removal still keeps - // the completed facts, composed exactly as the uncancelled Evidence. - if (signal?.aborted === true) - return err( - new AnalysisCancelledError(request.operation, { - cause: signal.reason, - ...(result.ok - ? { - partialObservation: createAnalysisExecutionEvidence( + const cleanup = + snapshot !== undefined && !(await this.discardRoot(snapshot.root)) + ? this.cleanupError( + snapshot.root, + request.operation, + result.ok + ? createAnalysisExecutionEvidence( request.operation, request.input, result.value, "observed", - ), - } - : {}), + ) + : undefined, + ) + : undefined; + // Cancellation observed after the awaited snapshot removal still keeps + // the completed facts, composed exactly as the uncancelled Evidence. + if (signal?.aborted === true && result.ok) { + result = err( + new AnalysisCancelledError(request.operation, { + cause: signal.reason, + partialObservation: createAnalysisExecutionEvidence( + request.operation, + request.input, + result.value, + "observed", + ), }), ); + } + if (cleanup !== undefined) { + if (!result.ok) + return err(this.withCleanup(result.error, cleanup, request.operation)); + cleanup.retainPartialObservation( + createAnalysisExecutionEvidence( + request.operation, + request.input, + result.value, + "observed", + ), + ); + return err(cleanup); + } return result; } diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 734bee97ef..98c5fe9805 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -3,7 +3,7 @@ import { snapshotGdbCommandRecords } from "./GdbCommandRecords.js"; import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { isUtf8 } from "node:buffer"; -import { readFile, readlink, realpath, stat } from "node:fs/promises"; +import { readlink, realpath, stat } from "node:fs/promises"; import { AnalysisCapabilityUnavailableError, @@ -27,13 +27,13 @@ import { } from "../process/ProviderProcess.js"; import { closeOwnedDebuggerSessions, + readLinuxProcessStartTime, stopOwnedDebuggerProcess, type OwnedDebuggerDetachResult, type OwnedInferiorIdentity, type ProcessStartTimeReader, verifyOwnedInferiorsEnded, } from "../process/ownedDebuggerSession.js"; -import { parseLinuxProcessStat } from "../process/LinuxProcessStat.js"; const STARTUP_TIMEOUT_MS = 10_000; const COMMAND_TIMEOUT_MS = 120_000; @@ -440,12 +440,8 @@ const verifyGdbInferiorOutputSink = async ( throw new Error( "GDB cannot confirm live inferior output descriptors outside Linux /proc.", ); - const statPath = `/proc/${pid}/stat`; - const expectedPid = Number(pid); - const beforeStat = parseLinuxProcessStat(await readFile(statPath, "utf8")); - const before = - beforeStat?.pid === expectedPid ? beforeStat.startTime : undefined; - if (before === undefined) + const before = await readLinuxProcessStartTime(pid); + if (before === null) throw new Error("GDB could not establish the live inferior identity."); const ttyPath = await realpath(selectedTty); const tty = await stat(ttyPath); @@ -465,9 +461,7 @@ const verifyGdbInferiorOutputSink = async ( `GDB inferior descriptor ${descriptor} no longer points to the selected TTY.`, ); } - const afterStat = parseLinuxProcessStat(await readFile(statPath, "utf8")); - const after = - afterStat?.pid === expectedPid ? afterStat.startTime : undefined; + const after = await readLinuxProcessStartTime(pid); if (after !== before) throw new Error( "The GDB inferior identity changed during descriptor readback.", @@ -1795,7 +1789,7 @@ export class GdbSessionManager { pending.reject( new GdbProtocolFailureError(message, { - command: pending.command, + command: pending.evidenceCommand, mi: "", ...snapshotGdbCommandRecords(pending.lines), output_truncated: pending.outputTruncated, @@ -1862,7 +1856,12 @@ export class GdbSessionManager { ): ReturnType { session.cleanupStarted = true; if (session.stopAttempt !== undefined) return session.stopAttempt; - const attempt = this.#stopProcess(session, "GDB") + const attempt = Promise.resolve() + .then(() => this.#stopProcess(session, "GDB")) + .catch((cause: unknown) => ({ + status: "incomplete" as const, + reason: `Owned GDB shutdown failed: ${gdbErrorMessage(cause)}`, + })) .then((stopped) => { if (stopped.status !== "incomplete") { for (const failure of session.cleanupFailures) @@ -1977,7 +1976,7 @@ export class GdbSessionManager { clearTimeout(pending.timer); pending.reject( new GdbCommandProcessExitError({ - command: pending.command, + command: pending.evidenceCommand, mi: "", ...snapshotGdbCommandRecords(pending.lines), process_exit_observed: true, @@ -2086,64 +2085,43 @@ export class GdbSessionManager { "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", ], }); - void this.#stopDebuggerOnly(session).then( - (stopped) => { - if ( - stopped.status === "incomplete" && - operation === "start_gdb_session" - ) { - pending.reject( - new AnalysisTimeoutError(operation, this.#commandTimeoutMs, { - cause: timeout, - cleanup: { reason: stopped.reason, resources: [session.id] }, - partialObservation: evidence, - ...copiedTimeoutGuidance(timeout), - }), - ); - return; - } + void this.#stopDebuggerOnly(session).then((stopped) => { + if ( + stopped.status === "incomplete" && + operation === "start_gdb_session" + ) { pending.reject( - stopped.status === "incomplete" - ? new AnalysisCapabilityUnavailableError( - "gdb", - "gdb_console", - `GDB command output could not be correlated and its owned process could not be confirmed stopped: ${stopped.reason}`, - { - cause: timeout, - cleanup: { - reason: stopped.reason, - resources: [session.id], - }, - partialObservation: evidence, - }, - ) - : new GdbCommandTimeoutError( - partial, - evidence, - this.#commandTimeoutMs, - ), + new AnalysisTimeoutError(operation, this.#commandTimeoutMs, { + cause: timeout, + cleanup: { reason: stopped.reason, resources: [session.id] }, + partialObservation: evidence, + ...copiedTimeoutGuidance(timeout), + }), ); - }, - (cause: unknown) => - pending.reject( - new AnalysisCapabilityUnavailableError( - "gdb", - "gdb_console", - "GDB command output could not be correlated and owned process shutdown failed.", - { - cause: new AggregateError( - [timeout, cause], - "GDB timeout and cleanup both failed.", - ), - cleanup: { - reason: gdbErrorMessage(cause), - resources: [session.id], + return; + } + pending.reject( + stopped.status === "incomplete" + ? new AnalysisCapabilityUnavailableError( + "gdb", + "gdb_console", + `GDB command output could not be correlated and its owned process could not be confirmed stopped: ${stopped.reason}`, + { + cause: timeout, + cleanup: { + reason: stopped.reason, + resources: [session.id], + }, + partialObservation: evidence, }, - partialObservation: evidence, - }, - ), - ), - ); + ) + : new GdbCommandTimeoutError( + partial, + evidence, + this.#commandTimeoutMs, + ), + ); + }); }, this.#commandTimeoutMs); pending = { token, @@ -2199,54 +2177,34 @@ export class GdbSessionManager { session.pending.delete(token); session.protocolError = "A GDB MI command was cancelled; owned-session shutdown was requested because output correlation can no longer be guaranteed."; - void this.#stopDebuggerOnly(session).then( - (stopped) => { - if (stopped.status !== "incomplete") { - pending.reject( - new AnalysisCancelledError("gdb_console", { - cause: signal?.reason, - partialObservation: evidence, - }), - ); - return; - } - const cleanupFailure = this.#retainCleanupFailure( - session, - "gdb_console", - stopped.reason, - ); + void this.#stopDebuggerOnly(session).then((stopped) => { + if (stopped.status !== "incomplete") { pending.reject( new AnalysisCancelledError("gdb_console", { cause: signal?.reason, - cleanup: { - reason: stopped.reason, - resources: [session.id], - }, partialObservation: evidence, - }).bindCleanupDependencies([cleanupFailure], { - cleanupResources: [session.id], }), ); - }, - (cause: unknown) => { - const reason = gdbErrorMessage(cause); - const cleanupFailure = this.#retainCleanupFailure( - session, - "gdb_console", - reason, - { cause }, - ); - pending.reject( - new AnalysisCancelledError("gdb_console", { - cause: signal?.reason, - cleanup: { reason, resources: [session.id] }, - partialObservation: evidence, - }).bindCleanupDependencies([cleanupFailure], { - cleanupResources: [session.id], - }), - ); - }, - ); + return; + } + const cleanupFailure = this.#retainCleanupFailure( + session, + "gdb_console", + stopped.reason, + ); + pending.reject( + new AnalysisCancelledError("gdb_console", { + cause: signal?.reason, + cleanup: { + reason: stopped.reason, + resources: [session.id], + }, + partialObservation: evidence, + }).bindCleanupDependencies([cleanupFailure], { + cleanupResources: [session.id], + }), + ); + }); }; signal?.addEventListener("abort", abortListener, { once: true }); if (signal?.aborted) { diff --git a/src/ghidra/GhidraAnalysisSeeds.test.ts b/src/ghidra/GhidraAnalysisSeeds.test.ts index 226f69ca2c..a76b0003c6 100644 --- a/src/ghidra/GhidraAnalysisSeeds.test.ts +++ b/src/ghidra/GhidraAnalysisSeeds.test.ts @@ -2,6 +2,8 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { createHash } from "node:crypto"; import { mkdtemp, + mkdir, + open, readFile, realpath, rm, @@ -152,6 +154,70 @@ describe("Ghidra seed commitment", () => { expect(resolved.value.path).toBe(await realpath(path)); }); + it.skipIf(process.platform === "win32")( + "rejects an invalid file/.. traversal instead of reading a sibling seed file", + async () => { + const directory = await workspace(); + await writeFile(join(directory, "file"), "not a directory"); + await writeFile(join(directory, "seeds.tsv"), SEEDS); + const configured = `${directory}/file/../seeds.tsv`; + await expect(open(configured)).rejects.toMatchObject({ code: "ENOTDIR" }); + const resolved = await resolveGhidraAnalysisSeeds(configured, resources); + expect(resolved.ok).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "preserves symlink/.. lookup against a sibling decoy", + async () => { + const directory = await workspace(); + const selected = join(directory, "selected"); + await mkdir(join(selected, "child"), { recursive: true }); + await writeFile(join(selected, "seeds.tsv"), SEEDS); + await writeFile(join(directory, "seeds.tsv"), "0x1234\tcode\n"); + const link = join(directory, "link"); + await symlink(join(selected, "child"), link); + const configured = `${link}/../seeds.tsv`; + const resolved = await resolveGhidraAnalysisSeeds(configured, resources); + if (!resolved.ok || resolved.value === undefined) + throw new Error("Expected a commitment"); + expect(resolved.value.configured_path).toBe(configured); + expect(resolved.value.path).toBe( + await realpath(join(selected, "seeds.tsv")), + ); + const snapshot = await snapshotGhidraAnalysisSeeds( + resolved.value, + await workspace(), + resources, + ); + expect(await readFile(snapshot, "utf8")).toBe(CANONICAL); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a configured traversal that becomes invalid before snapshotting", + async () => { + const directory = await workspace(); + const child = join(directory, "child"); + await mkdir(child); + await writeFile(join(directory, "seeds.tsv"), SEEDS); + const configured = `${child}/../seeds.tsv`; + const resolved = await resolveGhidraAnalysisSeeds(configured, resources); + if (!resolved.ok || resolved.value === undefined) + throw new Error("Expected a commitment"); + await rm(child, { recursive: true }); + await writeFile(child, "not a directory"); + await expect(open(configured)).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect( + snapshotGhidraAnalysisSeeds( + resolved.value, + await workspace(), + resources, + ), + ).rejects.toMatchObject({ _tag: "ProviderAdapterError" }); + }, + ); + it("reports an unreadable seed file as a provider error", async () => { const resolved = await resolveGhidraAnalysisSeeds( join(await workspace(), "absent.tsv"), diff --git a/src/ghidra/GhidraAnalysisSeeds.ts b/src/ghidra/GhidraAnalysisSeeds.ts index 31d5c8ef6c..52d56a70fa 100644 --- a/src/ghidra/GhidraAnalysisSeeds.ts +++ b/src/ghidra/GhidraAnalysisSeeds.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, realpath } from "node:fs/promises"; +import { mkdir, realpath, stat } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; @@ -13,6 +13,7 @@ import { writeGhidraProfileArtifact, ghidraFileFailure, } from "./GhidraFileArtifacts.js"; +import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; import { err, ok, type Result } from "../domain/result.js"; /** Seed file format admitted by this adapter. */ @@ -164,6 +165,15 @@ const readSeeds = ( }, ); +/** Preserve native lookup semantics while allowing configured symbolic links. */ +const resolveSeedPath = async (configured: string): Promise => { + // Darwin realpath may collapse file/.. even though native lookup rejects it. + const selected = await stat(configured); + if (!selected.isFile()) + throw new NonRegularFileReadError(configured, selected.isDirectory()); + return realpath(configured); +}; + /** Resolve the configured seed file into an immutable profile commitment. */ export const resolveGhidraAnalysisSeeds = async ( configured: string | undefined, @@ -176,7 +186,7 @@ export const resolveGhidraAnalysisSeeds = async ( let entries: readonly SeedEntry[]; let path: string; try { - path = await realpath(configured); + path = await resolveSeedPath(configured); entries = await readSeeds(path, resources, signal); } catch (cause: unknown) { return err( @@ -215,7 +225,9 @@ export const snapshotGhidraAnalysisSeeds = async ( resources .run(async () => { ghidraSeedCommitmentSchema.parse(commitment); - if ((await realpath(commitment.configured_path)) !== commitment.path) + if ( + (await resolveSeedPath(commitment.configured_path)) !== commitment.path + ) throw new Error( "Ghidra seed file resolved path differs from explicit configuration", ); diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 4af1bf5121..6b7c4407c5 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -1,3 +1,4 @@ +import { ghidraAsciiLiteralCandidate } from "./GhidraLiteralSearch.js"; import { fileSnapshotPartialObservationSchema, type FileSnapshotPartialObservation, @@ -372,7 +373,10 @@ export class GhidraClient { return this.#parseSessionInfo(result.value); } - /** Execute one admitted operation through the serial per-Program queue. */ + /** Execute one admitted operation through the serial per-Program queue. + * Insensitive literal searches return conservative native candidates; the + * inventory adapter owns shared matching and retains these raw source rows. + */ async callTool( operation: GhidraOperation, parameters: Readonly>, @@ -384,12 +388,7 @@ export class GhidraClient { ): Promise> { const started = await this.start(options.signal, options.progress); if (!started.ok) return started; - return this.#requestQueue.run(operation, parameters, { - ...(options.signal === undefined ? {} : { signal: options.signal }), - ...(options.decompileTimeoutSeconds === undefined - ? {} - : { decompileTimeoutSeconds: options.decompileTimeoutSeconds }), - }); + return this.#callTool(operation, parameters, options); } /** Dispatch against the connected bridge without invoking startup or retry. */ @@ -399,10 +398,38 @@ export class GhidraClient { options: { readonly signal?: AbortSignal } = {}, ): Promise | undefined> { if (!this.isReady()) return Promise.resolve(undefined); + return this.#callTool(operation, parameters, options); + } + + async #callTool( + operation: GhidraOperation, + parameters: Readonly>, + options: { + readonly signal?: AbortSignal; + readonly decompileTimeoutSeconds?: number | null; + }, + ): Promise> { + const pattern = + (operation === "search_procedures" || operation === "search_strings") && + parameters.mode === "literal" && + parameters.case_sensitive === false && + typeof parameters.pattern === "string" + ? parameters.pattern + : undefined; return this.#requestQueue.run( operation, - parameters, - options.signal === undefined ? {} : { signal: options.signal }, + pattern !== undefined + ? { + ...parameters, + literal_ascii_candidate: ghidraAsciiLiteralCandidate(pattern), + } + : parameters, + { + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.decompileTimeoutSeconds === undefined + ? {} + : { decompileTimeoutSeconds: options.decompileTimeoutSeconds }), + }, ); } diff --git a/src/ghidra/GhidraJavaProbe.test.ts b/src/ghidra/GhidraJavaProbe.test.ts index 0257d1f0bf..8851facbca 100644 --- a/src/ghidra/GhidraJavaProbe.test.ts +++ b/src/ghidra/GhidraJavaProbe.test.ts @@ -1,3 +1,8 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; @@ -266,3 +271,81 @@ it("preserves scalar whitespace for architecture and bitness validation", () => }, }); }); + +it("preserves each prerequisite cleanup retry outcome until the same owner releases", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: null, + exitCode: null as number | null, + signalCode: null, + kill: () => false, + }); + let attempt = 0; + const outcomes = [ + { cleaned: false as const, reason: "initial observation denied" }, + { cleaned: false as const, reason: "retry permission denied" }, + { cleaned: false as const, reason: "retry identity unreadable" }, + { cleaned: true as const, signaled: false }, + ]; + const retained: Parameters[0][] = []; + const result = await probeGhidraJava( + "/selected/bin/java", + {}, + { + resourceOwner: { + retain: (owner) => { + retained.push(owner); + }, + run: (work) => work(), + }, + launcher: async () => { + setImmediate(() => { + child.stderr.end("java.version = 21\njava.home = /selected/jdk\n"); + child.stdout.end(); + child.exitCode = 0; + child.emit("exit", 0, null); + child.emit("close", 0, null); + }); + return { + process: child, + ownership: { + runId: "probe-retry", + leaderPid: 12345, + processGroupId: 12345, + }, + cleanup: async () => outcomes[attempt++]!, + }; + }, + }, + ); + if (result.ok || !(result.error instanceof AnalysisError)) + throw new Error("Expected the probe cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(retained).toHaveLength(1); + const failures: ProviderCleanupError[] = []; + for (const reason of [ + "retry permission denied", + "retry identity unreadable", + ]) { + const failure: unknown = await retained[0]! + .close() + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ProviderCleanupError); + if (!(failure instanceof ProviderCleanupError)) throw failure; + expect(failure.diagnostics).toMatchObject({ + reason, + process_cleanup: { reason }, + }); + expect(failure.cause).toHaveProperty( + "cleanupFailure", + "initial observation denied", + ); + expect(failure.cleanupIncomplete).toBe(true); + failures.push(failure); + } + await retained[0]!.close(); + expect(attempt).toBe(4); + expect(failures.every((failure) => !failure.cleanupIncomplete)).toBe(true); + expect(result.error.cleanupIncomplete).toBe(false); +}); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 107745bc29..6e1c99cedd 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -33,7 +33,6 @@ import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js"; import { cleanupOwnedProcessGroup, - cleanupWindowsProcessTree, type ProcessCleanupResult, type ProcessCleanupFailure, } from "../process/ProcessOwnership.js"; @@ -745,7 +744,10 @@ const cleanupStartedProcess = ( started.cleanup !== undefined ? started.cleanup() : platform === "win32" - ? cleanupWindowsProcessTree(started.ownership.leaderPid) + ? Promise.resolve({ + cleaned: false, + reason: "Windows Ghidra process has no owned cleanup capability", + }) : cleanupOwnedProcessGroup(started.ownership); /** Paths encoded into one bounded analyzeHeadless invocation. */ diff --git a/src/ghidra/GhidraLiteralSearch.test.ts b/src/ghidra/GhidraLiteralSearch.test.ts new file mode 100644 index 0000000000..94083ef3a3 --- /dev/null +++ b/src/ghidra/GhidraLiteralSearch.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { literalSubstringPredicate } from "../domain/analysisSearch.js"; +import { ghidraAsciiLiteralCandidate } from "./GhidraLiteralSearch.js"; + +describe("Ghidra ASCII literal candidates", () => { + it.each([ + ["ASCII.[A]", "ascii.[a]"], + ["ſK", "sk"], + ["İ", null], + ["ı", null], + ["Σ", null], + ["ß", null], + ["\ud800", null], + ["𐐀", null], + ])("derives %j as %j", (pattern, expected) => { + expect(ghidraAsciiLiteralCandidate(pattern!)).toBe(expected); + }); + + it("retains every matching ASCII candidate for Unicode edge cases", () => { + const patterns = [ + "s", + "ſ", + "k", + "K", + "i", + "İ", + "ı", + "σ", + "ς", + "Σ", + "ß", + "ss", + "𐐀", + "\ud800", + "ſ.a", + "a\n", + ]; + const values = [ + "S", + "s", + "K", + "k", + "i", + "I", + "ss", + "SS", + "prefixS.aSuffix", + "A\n", + "unrelated", + ]; + for (const pattern of patterns) { + const candidate = ghidraAsciiLiteralCandidate(pattern); + const matches = literalSubstringPredicate(pattern, false); + for (const value of values) { + expect( + candidate !== null && value.toLowerCase().includes(candidate), + ).toBe(matches(value)); + } + } + }); +}); diff --git a/src/ghidra/GhidraLiteralSearch.ts b/src/ghidra/GhidraLiteralSearch.ts new file mode 100644 index 0000000000..5d1218a214 --- /dev/null +++ b/src/ghidra/GhidraLiteralSearch.ts @@ -0,0 +1,50 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import { literalSubstringPredicate } from "../domain/analysisSearch.js"; + +/** ASCII-only candidates are exact; every non-ASCII source value stays eligible. + * Node owns Unicode folding, so the Java runtime's Unicode version cannot omit + * a match. Null means no ASCII-only value can match this query. + */ +export const ghidraAsciiLiteralCandidate = (pattern: string): string | null => { + const equivalents = new Map(); + let candidate = ""; + for (const character of pattern) { + if (character.charCodeAt(0) < 128) { + candidate += character.toLowerCase(); + continue; + } + let equivalent = equivalents.get(character); + if (equivalent === undefined) { + const matches = literalSubstringPredicate(character, false); + equivalent = null; + for (let ascii = 0; ascii < 128; ascii++) { + const value = String.fromCharCode(ascii); + if (matches(value)) { + equivalent = value.toLowerCase(); + break; + } + } + equivalents.set(character, equivalent); + } + if (equivalent === null) return null; + candidate += equivalent; + } + return candidate; +}; + +/** Leave invalid rows present so the inventory owner retains its output error. */ +export const filterGhidraLiteralCandidates = ( + value: JsonValue, + pattern: string, +): JsonValue => { + if (!Array.isArray(value)) return value; + const matches = literalSubstringPredicate(pattern, false); + return value.filter( + (item) => + item === null || + typeof item !== "object" || + Array.isArray(item) || + typeof item.value !== "string" || + matches(item.value), + ); +}; diff --git a/src/ghidra/GhidraProjectSnapshot.ts b/src/ghidra/GhidraProjectSnapshot.ts index 42f9c97294..64105951db 100644 --- a/src/ghidra/GhidraProjectSnapshot.ts +++ b/src/ghidra/GhidraProjectSnapshot.ts @@ -63,18 +63,24 @@ const selectionFailure = ( new ConfigurationError(message, { settings: [{ setting, constraint: message }], }); -const entriesFor = (name: string) => [`${name}.gpr`, `${name}.rep`]; +const entriesFor = (name: string) => [`${name}.gpr`, `${name}.rep`] as const; + +const absentProjectEntry = (cause: unknown): undefined => { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return undefined; + throw cause; +}; const validateProject = async (root: string, name: string): Promise => { - if (!(await lstat(root)).isDirectory()) + if (!(await lstat(root).catch(absentProjectEntry))?.isDirectory()) throw selectionFailure( `Existing Ghidra project directory does not exist: ${root}`, ); - const [marker, data] = await Promise.all( - entriesFor(name).map((entry) => - lstat(join(root, entry)).catch(() => undefined), - ), - ); + // Inspect both entries even when one is missing; a physical failure of the + // other entry must not be mistaken for an invalid project selection. + const [markerName, dataName] = entriesFor(name); + const marker = await lstat(join(root, markerName)).catch(absentProjectEntry); + const data = await lstat(join(root, dataName)).catch(absentProjectEntry); if (!marker?.isFile() || !data?.isDirectory()) throw selectionFailure( `Existing Ghidra project requires a regular ${name}.gpr marker and ${name}.rep directory; restore saved .grez archives before selecting the project: ${root}`, @@ -167,6 +173,13 @@ export const resolveGhidraProject = async ( "resolve_analysis_profile", async () => { assertArtifactReadActive(signal); + // Admit the caller's exact marker before canonicalizing its parent. + // Darwin realpath(file/..) can succeed despite native lookup rejecting + // that traversal; hashing the canonical sibling would select a new input. + if (!(await lstat(selection.path)).isFile()) + throw selectionFailure( + `Existing Ghidra project marker must be a regular file: ${selection.path}`, + ); const projectName = basename(selection.path, ".gpr"); const projectPath = await realpath(dirname(selection.path)); const program = ghidraProgramNameSchema.safeParse( @@ -226,10 +239,10 @@ export const copyExistingGhidraProject = async ( resources, } = options; assertArtifactReadActive(signal); - const source = await realpath(sourcePath).catch(() => undefined); - const runtime = await realpath(dirname(destinationRoot)).catch(() => - resolve(dirname(destinationRoot)), - ); + const source = await realpath(sourcePath).catch(absentProjectEntry); + const runtime = + (await realpath(dirname(destinationRoot)).catch(absentProjectEntry)) ?? + resolve(dirname(destinationRoot)); if ( source !== undefined && entriesFor(projectName).some((entry) => diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 95920306ac..911f280a7b 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -4,7 +4,7 @@ import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js" import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { resolvedAnalysisError } from "../domain/analysisErrorBase.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; -import { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; +import { ProviderInspectionScope } from "../application/ProviderInspectionScope.js"; import { ok as javaProbeOk, err, ok } from "../domain/result.js"; import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js"; @@ -4691,3 +4691,41 @@ it("keeps saved project reports as shared historical Evidence without applying f first.value.sourceEvidence?.[0], ); }); + +it.each(["search_procedures", "search_strings"] as const)( + "%s applies shared folding after validation and preserves native source rows", + async (operation) => { + const rows = [ + { address: "0x1000", value: "ſource" }, + { address: "0x1001", value: "SOURCE" }, + { address: "0x1002", value: "İource" }, + { address: "0x1003", value: "unrelated" }, + ]; + const ghidra = provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(jsonValueSchema.parse(rows))), + close: () => Promise.resolve(ok(null)), + })); + const client = await createElfClient(ghidra); + try { + const result = await client.execute(operation, { pattern: "source" }); + if (!result.ok) throw result.error; + expect(result.value.result).toEqual(rows.slice(0, 2)); + expect(result.value.rawResult).toEqual(rows); + const regex = await client.execute(operation, { + pattern: "source", + mode: "regex", + }); + if (!regex.ok) throw regex.error; + expect(regex.value.result).toEqual(rows); + const sensitive = await client.execute(operation, { + pattern: "source", + case_sensitive: true, + }); + if (!sensitive.ok) throw sensitive.error; + expect(sensitive.value.result).toEqual(rows); + } finally { + await client.close(); + } + }, +); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index cfccece9c3..e032ba2683 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -6,7 +6,7 @@ import { import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { resolveGhidraProject } from "./GhidraProjectSnapshot.js"; import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js"; -import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; +import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index b9b32a42c3..00d31f8eb0 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -1,3 +1,4 @@ +import { filterGhidraLiteralCandidates } from "./GhidraLiteralSearch.js"; import { ghidraProjectHistoryEvidence } from "./GhidraProjectHistory.js"; import type { Evidence, EvidenceLocation } from "../domain/evidence.js"; import { ghidraProjectCommitmentSchema } from "./GhidraProjectSnapshot.js"; @@ -787,6 +788,18 @@ export const createGhidraProviderClient = (input: { : parseGhidraInventoryResult(operation, called.value); if (!result.ok) return result; let { value: normalized, ...observation } = result.value; + if ( + (operation === "search_procedures" || operation === "search_strings") && + input.value.mode === "literal" && + input.value.case_sensitive === false && + typeof input.value.pattern === "string" + ) { + normalized = filterGhidraLiteralCandidates( + normalized, + input.value.pattern, + ); + observation = { ...observation, rawResult: called.value }; + } if ( operation === "inspect_native_data_type" && normalized !== null && diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 4595a7518d..4ad7864d67 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -258,9 +258,11 @@ const inlineDataEnd = ( const observedText = (bytes: Buffer): string | null => { try { - return observedUtf8(bytes, "Go build string"); + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); } catch (cause) { - if (cause instanceof BinaryFormatFailure) return null; + if (cause instanceof TypeError) return null; throw cause; } }; @@ -390,17 +392,3 @@ const readOptionalModuleString = ( isZeroFilledSpan(mappings, zeroFills, address, (pointerBits / 8) * 2) ? { bytes: Buffer.alloc(0), location: { offset: pointerField, bytes: 0 } } : readPointerString(reader, mappings, zeroFills, address, pointerBits); - -/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */ -export const observedUtf8 = (bytes: Buffer, label: string): string => { - try { - return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - bytes, - ); - } catch (cause) { - throw new BinaryFormatFailure( - "malformed", - `${label} contains malformed UTF-8: ${String(cause)}`, - ); - } -}; diff --git a/src/gpu/GpuElfKernels.ts b/src/gpu/GpuElfKernels.ts index 1e4e1c5976..9bf8f4acc3 100644 --- a/src/gpu/GpuElfKernels.ts +++ b/src/gpu/GpuElfKernels.ts @@ -369,6 +369,7 @@ const cudaKernels = async ( export interface AmdElfMetadata { readonly target: string | null; readonly targetIdentity: AmdGpuTargetIdentity | null; + readonly targetMetadata: import("../domain/gpu/gpuCodeObjects.js").GpuObject["amd_target_metadata"]; readonly limitations: string[]; readonly notes: { section: NativeSection; @@ -388,12 +389,18 @@ export const readAmdElfMetadata = async ( { kind: "incomplete-amd-metadata"; cause: unknown; partial: AmdElfMetadata } > > => { - const targets = new Set(), - limitations: string[] = []; + const limitations: string[] = []; const notes: AmdElfMetadata["notes"] = []; - let processor: string | null = null; let retainedBytes = 0; + let unsupportedNotes = false; + let targetMetadata: AmdElfMetadata["targetMetadata"] = null; const observed = (): AmdElfMetadata => { + const targets = new Set( + (targetMetadata?.notes ?? []).flatMap(({ target }) => + target === null ? [] : [target], + ), + ); + const processor = targetMetadata?.processor ?? null; const noteTarget = targets.size === 1 ? [...targets][0]! : null; const noteIdentity = noteTarget === null ? null : readAmdGpuNoteTarget(noteTarget); @@ -402,12 +409,17 @@ export const readAmdElfMetadata = async ( processor !== null && noteIdentity !== null && processor !== noteIdentity.processor; - const conflict = noteConflict || processorConflict; - const limitation = noteConflict - ? "AMDGPU notes declare conflicting processor targets; processor identity is unknown" - : processorConflict - ? "AMDGPU ELF processor flags conflict with target note metadata; processor identity is unknown" - : null; + const invalidTarget = (targetMetadata?.notes ?? []).some( + ({ target_state }) => target_state === "invalid", + ); + const conflict = noteConflict || processorConflict || invalidTarget; + const limitation = invalidTarget + ? "AMDGPU note target is not a string; processor identity is unknown" + : noteConflict + ? "AMDGPU notes declare conflicting processor targets; processor identity is unknown" + : processorConflict + ? "AMDGPU ELF processor flags conflict with target note metadata; processor identity is unknown" + : null; if (limitation !== null && !limitations.includes(limitation)) limitations.push(limitation); const target = conflict ? null : (noteTarget ?? processor); @@ -418,12 +430,25 @@ export const readAmdElfMetadata = async ( (noteTarget === null && processor !== null ? { key: processor, processor } : null)); - return { target, targetIdentity, limitations, notes }; + return { target, targetIdentity, targetMetadata, limitations, notes }; }; try { if (directory.machine !== 224) rejectGpu("AMDGPU metadata requires an AMDGPU ELF machine"); - processor = readAmdGpuElfProcessor(directory.flags); + targetMetadata = { + flags: `0x${directory.flags.toString(16).padStart(8, "0")}`, + processor: readAmdGpuElfProcessor(directory.flags), + header_location: { + container_id: source.containerId, + offset: 0, + size: directory.bits === 64 ? 64 : 52, + section_index: null, + section_name: null, + }, + note_coverage: "partial", + notes: [], + }; + for (const section of directory.sections) { if (section.type !== 7) continue; const sectionSize = gpuCount(section.size); @@ -458,19 +483,36 @@ export const readAmdElfMetadata = async ( const metadata = amdRecord( readAmdMetadata(bytes.subarray(desc, desc + size)), ); + if (metadata === null) rejectGpu("AMDGPU metadata is not a map"); + const target = amdString(metadata?.["amdhsa.target"]); + const target_state = !Object.hasOwn(metadata!, "amdhsa.target") + ? "absent" + : target === null + ? "invalid" + : "declared"; + if (target_state === "invalid") unsupportedNotes = true; + targetMetadata.notes.push({ + target, + target_state, + location: site(source, section, desc, size), + }); const kernels = metadata?.["amdhsa.kernels"]; if (!Array.isArray(kernels)) return rejectGpu("AMDGPU metadata has no valid kernel array"); - const target = amdString(metadata!["amdhsa.target"]); - if (target !== null) targets.add(target); notes.push({ section, noteOffset: p, desc, size, kernels }); - } else if (vendor === "AMD" && (type === 10 || type === 11)) + } else if (vendor === "AMD" && (type === 10 || type === 11)) { + unsupportedNotes = true; limitations.push( "Legacy AMDGPU YAML metadata is outside MessagePack coverage", ); + } p = end; } } + targetMetadata.note_coverage = + unsupportedNotes || directory.sections.length === 0 + ? "partial" + : "complete"; if (notes.length === 0) throw new BinaryFormatFailure( "unsupported", @@ -491,6 +533,7 @@ const amdKernels = ( onTarget?.({ value: prepared.target, identity: prepared.targetIdentity, + amdMetadata: prepared.targetMetadata, scope: "amdgpu", }); for (const { diff --git a/src/gpu/GpuInventory.ts b/src/gpu/GpuInventory.ts index f3286d87ec..f7870c095c 100644 --- a/src/gpu/GpuInventory.ts +++ b/src/gpu/GpuInventory.ts @@ -455,11 +455,13 @@ export class GpuInventory { this.#retain({ payload_target: payload.value, cuda_target_header: payload.cudaHeader ?? null, + amd_target_metadata: payload.amdMetadata ?? null, target_consistency: state, target: selectedTarget ?? payload.value, }); row.payload_target = payload.value; row.cuda_target_header = payload.cudaHeader ?? null; + row.amd_target_metadata = payload.amdMetadata ?? null; row.target_consistency = state; if (selectedTarget === null) row.target = payload.value; if (row.target === null) this.discovery = "partial"; @@ -513,6 +515,7 @@ export class GpuInventory { this.#target(row, { value: prepared.target, identity: prepared.targetIdentity, + amdMetadata: prepared.targetMetadata, scope: "amdgpu", }); if (!inspected.ok) { diff --git a/src/gpu/GpuObject.ts b/src/gpu/GpuObject.ts index 84aeb98947..75935fbd74 100644 --- a/src/gpu/GpuObject.ts +++ b/src/gpu/GpuObject.ts @@ -3,6 +3,7 @@ import type { GpuSource } from "./GpuSource.js"; import type { AmdGpuTargetIdentity } from "./AmdGpuTarget.js"; export type GpuPayloadTarget = { readonly value: string | null; + readonly amdMetadata?: GpuObject["amd_target_metadata"]; readonly cudaHeader?: GpuObject["cuda_target_header"]; readonly limitation?: string; } & ( @@ -38,6 +39,7 @@ export const gpuObject = ( target: null, payload_target: null, cuda_target_header: null, + amd_target_metadata: null, target_consistency: "unexamined", bundle_id: null, compression: "none", diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 9f0afb780b..36a1c5c80c 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -289,8 +289,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { const leaseResult = await this.#acquireLaunchLease(session.runId); if (!leaseResult.ok) return leaseResult; const lease = leaseResult.value; - let leaseTransferred = false; - try { + const outcome = await (async () => { let loadedPath = this.options.targetPath; const preparedImage = this.options.preparedImage; const sourceSha256 = @@ -354,11 +353,33 @@ export class HopperApplicationLauncher implements BridgeLauncher { ownsProcessLifetime, lease, }); - leaseTransferred = launched.ok; return launched; - } finally { - if (!leaseTransferred) await lease?.release(); - } + })().catch((cause: unknown) => err(new HopperStartError({ cause }))); + if (outcome.ok || lease === undefined) return outcome; + const released = await this.#probeResources.release({ + kind: "lease", + resource: lease.socketPath ?? session.directory, + lease, + }); + if (released.kind === "released") return outcome; + const cleanup = this.#probeResources.cleanupFailure({ kind: "completed" }, [ + released, + ]); + const leaseFailure = new HopperStartError({ + cause: cleanup, + cleanup: cleanup.cleanup, + }); + bindRetainedArtifactCleanup(leaseFailure, cleanup); + const failure = new HopperStartError({ + cause: cleanup, + cleanupPrimary: outcome.error, + cleanup: cleanup.cleanup, + ...(outcome.error.userMessage === undefined + ? {} + : { userMessage: outcome.error.userMessage }), + }); + failure.bindCleanupDependencies([outcome.error, leaseFailure]); + return err(failure); } async #launchPreparedTarget(input: { @@ -488,8 +509,8 @@ export class HopperApplicationLauncher implements BridgeLauncher { return err(hopperLaunchFailure(cause, signal)); } finally { // Remove the token-bearing bootstrap file on every non-success path. - // Only the launched process needs the file; all error returns, early - // cancellations, and thrown exceptions must not leave it on disk. + // This is best effort: the caller owns session.directory and performs + // authoritative private-runtime cleanup, retaining it when removal fails. if (!launched) await rm(bootstrapPath, { force: true }).catch(() => undefined); } @@ -507,7 +528,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { ? await ( this.dependencies.acquireApplicationLease ?? acquireLinuxHopperApplicationLease - )({ runId }) + )({ runId, resources: this.#probeResources }) : await ( this.dependencies.acquireTargetLease ?? acquireHopperTargetLease )({ @@ -515,6 +536,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { targetKind: this.options.targetKind, loaderArgs: this.options.loaderArgs, runId, + resources: this.#probeResources, }); if (!acquisition.acquired) return err( @@ -556,7 +578,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { await chmod(bootstrapPath, 0o600); return ok(bootstrapPath); } catch (cause: unknown) { - // Clean up any partially written bootstrap file that may contain the token. + // Best effort; the caller also owns cleanup of the private runtime root. await rm(bootstrapPath, { force: true }).catch(() => undefined); return err(new HopperStartError({ cause })); } diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 5c4c288391..050ac35a2e 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -25,6 +25,7 @@ import { validateFatSliceIdentity, type FatSliceDeclaration, } from "../domain/apple/machoContainer.js"; +import { MACHO_CPU_TYPES } from "../domain/apple/machoTargetSelection.js"; import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js"; import { HopperCancelledError, @@ -366,7 +367,6 @@ const inspectHopperMachOImage = async ( ) return invalid("Hopper cannot select the admitted Mach-O architecture"); const architecture = target.architecture; - const cpuTypes = { x86: 7, x86_64: 0x1000007, arm: 12, arm64: 0x100000c }; const matches = []; for (const slice of parsed.slices) { if (signal?.aborted) @@ -393,7 +393,7 @@ const inspectHopperMachOImage = async ( ); const identityError = validateFatSliceIdentity(slice, identity); if (identityError !== null) return invalid(identityError); - if (slice.cpuType === cpuTypes[architecture]) matches.push(slice); + if (slice.cpuType === MACHO_CPU_TYPES[architecture]) matches.push(slice); } if (findOverlappingFatSlices(parsed.slices) !== null) return invalid("FAT slices overlap"); diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 7dc9e464ea..2f6d58010c 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -1,3 +1,4 @@ +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { fileURLToPath } from "node:url"; @@ -25,15 +26,9 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { err, ok } from "../domain/result.js"; -import { - AnalysisCleanupLease, - AnalysisCleanupLeaseGroup, -} from "../artifacts/AnalysisCleanupLease.js"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { - ArtifactResourceRetryFailure, - ArtifactResourceScope, -} from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -229,8 +224,7 @@ export class HopperProvider implements AnalysisProviderCandidate { { reason: cause instanceof Error ? cause.message : String(cause) }, { cause, operation: "close_owned_resources" }, ); - if (cause instanceof ArtifactResourceRetryFailure) - new AnalysisCleanupLeaseGroup(failure, cause.cleanupLeases); + bindArtifactFileCleanup(cause, failure); return err(failure); } } @@ -508,13 +502,7 @@ const hopperSourceCleanupError = ( cause: ArtifactReaderFailure, operation: string, ): AnalysisError => { - const leases = [ - ...new Set( - cause instanceof ArtifactResourceRetryFailure - ? cause.cleanupLeases - : (cause.cleanupLeases ?? []), - ), - ]; + const leases = cause.cleanupLeases ?? []; const failure = new ProviderCleanupError( HOPPER_PROVIDER_IDENTITY.id, cause.cleanup?.resources ?? [ @@ -523,6 +511,6 @@ const hopperSourceCleanupError = ( { reason: cause.cleanup?.reason ?? cause.message }, { operation, cause }, ); - if (leases.length > 0) new AnalysisCleanupLeaseGroup(failure, leases); + bindArtifactFileCleanup(cause, failure); return failure; }; diff --git a/src/hopper/HopperRegexSearch.test.ts b/src/hopper/HopperRegexSearch.test.ts index 2a3b4c6ad6..ea1b3abc58 100644 --- a/src/hopper/HopperRegexSearch.test.ts +++ b/src/hopper/HopperRegexSearch.test.ts @@ -8,6 +8,10 @@ import { HopperProcessError, } from "../domain/hopperErrors.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { parseConfig } from "../config/parseConfig.js"; +import { silentLogger } from "../logger.js"; +import { HopperClient } from "./HopperClient.js"; +import { HopperProvider } from "./HopperProvider.js"; const regexMatch = vi.hoisted(() => ({ afterSettle: undefined as (() => void) | undefined, @@ -27,6 +31,103 @@ vi.mock("../process/RegexMatcher.js", async (importOriginal) => { }; }); +it.each(["search_strings", "search_procedures"] as const)( + "preserves selected source metadata through the provider's %s regex boundary", + async (operation) => { + const config = parseConfig({}); + if (!config.ok) throw config.error; + const metadata = + operation === "search_strings" + ? { + provider_value: "short display", + string: { + encoding: "utf-8", + encoding_status: "observed", + termination: "present_or_not_required", + byte_length: 7, + }, + } + : { + procedure: { external: false, thunk: true, thunk_target: "0x2" }, + }; + const selected = { + address: "0x1", + value: "matched", + ...metadata, + provider_metadata: { empty: "", absent: null, zero: 0, flag: false }, + }; + const source = [ + { address: "0x1", value: "different" }, + selected, + { ...selected, value: "matched alternative" }, + ]; + const callTool = vi + .spyOn(HopperClient.prototype, "callTool") + .mockResolvedValue(ok(source)); + const provider = new HopperProvider( + config.value, + silentLogger, + {}, + "linux", + ); + const client = provider.createClient({ + path: "/tmp/fixture.hop", + sha256: "a".repeat(64), + kind: "database", + format: "analysis-database", + }); + try { + const result = await client.execute(operation, { + pattern: "^matched$", + mode: "regex", + case_sensitive: true, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.result).toEqual([selected]); + expect(callTool).toHaveBeenCalledOnce(); + expect(callTool.mock.calls[0]?.[0]).toBe( + operation === "search_strings" ? "list_strings" : "list_procedures", + ); + expect(source[1]).toEqual(selected); + } finally { + await client.close(); + callTool.mockRestore(); + } + }, +); + +it.each(["search_strings", "search_procedures"] as const)( + "rejects malformed canonical metadata in the %s inventory", + async (operation) => { + const search = new HopperRegexSearch({ + callTool: async () => + ok([ + { + address: "0x1", + value: "matched", + ...(operation === "search_strings" + ? { string: { encoding: false } } + : { procedure: { external: "unknown" } }), + }, + ]), + }); + try { + expect( + await search.execute(operation, { + pattern: "matched", + mode: "regex", + case_sensitive: true, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError", operation }, + }); + } finally { + await search.close(); + } + }, +); + it.each( (["search_strings", "search_procedures"] as const).flatMap((operation) => (["remote", "capability", "timeout", "process"] as const).map((family) => ({ diff --git a/src/hopper/HopperRegexSearch.ts b/src/hopper/HopperRegexSearch.ts index b49fac82dc..f4828eb6e3 100644 --- a/src/hopper/HopperRegexSearch.ts +++ b/src/hopper/HopperRegexSearch.ts @@ -6,7 +6,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; -import { analysisStringSchema } from "../domain/native/nativeString.js"; +import { nativeInventoryRowsSchemas } from "../domain/native/nativeInventory.js"; import { HopperRemoteError } from "../domain/hopperErrors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -111,8 +111,13 @@ export class HopperRegexSearch { return err( analysisClientFailureForOperation(inventory.error, "hopper", operation), ); - const parsed = z.array(analysisStringSchema).safeParse(inventory.value); - if (!parsed.success) + const rows = inventory.value; + const schema = + operation === "search_strings" + ? nativeInventoryRowsSchemas.strings + : nativeInventoryRowsSchemas.names; + const parsed = schema.safeParse(rows); + if (!parsed.success || !Array.isArray(rows)) return err( new AnalysisOutputError( operation, @@ -150,6 +155,6 @@ export class HopperRegexSearch { new AnalysisCancelledError(operation, { cause: options.signal.reason }), ); const selected = new Set(matched.value); - return ok(parsed.data.filter((_, index) => selected.has(index))); + return ok(rows.filter((_, index) => selected.has(index))); } } diff --git a/src/hopper/LinuxPrivateDisplayProbe.ts b/src/hopper/LinuxPrivateDisplayProbe.ts index cd1d7dd371..e40e08745b 100644 --- a/src/hopper/LinuxPrivateDisplayProbe.ts +++ b/src/hopper/LinuxPrivateDisplayProbe.ts @@ -344,21 +344,9 @@ const waitForProbeOutputClose = async ( signal: AbortSignal | undefined, ): Promise<"exit" | "timeout" | "cancelled"> => { if (signal?.aborted === true) return "cancelled"; - let releaseAbort: (() => void) | undefined; - const aborted = new Promise<"cancelled">((resolve) => { - if (signal === undefined) return; - const listener = (): void => resolve("cancelled"); - signal.addEventListener("abort", listener, { once: true }); - releaseAbort = () => signal.removeEventListener("abort", listener); - }); - try { - return await Promise.race([ - supervisor - .waitForOutputClose(Math.max(1, timeoutMs)) - .then((closed): "exit" | "timeout" => (closed ? "exit" : "timeout")), - aborted, - ]); - } finally { - releaseAbort?.(); - } + const closed = await supervisor.waitForOutputClose( + Math.max(1, timeoutMs), + signal, + ); + return closed ? "exit" : isAborted(signal) ? "cancelled" : "timeout"; }; diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index ec3e70f103..e8194cbb5e 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -26,7 +26,7 @@ import type { RetainedResourceScope, AnalysisProfileResolution, } from "../application/AnalysisProvider.js"; -import { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; +import { ProviderInspectionScope } from "../application/ProviderInspectionScope.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; const resourceScope: RetainedResourceScope = { @@ -706,23 +706,28 @@ describe("native IDA callee lookup batches", () => { }); }); +const nativeLiteralValues = [ + "A.B", + "axb", + "Kelvin", + "İstanbul", + "σ", + "ς", + "literal[bracket", + "a\\b", + "🧪", + "Σ", + "ΟΣ", + "ος", + "S", + "s", + "ſ", +]; + describe("native IDA literal matching", () => { it("retains literal, Unicode, case, and regex semantics while narrowing native candidates", async () => { const { client, producer } = await fixture(); - const values = [ - "A.B", - "axb", - "Kelvin", - "İstanbul", - "σ", - "ς", - "literal[bracket", - "a\\b", - "🧪", - "Σ", - "ΟΣ", - "ος", - ]; + const values = nativeLiteralValues; producer.overrideCall = (name, args) => { if (name !== "find_regex") return undefined; if (typeof args.pattern !== "string") @@ -743,6 +748,12 @@ describe("native IDA literal matching", () => { }; }; const cases = [ + { + pattern: "ſ", + mode: "literal", + case_sensitive: false, + expected: ["İstanbul", "S", "s", "ſ"], + }, { pattern: "a.b", mode: "literal", @@ -833,7 +844,10 @@ describe("native IDA literal matching", () => { .slice(before) .filter(({ name }) => name === "find_regex"); expect(reads).toHaveLength(1); - if (query.mode === "regex" || query.pattern === "\ud800") + const unfiltered = query.mode === "regex" || query.pattern === "\ud800"; + const unicodeFold = + !query.case_sensitive && /[\u0080-\u{10ffff}]/u.test(query.pattern); + if (unfiltered || unicodeFold) expect(reads[0]?.args.pattern).toBe("[\\s\\S]*"); else expect(reads[0]?.args.pattern).not.toBe("[\\s\\S]*"); } diff --git a/src/ida/IdaNativeProtocol.ts b/src/ida/IdaNativeProtocol.ts index cfee5d0ef0..1c0f91c4a0 100644 --- a/src/ida/IdaNativeProtocol.ts +++ b/src/ida/IdaNativeProtocol.ts @@ -83,15 +83,18 @@ export const nativeXrefsSchema = z .length(1); /** - * Native regexes fold bytes, while REA's insensitive literal search folds - * Unicode strings. Include every non-ASCII value as a candidate, then apply - * REA's exact predicate. Ill-formed UTF-16 cannot cross upstream JSON/UTF-8. + * Native regexes fold bytes, while REA's insensitive literal search uses + * ECMAScript Unicode simple case folding. Non-ASCII patterns can match ASCII + * values (for example long s matches S), so leave those queries unfiltered. + * For ASCII patterns include every non-ASCII value, then apply REA's predicate. + * Ill-formed UTF-16 cannot cross upstream JSON/UTF-8. */ export const nativeLiteralSearchPattern = ( pattern: string, caseSensitive: boolean, ): string | undefined => { if (/[\uD800-\uDFFF]/u.test(pattern)) return undefined; + if (!caseSensitive && /[\u0080-\u{10ffff}]/u.test(pattern)) return undefined; let literal = (caseSensitive ? pattern : pattern.toLowerCase()).replace( /[.*+?^${}()|[\]\\]/gu, "\\$&", diff --git a/src/ida/IdaOperationRunner.test.ts b/src/ida/IdaOperationRunner.test.ts index aacd1a9553..bd932e8425 100644 --- a/src/ida/IdaOperationRunner.test.ts +++ b/src/ida/IdaOperationRunner.test.ts @@ -188,3 +188,47 @@ it.each(["legacy", "modern", "native"] as const)( } }, ); + +it.each([ + ["legacy", "functions"], + ["modern", "functions"], + ["native", "functions"], + ["legacy", "strings"], + ["modern", "strings"], + ["native", "strings"], +] as const)( + "%s %s inventory rejects a repeated or backward offset before another request", + async (protocol, inventory) => { + const { root, target } = await createIdaTarget(); + const producer = new RecordingIdaMcp(target); + try { + for (const next of [2, 1]) { + let pages = 0; + producer.overrideCall = (tool) => { + if ( + ![ + "list_functions", + "list_funcs", + "list_strings", + "find_regex", + ].includes(tool) + ) + return undefined; + const nextOffset = pages++ === 0 ? 2 : next; + if (tool === "find_regex") + return { matches: [], cursor: { next: nextOffset } }; + const page = { data: [], next_offset: nextOffset }; + return tool === "list_funcs" ? [page] : page; + }; + const runner = new IdaOperationRunner(producer, protocol); + await expect(runner[inventory]()).rejects.toMatchObject({ + _tag: "AnalysisProtocolError", + }); + expect(pages).toBe(2); + } + } finally { + await producer.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index a447057362..41df1f2741 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -54,6 +54,7 @@ import { modernPageSchema, modernXrefsSchema, nextOffset, + nextPageOffset, surveySegmentsSchema, type IdaDisassembly, type IdaFunction, @@ -160,11 +161,7 @@ export class IdaOperationRunner { if (value === undefined) throw new AnalysisProtocolError("IDA MCP omitted its function page."); result.push(...value.data.map((fn) => idaFunctionSchema.parse(fn))); - offset = value.next_offset; - if (offset !== null && offset <= current) - throw new AnalysisProtocolError( - "IDA MCP function pagination did not advance.", - ); + offset = nextPageOffset(value.next_offset, current); } return result; } @@ -349,7 +346,7 @@ export class IdaOperationRunner { const s = legacyStringSchema.parse(item); result.push({ address: s.address, value: s.string }); } - offset = page.next_offset; + offset = nextPageOffset(page.next_offset, current); } else { const page = idaStringsPageSchema.parse( await this.call("find_regex", { @@ -370,10 +367,6 @@ export class IdaOperationRunner { ); offset = nextOffset(page.cursor, current); } - if (offset !== null && offset <= current) - throw new AnalysisProtocolError( - "IDA MCP string pagination did not advance.", - ); } return result; } diff --git a/src/ida/IdaProtocolValues.ts b/src/ida/IdaProtocolValues.ts index 7faf6915b1..509c2cce9d 100644 --- a/src/ida/IdaProtocolValues.ts +++ b/src/ida/IdaProtocolValues.ts @@ -61,30 +61,28 @@ export const cursorSchema = z.union([ z.object({ next: z.int().nonnegative() }), z.object({ done: z.literal(true) }), ]); -export const nextOffset = ( - cursor: z.infer, +/** Both legacy pages and modern cursors must finish or advance strictly. */ +export const nextPageOffset = ( + next: number | null, current: number, ): number | null => { - if ("done" in cursor) return null; - if (cursor.next <= current) + if (next !== null && next <= current) throw new AnalysisProtocolError( "IDA MCP pagination did not advance its offset.", ); - return cursor.next; + return next; }; +export const nextOffset = ( + cursor: z.infer, + current: number, +): number | null => + nextPageOffset("done" in cursor ? null : cursor.next, current); export const legacyPageSchema = z.object({ data: z.array(jsonValueSchema), next_offset: z.int().nonnegative().nullable(), }); -export const modernPageSchema = z - .array( - z.object({ - data: z.array(jsonValueSchema), - next_offset: z.int().nonnegative().nullable(), - }), - ) - .length(1); +export const modernPageSchema = z.array(legacyPageSchema).length(1); const modernLookupRowSchema = z.object({ query: z.string(), diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index a7a6c4c9e2..44bb1509e1 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -1,3 +1,4 @@ +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { launchRegexMatchingLimitation } from "../process/RegexMatcher.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { @@ -46,14 +47,10 @@ import { } from "./IdaProviderCapabilities.js"; import { IdaSessionClient } from "./IdaSessionClient.js"; import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js"; -import { - ArtifactResourceScope, - ArtifactResourceRetryFailure, -} from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; -import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; -import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js"; +import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js"; /** Bring-your-own IDA MCP candidate; discovery performs no provider startup. */ export class IdaProvider implements AnalysisProviderCandidate { @@ -169,8 +166,7 @@ export class IdaProvider implements AnalysisProviderCandidate { { cause: cause instanceof Error ? cause.message : String(cause) }, { cause, operation: "close_owned_resources" }, ); - if (cause instanceof ArtifactResourceRetryFailure) - new AnalysisCleanupLeaseGroup(failure, cause.cleanupLeases); + bindArtifactFileCleanup(cause, failure); return err(failure); } } diff --git a/src/ida/IdaWorkspace.ts b/src/ida/IdaWorkspace.ts index 0e0c696dae..b8219421f7 100644 --- a/src/ida/IdaWorkspace.ts +++ b/src/ida/IdaWorkspace.ts @@ -152,7 +152,20 @@ export class IdaWorkspace { if (end > start) { const admitted = bytes.subarray(start - position, end - position); inputHash.update(admitted); - await input.write(admitted); + let offset = 0; + while (offset < admitted.length) { + assertArtifactReadActive(signal); + const { bytesWritten } = await input.write( + admitted, + offset, + admitted.length - offset, + ); + if (bytesWritten === 0) + throw new AnalysisProtocolError( + "IDA input copy write made no progress", + ); + offset += bytesWritten; + } assertArtifactReadActive(signal); } position += observed.bytesRead; diff --git a/src/ida/IdaWorkspaceThinning.test.ts b/src/ida/IdaWorkspaceThinning.test.ts index 3d3ee70685..cafb2eb857 100644 --- a/src/ida/IdaWorkspaceThinning.test.ts +++ b/src/ida/IdaWorkspaceThinning.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; -import { readFile, rm, writeFile } from "node:fs/promises"; +import { open, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parseBinaryTarget } from "../application/BinaryTargetResolver.js"; import { parseExecutableHeader } from "../domain/binaryTarget.js"; @@ -14,8 +14,17 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { IdaSessionClient } from "./IdaSessionClient.js"; import { IdaWorkspace } from "./IdaWorkspace.js"; +interface BufferWritePrototype { + write( + buffer: Buffer, + offset?: number, + length?: number, + ): Promise<{ bytesWritten: number; buffer: Buffer }>; +} + const roots: string[] = []; afterEach(async () => { + vi.restoreAllMocks(); for (const root of roots.splice(0)) { await rm(root, { recursive: true, force: true }); } @@ -172,7 +181,79 @@ describe("IDA workspace input thinning", () => { expect(workspace.inputSha256).toBe(target.sha256); await workspace.remove(); }); +}); +describe("IDA workspace write completion", () => { + it.each(["thin", "universal"] as const)( + "completes short writes before recording the %s input digest", + async (kind) => { + const selected = + kind === "thin" + ? await createIdaTarget("arm64") + : await universalTarget("arm64"); + if ("root" in selected) roots.push(selected.root); + const { target } = selected; + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + const probe = await open(target.path, "r"); + const prototype = Object.getPrototypeOf(probe) as BufferWritePrototype; + const originalWrite = prototype.write; + await probe.close(); + const write = vi.spyOn(prototype, "write").mockImplementation(function ( + this: unknown, + buffer: Buffer, + offset = 0, + length = buffer.length, + ) { + return Reflect.apply(originalWrite, this, [ + buffer, + offset, + Math.min(length, 7), + ]); + }); + await workspace.copyInput(); + write.mockRestore(); + const input = await readFile(workspace.inputPath); + const source = await readFile(target.path); + const slice = + target.format === "mach-o-universal" + ? target.universalSlice + : undefined; + expect( + input.equals( + slice === undefined + ? source + : source.subarray(slice.offset, slice.offset + slice.size), + ), + ).toBe(true); + expect(workspace.inputSha256).toBe( + createHash("sha256").update(input).digest("hex"), + ); + await workspace.remove(); + }, + ); + + it("rejects a zero-progress write without publishing an input digest", async () => { + const { root, target } = await createIdaTarget("arm64"); + roots.push(root); + const workspace = await IdaWorkspace.allocate(target); + roots.push(workspace.directory); + const probe = await open(target.path, "r"); + const prototype = Object.getPrototypeOf(probe) as BufferWritePrototype; + await probe.close(); + const write = vi + .spyOn(prototype, "write") + .mockResolvedValue({ bytesWritten: 0, buffer: Buffer.alloc(0) }); + await expect(workspace.copyInput()).rejects.toThrow( + "write made no progress", + ); + write.mockRestore(); + expect(workspace.inputSha256).toBeUndefined(); + await workspace.remove(); + }); +}); + +describe("IDA workspace admission identity", () => { it.skipIf(process.platform === "win32")( "rejects a universal source that changed after admission", async () => { diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index e8ff9fd516..dc653060d2 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -1,7 +1,7 @@ import { - readFatContainerHeader, - validateFatSlice, -} from "../domain/apple/machoContainer.js"; + readUniversalMachoImage, + readUniversalMachoSlice, +} from "../domain/apple/machoImageSelection.js"; import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture.js"; import { BinaryFormatFailure, @@ -33,8 +33,14 @@ const selectImage = ( bytes: Buffer, architecture: string | undefined, ): Buffer => { - const fat = readFatContainerHeader(bytes, bytes.length); - if (fat.state === "not-fat") { + const container = readUniversalMachoImage(bytes); + if (!container.ok) + throw new Il2cppBinaryFormatFailure( + "malformed-container", + container.error.message, + ); + const fat = container.value; + if (fat === null) { if (architecture !== undefined) throw new Il2cppBinaryFormatFailure( "slice-not-found", @@ -42,11 +48,6 @@ const selectImage = ( ); return bytes; } - if (fat.state !== "parsed") - throw new Il2cppBinaryFormatFailure( - "malformed-container", - fat.state === "malformed" ? fat.reason : "universal header is truncated", - ); const available = fat.slices.map( (slice) => MACHO_CPU.get(slice.cpuType) ?? `macho-cpu-${slice.cpuType}`, ); @@ -67,10 +68,13 @@ const selectImage = ( "slice-not-found", `universal Mach-O holds ${available.join(", ")}, not ${architecture}`, ); - const issue = validateFatSlice(slice, bytes.length, fat.tableEnd); - if (issue !== null) - throw new Il2cppBinaryFormatFailure("malformed-container", issue); - return bytes.subarray(slice.offset, slice.offset + slice.size); + const selected = readUniversalMachoSlice(bytes, fat, slice); + if (!selected.ok) + throw new Il2cppBinaryFormatFailure( + "malformed-container", + selected.error.message, + ); + return selected.value; }; export interface NativeImage { @@ -108,14 +112,6 @@ export function* openImageSteps( ); throw cause; } - if ( - architecture !== undefined && - (container.format !== "macho" || container.architecture !== architecture) - ) - throw new Il2cppBinaryFormatFailure( - "malformed-container", - "Selected FAT declaration disagrees with the slice header architecture.", - ); if (container.bits !== 64 || container.byte_order !== "little") throw new Il2cppBinaryFormatFailure( "unsupported-container", diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index 59e8a15b7a..05a3c198ef 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -28,6 +28,7 @@ export interface V8InspectorTarget { export interface V8InspectorDiscovery { readonly limitations: readonly string[]; + readonly ambiguousTargetIds?: readonly string[]; readonly runtime: JavaScriptRuntimeTargetList["runtime"]; readonly targets: readonly V8InspectorTarget[]; } @@ -43,12 +44,13 @@ export const discoverV8Inspector = async ( readCdpJson(new URL("/json/list", endpoint), operation, signal), ]); const version = parseCdpEndpointValue(versionSchema, versionInput, operation); - const { targets, limitations } = parseCdpDiscoveryTargets( + const { targets, limitations, ambiguousTargetIds } = parseCdpDiscoveryTargets( targetsInput, operation, ); return { limitations, + ambiguousTargetIds, runtime: { product: version.Browser, protocol_version: version["Protocol-Version"], diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index de16d665f2..bd56a42e92 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -1,3 +1,4 @@ +import { selectCdpDiscoveryTarget } from "../browser/CdpDiscoveryTargets.js"; import { DEFAULT_ASAR_HEADER_ADMISSION, type AsarHeaderAdmission, @@ -52,7 +53,7 @@ import { import { discoverV8Inspector, type AuthorizedV8InspectorTarget, - type V8InspectorTarget, + type V8InspectorDiscovery, } from "./V8InspectorEndpoint.js"; import { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js"; @@ -364,9 +365,8 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { options.signal, ); target = await authorizedTarget( - discovery.targets, + discovery, input, - discovery.runtime.product, this.#asarHeaderAdmission, options.signal, ); @@ -506,14 +506,18 @@ const projectTarget = (target: AuthorizedV8InspectorTarget) => ({ }); const authorizedTarget = async ( - targets: readonly V8InspectorTarget[], + discovery: V8InspectorDiscovery, input: ObserveJavaScriptRuntimeInput, - product: string, asarHeaderAdmission: AsarHeaderAdmission, signal?: AbortSignal, ): Promise => { throwIfRuntimeObservationCancelled(signal); - const target = targets.find(({ id }) => id === input.target_id); + const target = selectCdpDiscoveryTarget( + discovery.targets, + input.target_id, + "observe_javascript_runtime", + discovery.ambiguousTargetIds, + ); if (target === undefined) throw new BrowserObservationError( "observe_javascript_runtime", @@ -521,7 +525,7 @@ const authorizedTarget = async ( ); const decision = await authorizeRuntimeTargetLocation( target.url, - { type: target.type, product }, + { type: target.type, product: discovery.runtime.product }, signal, "observe_javascript_runtime", asarHeaderAdmission, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 58e87c9cf6..7b15baf7a5 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -1,3 +1,4 @@ +import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js"; import { safeParseUtf8Json } from "../../domain/safeJson.js"; import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js"; import { WorkerFrameSignal } from "../../process/WorkerFrameSignal.js"; @@ -541,7 +542,17 @@ export class JavaScriptAnalysisWorker { await rm(join(rootPath, `${id}.${suffix}`), { force: true }); } catch (cause: unknown) { this.#runtime.retainForCleanup(); - const error = this.#cleanupError(message(cause)); + const previous = result.ok ? undefined : result.error.error; + const primary = signal?.aborted + ? analysisErrorWithCancellation( + previous, + new AnalysisCancelledError(OPERATION, { + cause: signal.reason, + capturedOutput: this.#captured(), + }), + ) + : previous; + const error = this.#cleanupError(message(cause), primary); this.#runtime.bindCleanupFailure(error); result = err({ javascript, @@ -551,12 +562,15 @@ export class JavaScriptAnalysisWorker { }); } } - if (result.ok && signal?.aborted === true) { - const error = new AnalysisCancelledError(OPERATION, { - cause: signal.reason, - capturedOutput: this.#captured(), - }); - this.#lastFailure = error; + if (signal?.aborted === true) { + const error = analysisErrorWithCancellation( + result.ok ? undefined : result.error.error, + new AnalysisCancelledError(OPERATION, { + cause: signal.reason, + capturedOutput: this.#captured(), + }), + ); + if (result.ok) this.#lastFailure = error; result = err({ error, javascript, module, projection }); } if (this.#applicationProjectionFailure !== undefined) diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index c2b1331ce8..47ed06e804 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -1,3 +1,4 @@ +import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { basename } from "node:path"; import { regularFileCancellation, @@ -89,10 +90,7 @@ import { type KunaProjectFileSpan, } from "./KunaProjectReader.js"; import { kunaUnavailable } from "./KunaUnavailable.js"; -import { - ArtifactResourceRetryFailure, - ArtifactResourceScope, -} from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { kunaQueryFailureForCaller } from "./KunaQueryErrors.js"; @@ -204,11 +202,7 @@ const descriptorCleanupError = ( { reason: observation.reason }, { cause, operation }, ); - if ( - cause instanceof ArtifactResourceRetryFailure && - cause.cleanupLeases.length > 0 - ) - new AnalysisCleanupLeaseGroup(error, cause.cleanupLeases); + bindArtifactFileCleanup(cause, error); return error; }; diff --git a/src/main/transport.ts b/src/main/transport.ts index 8735fce606..847f7b42d9 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -87,8 +87,12 @@ export const startMcpTransport = async ( androidProviders.delete(provider); }), ); - for (const result of results) - if (result.status === "rejected") throw result.reason; + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "Android provider cleanup failed"); }; // Optional providers are shared by every server instance the SDK creates, // including a discarded discovery probe, so only process shutdown closes them. diff --git a/src/native/AppleMachoNames.ts b/src/native/AppleMachoNames.ts index d0040af15e..6fd9e3a6ff 100644 --- a/src/native/AppleMachoNames.ts +++ b/src/native/AppleMachoNames.ts @@ -11,3 +11,10 @@ export const decodeMachoName = (bytes: Buffer): MachoName => { return { bytes_base64: bytes.toString("base64") }; } }; + +/** Fixed-width Mach-O names end at the first NUL; retain every preceding byte. */ +export const decodeMachoFixedName = (bytes: Buffer): string => { + const nul = bytes.indexOf(0); + // Latin-1 preserves high bits instead of fabricating ASCII metadata names. + return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("latin1"); +}; diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 1283fe2b29..b9fbe75dac 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -11,11 +11,13 @@ import { machoImageWithUnknownCommand, } from "../artifacts/apple/MachoImage.fixture.js"; import { readMachoImage } from "../artifacts/apple/MachoLoadCommandReader.js"; +import { readMachoImage as readMachoMetadataImage } from "./metadata/MachoImage.js"; import { parseMachoLayout, selectMachoSlice, UnsupportedMachoMetadataFormatError, } from "./AppleMachoSelection.js"; +import { parsePointerFixups } from "./AppleMachoFixups.js"; import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; /** macOS 27 system binaries ship CPU_SUBTYPE_ARM64E_X1 (12) with the PAC ABI bit. */ @@ -108,6 +110,201 @@ it.each([false, true])( }, ); +it.each([ + { source: "__objc_const\0\nxx", expected: "__objc_const" }, + { source: "abcdefghijklmnop", expected: "abcdefghijklmnop" }, + { source: "\0\nignored", expected: "" }, + { source: "before\nafter\0xx", expected: "before\nafter" }, +])( + "reads fixed-width section names from bytes: $source", + ({ source, expected }) => { + const segment = Buffer.alloc(152); + segment.writeUInt32LE(0x19, 0); + segment.writeUInt32LE(segment.length, 4); + segment.writeUInt32LE(1, 64); + Buffer.from(source, "latin1").copy(segment, 72, 0, 16); + Buffer.from("__DATA\0\npadding", "latin1").copy(segment, 72 + 16); + const bytes = Buffer.from(machoImage({ commands: [segment] })); + + expect(parseMachoLayout(bytes, "arm64").sections[0]?.name).toBe(expected); + expect(readMachoMetadataImage(bytes).sections[0]).toMatchObject({ + name: `__DATA,${expected}`, + section_name: expected, + }); + }, +); + +/** File ranges remain inside the padded source while VM ranges may alias. */ +const mappingSegment = ( + address: bigint, + fileOffset: number, + fileSize: number, + memorySize = fileSize, +): Buffer => { + const command = Buffer.alloc(72); + command.writeUInt32LE(0x19); + command.writeUInt32LE(command.length, 4); + command.writeBigUInt64LE(address, 24); + command.writeBigUInt64LE(BigInt(memorySize), 32); + command.writeBigUInt64LE(BigInt(fileOffset), 40); + command.writeBigUInt64LE(BigInt(fileSize), 48); + command.writeUInt32LE(1, 60); + return command; +}; + +const mappingImage = (commands: readonly Buffer[]): Buffer => { + const bytes = Buffer.alloc(512); + Buffer.from(machoImage({ commands })).copy(bytes); + return bytes; +}; + +describe("Mach-O metadata span mapping", () => { + it.each([ + ["file size larger than VM size", mappingSegment(0x1000n, 0, 128, 64)], + ["VM address overflow", mappingSegment((1n << 64n) - 1n, 0, 2)], + ])("rejects %s consistently across metadata consumers", (_name, segment) => { + const bytes = mappingImage([segment]); + expect(() => parseMachoLayout(bytes, "arm64")).toThrow( + "Mach-O segment addresses or sizes are invalid", + ); + expect(() => readMachoMetadataImage(bytes)).toThrow( + "Mach-O segment addresses or sizes are invalid", + ); + }); + + it.each(["unmapped", "pagezero"])( + "does not expose %s file bytes as metadata pointers", + (kind) => { + const segment = mappingSegment(0x1000n, 0, 128); + if (kind === "unmapped") { + segment.writeBigUInt64LE(0n, 32); + segment.writeUInt32LE(0, 60); + } else segment.write("__PAGEZERO", 8, "ascii"); + const bytes = mappingImage([segment]); + const layout = parseMachoLayout(bytes, "arm64"); + // Dyld fixup segment ordinals retain the original command order. + expect(layout.segments).toHaveLength(1); + expect(layout.baseAddress).toBe(0n); + expect(() => layout.offset(0x1000n)).toThrow(RangeError); + expect(readMachoMetadataImage(bytes).mappings).toEqual([]); + }, + ); + + it("preserves dyld segment ordinals across PAGEZERO and unmapped segments", () => { + const pagezero = mappingSegment(0n, 0, 0, 4096); + pagezero.write("__PAGEZERO", 8, "ascii"); + const unmapped = mappingSegment(0x1000n, 0, 0, 0); + unmapped.writeUInt32LE(0, 60); + const data = mappingSegment(0x2000n, 256, 128); + const opcodes = Buffer.from([ + 0x40, + ...Buffer.from("_local\0"), + 0x72, + 8, + 0x90, + 0, + ]); + const dyldInfo = Buffer.alloc(48); + dyldInfo.writeUInt32LE(0x80000022, 0); + dyldInfo.writeUInt32LE(48, 4); + dyldInfo.writeUInt32LE(400, 16); + dyldInfo.writeUInt32LE(opcodes.length, 20); + const bytes = mappingImage([pagezero, unmapped, data, dyldInfo]); + opcodes.copy(bytes, 400); + const layout = parseMachoLayout(bytes, "arm64"); + expect(layout.offset(0x2008n, 8)).toBe(264); + expect(() => layout.offset(8n, 8)).toThrow(RangeError); + expect(() => layout.offset(0x1000n, 8)).toThrow(RangeError); + const fixups = parsePointerFixups( + bytes, + layout.fixups, + layout.segments, + layout.baseAddress, + ); + expect(fixups.issues).toEqual([]); + expect(fixups.decode(0x2008n, 0n)).toMatchObject({ + kind: "bind", + symbol: "_local", + library_ordinal: 0, + binding_source: "bind", + }); + }); + + it("rejects a conflicting alias covering only the end of a pointer", () => { + const layout = parseMachoLayout( + mappingImage([ + mappingSegment(0x1000n, 0, 512), + mappingSegment(0x1104n, 400, 4), + ]), + "arm64", + ); + expect(layout.offset(0x1100n, 4)).toBe(256); + expect(() => layout.offset(0x1100n, 8)).toThrow(RangeError); + }); + + it("maps adjacent ranges and duplicate aliases with the same file translation", () => { + const layout = parseMachoLayout( + mappingImage([ + mappingSegment(0x1000n, 0, 128), + mappingSegment(0x1080n, 128, 128), + mappingSegment(0x107cn, 124, 8), + ]), + "arm64", + ); + expect(layout.offset(0x107cn, 8)).toBe(124); + }); + + it("rejects file aliases into another segment's zero-filled memory", () => { + const layout = parseMachoLayout( + mappingImage([ + mappingSegment(0x1000n, 0, 128, 256), + mappingSegment(0x1080n, 256, 8), + ]), + "arm64", + ); + expect(() => layout.offset(0x1080n, 8)).toThrow(RangeError); + }); + + it.each([-1, 0.5, Number.POSITIVE_INFINITY])( + "rejects an invalid requested span size %s", + (size) => { + const layout = parseMachoLayout( + mappingImage([mappingSegment(0x1000n, 0, 512)]), + "arm64", + ); + expect(() => layout.offset(0x1000n, size)).toThrow(RangeError); + }, + ); + + it("keeps dispatch coverage partial when a class-list pointer has a partial alias", () => { + const main = Buffer.concat([ + mappingSegment(0x1000n, 0, 512), + Buffer.alloc(80), + ]); + main.writeUInt32LE(main.length, 4); + main.writeUInt32LE(1, 64); + main.write("__objc_classlist", 72, "ascii"); + main.writeBigUInt64LE(0x1100n, 72 + 32); + main.writeBigUInt64LE(8n, 72 + 40); + const result = decodeAppleDispatchMetadata( + mappingImage([main, mappingSegment(0x1104n, 400, 4)]), + 10, + { path: "/aliased", sha256: "fixture" }, + ); + expect(result.objc_classes).toEqual([]); + expect(result.coverage).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "partial", + reason: expect.stringContaining( + "Unmapped or ambiguous metadata pointer", + ), + }), + ]), + ); + }); +}); + describe("native Mach-O slice selection", () => { it("rejects misaligned load commands", () => { expect(() => diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 032347ddf5..5949198699 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -3,15 +3,24 @@ import { readMachoDylibCommandFacts, } from "../domain/apple/machoDylibCommand.js"; import { readMachoSectionHeader } from "../domain/apple/machoSectionHeader.js"; -import { readMachoSegmentHeader } from "../domain/apple/machoSegmentHeader.js"; +import { + admitMachoSegmentMapping, + readMachoSegmentHeader, +} from "../domain/apple/machoSegmentHeader.js"; import { MachoLoadCommandCursor, readMachoLoadCommandDeclarations, } from "../domain/apple/machoLoadCommands.js"; import { createHash } from "node:crypto"; +import { + BinaryFormatFailure, + createFileMappingIndex, + type ZeroFillMapping, + type FileMapping, +} from "./metadata/BinaryContainer.js"; import type { FixupCommands } from "./AppleMachoFixups.js"; -import { decodeMachoName } from "./AppleMachoNames.js"; +import { decodeMachoFixedName, decodeMachoName } from "./AppleMachoNames.js"; import { MACHO_ARCHITECTURE_POLICIES, machoSubtypeRank, @@ -197,6 +206,8 @@ export const parseMachoLayout = ( slice, sliceEnd, segments: [], + mappings: [], + zeroFills: [], sections: [], dylibs: [], binds: [], @@ -220,19 +231,20 @@ export const parseMachoLayout = ( const complete = framing.finish(); if (!complete.ok) throw new RangeError(complete.error); const { segments, sections, chained, binds, rebases, dylibs } = state; + const mappings = createFileMappingIndex(state.mappings, state.zeroFills); const offset = (address: bigint, size = 1): number => { - const matches = segments.filter( - (segment) => - address >= segment.address && - address + BigInt(size) <= segment.address + segment.size, - ); - if (matches.length !== 1) + try { + return mappings.offset(address, size); + } catch (cause) { + // Metadata consumers use RangeError to retain partial facts when a + // pointer is unavailable. Mapping interpretation belongs to the shared + // image reader, including aliases that only overlap part of the span. + if (!(cause instanceof BinaryFormatFailure)) throw cause; throw new RangeError( `Unmapped or ambiguous metadata pointer ${hex(address)}`, + { cause }, ); - const segment = matches[0]; - if (segment === undefined) throw new RangeError("Missing metadata segment"); - return segment.offset + Number(address - segment.address); + } }; return { segments, @@ -261,6 +273,8 @@ interface LayoutState { readonly slice: number; readonly sliceEnd: number; readonly segments: Segment[]; + readonly mappings: FileMapping[]; + readonly zeroFills: ZeroFillMapping[]; readonly sections: Section[]; readonly dylibs: FixupCommands["dylibs"][number][]; readonly binds: FixupCommands["binds"][number][]; @@ -290,21 +304,43 @@ const collectSegment = ( if (!parsed.ok) throw new RangeError("Truncated segment command"); const { address, + memorySize, + nameBytes, fileSize, fileOffset, protection, sectionCount: count, headerSize, } = parsed.value; + const admitted = admitMachoSegmentMapping(parsed.value, 64); + if (!admitted.ok) throw new RangeError(admitted.error.message); if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice)) throw new RangeError("Segment file range exceeds target bytes"); + const name = decodeMachoFixedName(nameBytes); state.segments.push({ address, size: fileSize, offset: state.slice + Number(fileOffset), executable: (protection & 4) !== 0, }); - if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address; + if (!admitted.value.unmapped && name !== "__PAGEZERO") + state.mappings.push({ + address, + size: Number(fileSize), + offset: state.slice + Number(fileOffset), + }); + if (memorySize > fileSize && name !== "__PAGEZERO") + state.zeroFills.push({ + address: address + fileSize, + size: memorySize - fileSize, + }); + if ( + !admitted.value.unmapped && + name !== "__PAGEZERO" && + fileOffset === 0n && + fileSize > 0n + ) + state.baseAddress ??= address; if (headerSize + count * 80 > size) throw new RangeError("Truncated section table"); for (let section = 0; section < count; section++) { @@ -319,10 +355,7 @@ const collectSegment = ( if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError("Section size exceeds exact numeric range"); state.sections.push({ - name: nameBytes - // ASCII decoding masks high bits and can fabricate known metadata names. - .toString("latin1") - .replace(/\0.*$/u, ""), + name: decodeMachoFixedName(nameBytes), address, size: Number(sectionSize), }); diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index ce222652b4..3ab02bff3f 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1,3 +1,4 @@ +import { withArtifactTargetIdentity } from "../artifacts/withArtifactTargetIdentity.js"; import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js"; import { plistSourceFormat, @@ -956,54 +957,36 @@ class NativeMacOSClient implements AnalysisClient { ); try { return await this.#plistResources.run(async () => { + if ( + this.activeTarget === undefined || + this.activeTarget.kind !== "executable" || + this.activeTarget.bundleInfoPlist === undefined + ) + return this.#readPlist(requested, signal, timeoutMs); try { - const plist = - this.bundleResourceRoot === undefined - ? await resolveNativePlistSelection(this.target, requested) - : resolveBundleResourcePlistSelection( - this.bundleResourceRoot, - requested, - ); - if (!plist.ok) return plist; - const fileInput: NativePlistReadContext = { - selection: plist.value, - resources: this.#plistResources, + const observation = await withArtifactTargetIdentity( + this.activeTarget, + this.#plistResources, + async () => { + const result = await this.#readPlist( + requested, + signal, + timeoutMs, + ); + if (!result.ok) throw result.error; + return result.value; + }, signal, - timeoutMs, - }; - const selected = await requireRegularPlist(fileInput); - if (!selected.ok) return selected; - const classified = await this.#run( - "inspect_plist", - "file", - ["-b", plist.value.path], - { signal, timeoutMs }, - ); - if (!classified.ok) return classified; - const binary = await this.#inspectBinaryPlist( - fileInput, - classified.value, - ); - if (binary !== undefined) return binary; - const format = await plistSourceFormat( - classified.value.stdout, - fileInput, - ); - if (!format.ok) return format; - const source = await this.#inspectTextPlist( - fileInput, - classified.value, - format.value, - ); - if (source !== undefined) return source; - return await this.#inspectConvertedPlist( - fileInput, - classified.value, - format.value, ); + return ok(observation); } catch (cause: unknown) { - // Producer failures stay opaque; only scope admission failures reach the file mapper. - return err(nativeOutputFailure("inspect_plist", cause)); + if (cause instanceof AnalysisError) return err(cause); + return err( + nativePlistFailure(cause, { + path: this.activeTarget.path, + origin: "selected", + }), + ); } }); } catch (cause: unknown) { @@ -1011,6 +994,62 @@ class NativeMacOSClient implements AnalysisClient { } } + async #readPlist( + requested: string | undefined, + signal: AbortSignal | undefined, + timeoutMs: number, + ): Promise> { + try { + const plist = + this.bundleResourceRoot === undefined + ? await resolveNativePlistSelection(this.target, requested) + : resolveBundleResourcePlistSelection( + this.bundleResourceRoot, + requested, + ); + if (!plist.ok) return plist; + const fileInput: NativePlistReadContext = { + selection: plist.value, + resources: this.#plistResources, + signal, + timeoutMs, + }; + const selected = await requireRegularPlist(fileInput); + if (!selected.ok) return selected; + const classified = await this.#run( + "inspect_plist", + "file", + ["-b", plist.value.path], + { signal, timeoutMs }, + ); + if (!classified.ok) return classified; + const binary = await this.#inspectBinaryPlist( + fileInput, + classified.value, + ); + if (binary !== undefined) return binary; + const format = await plistSourceFormat( + classified.value.stdout, + fileInput, + ); + if (!format.ok) return format; + const source = await this.#inspectTextPlist( + fileInput, + classified.value, + format.value, + ); + if (source !== undefined) return source; + return await this.#inspectConvertedPlist( + fileInput, + classified.value, + format.value, + ); + } catch (cause: unknown) { + // Producer failures stay opaque; only scope admission failures reach the file mapper. + return err(nativeOutputFailure("inspect_plist", cause)); + } + } + async #inspectBinaryPlist( fileInput: NativePlistReadContext, classified: NativeCommandCapture, diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index c981e4138a..9d85fa2f82 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -17,7 +17,11 @@ import { MACHO_ARCHITECTURE_POLICIES, selectMachoArchitectureName, } from "./MachoArchitectureSelection.js"; -import { parseDyldExports, parseDyldImports } from "./parsers/dyldInfo.js"; +import { + parseDyldExportAddressRow, + parseDyldExports, + parseDyldImports, +} from "./parsers/dyldInfo.js"; import { parseLipoCapture, type LipoArchitecture } from "./parsers/lipo.js"; import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; import { parseOtoolLoadCommands } from "./parsers/otool.js"; @@ -476,7 +480,7 @@ const namesEveryExport = ( const rows = parseDyldExports(line, null, names); // The parser withholds a row only when nm names both of its readings. if (rows.length === 0) - return names.has(/^0x[\da-f]+\s+(.+)$/isu.exec(line)?.[1] ?? ""); + return names.has(parseDyldExportAddressRow(line)?.text ?? ""); return rows.length === 1 && names.has(rows[0]!.name); }); }; diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 5302adc06a..7488cc81da 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -7,7 +7,6 @@ import { readMappedCString, } from "../metadata/BinaryContainer.js"; import { architectureForElfMachine } from "../metadata/ElfImage.js"; -import { isUtf8 } from "node:buffer"; import { readElfStringBytes } from "../metadata/ElfStringTable.js"; import { readElfSymbol } from "../../domain/elfSymbol.js"; import { @@ -17,6 +16,7 @@ import { import { readPeImage } from "../metadata/PeImage.js"; import { MAX_NATIVE_EXPORT_ROWS, + decodeElfExportName, type ElfExport, type NativeExportIssue, type NativeExports, @@ -304,22 +304,7 @@ const versionSectionData = ( }; /** Version names by index, read from `.gnu.version_d` and `.gnu.version_r`. */ -type DecodedElfString = { - readonly name: string; - readonly name_bytes_base64: string | null; - readonly name_utf8_decodable: boolean; -}; - -const decodeElfString = (bytes: Buffer): DecodedElfString => { - const utf8 = isUtf8(bytes); - return { - name: bytes.toString(utf8 ? "utf8" : "latin1"), - name_bytes_base64: bytes.some((byte) => byte >= 0x80) - ? bytes.toString("base64") - : null, - name_utf8_decodable: utf8, - }; -}; +type DecodedElfString = ReturnType; const readVersionNames = ( reader: BinaryReader, @@ -331,7 +316,7 @@ const readVersionNames = ( // output rows. Replacing an index releases its previous decoded name. let textBytes = 0; const addName = (index: number, name: Buffer): void => { - const decoded = name.length === 0 ? null : decodeElfString(name); + const decoded = name.length === 0 ? null : decodeElfExportName(name); const previous = namesByIndex.get(index); const nextTextBytes = textBytes - @@ -641,7 +626,7 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => { let rawNameBase64: string | null; try { const rawName = readElfStringBytes(names, symbol.name_offset); - const decodedName = decodeElfString(rawName); + const decodedName = decodeElfExportName(rawName); nameUtf8Decodable = decodedName.name_utf8_decodable; name = decodedName.name; rawNameBase64 = decodedName.name_bytes_base64; diff --git a/src/native/exports/NativeExportsView.test.ts b/src/native/exports/NativeExportsView.test.ts index f451cbd461..53f3a4e0db 100644 --- a/src/native/exports/NativeExportsView.test.ts +++ b/src/native/exports/NativeExportsView.test.ts @@ -118,7 +118,7 @@ it("qualifies projected names with exact encoding facts and normalizes historica analysisViewRequestSchema.parse({ kind: "native-exports", collection: "exports", - names: ["café"], + indices: [3], fields: ["name"], }), ); @@ -131,6 +131,66 @@ it("qualifies projected names with exact encoding facts and normalizes historica } }); +it("matches ELF UTF-8 identities without conflating Latin-1 display names or unknown encodings", () => { + const report = parseNativeExports( + elfExportImage({ + symbols: [{ name: "café" }, { name: "café" }, { name: "same" }], + }), + artifact, + ); + expect( + project(report, { + kind: "native-exports", + collection: "exports", + names: ["café"], + fields: ["name"], + }), + ).toMatchObject({ + items: [{ source_index: 0, name: "café", name_utf8_decodable: true }], + coverage: { total: 1 }, + }); + if (report.format !== "elf") throw new Error("Expected ELF fixture"); + const historical = { + ...report, + exports: report.exports.map( + ({ name_bytes_base64: _bytes, name_utf8_decodable: _utf8, ...row }) => + row, + ), + }; + expect( + project(historical, { + kind: "native-exports", + collection: "exports", + names: ["café"], + }), + ).toMatchObject({ + items: [], + unknowns: [expect.stringContaining("lack retained encoding facts")], + }); + expect( + project(historical, { + kind: "native-exports", + collection: "exports", + names: [], + }), + ).toMatchObject({ items: [], unknowns: [] }); + const missingBytes = { + ...report, + exports: report.exports.map((row) => ({ ...row, name_bytes_base64: null })), + }; + expect( + project(missingBytes, { + kind: "native-exports", + collection: "exports", + names: ["café", "same"], + fields: [], + }), + ).toMatchObject({ + items: [{ source_index: 2 }], + unknowns: [expect.stringContaining("lack retained encoding facts")], + }); +}); + it("does not fill historical name defaults into a projection that omits names", () => { const result = project(elf(), { kind: "native-exports", @@ -146,6 +206,54 @@ it("does not fill historical name defaults into a projection that omits names", expect(view_digest).toBe(digestCanonicalValue(content, "Analysis view")); }); +it.each(["Y2Fmw6k", "Y2Fmw6l=", "not base64"])( + "rejects untrustworthy imported ELF name bytes %s before selection", + (encoded) => { + for (const target of ["symbol", "version"]) { + const report = elf(); + const row = report.exports[0]!; + if (target === "symbol") row.name_bytes_base64 = encoded; + else if (typeof row.version === "object" && row.version !== null) + row.version.name_bytes_base64 = encoded; + const result = projectAnalysisView( + parent(report), + analysisViewRequestSchema.parse({ + kind: "native-exports", + collection: "exports", + names: ["missing"], + }), + ); + expect(result.ok).toBe(false); + } + }, +); + +it.each(["text", "encoding"])( + "rejects contradictory ELF %s facts at the source owner", + (fact) => { + for (const target of ["symbol", "version"]) { + const report = elf(); + const row = report.exports[0]!; + const name = target === "symbol" ? row : row.version; + if (name === null || typeof name !== "object") + throw new Error("Expected named ELF row"); + name.name_bytes_base64 = Buffer.from(name.name!).toString("base64"); + if (fact === "text") name.name = "different"; + else name.name_utf8_decodable = false; + expect( + projectAnalysisView( + parent(report), + analysisViewRequestSchema.parse({ + kind: "native-exports", + collection: "exports", + names: ["missing"], + }), + ).ok, + ).toBe(false); + } + }, +); + it("preserves PE ordinal-only, forwarder, alias and same-address distinctions", () => { const report = parseNativeExports( peExportImage({ diff --git a/src/native/metadata/MachoImage.ts b/src/native/metadata/MachoImage.ts index e0c116df28..c2178615e7 100644 --- a/src/native/metadata/MachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -1,8 +1,12 @@ +import { decodeMachoFixedName } from "../AppleMachoNames.js"; import { readMachoSectionHeader, type MachoSectionHeader, } from "../../domain/apple/machoSectionHeader.js"; -import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js"; +import { + admitMachoSegmentMapping, + readMachoSegmentHeader, +} from "../../domain/apple/machoSegmentHeader.js"; import { declaredFileRange, BinaryFormatFailure, @@ -76,15 +80,13 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => { sectionCount, nameBytes, } = parsedSegment.value; - const unmapped = memorySize === 0n && protection === 0; - if ( - (!unmapped && fileSize > memorySize) || - address + memorySize > 1n << BigInt(bits) - ) + const admitted = admitMachoSegmentMapping(parsedSegment.value, bits); + if (!admitted.ok) throw new BinaryFormatFailure( - "malformed", - "Mach-O segment addresses or sizes are invalid", + admitted.error.kind, + admitted.error.message, ); + const { unmapped } = admitted.value; if (sectionCount > Math.floor((size - segmentHeader) / sectionWidth)) throw new BinaryFormatFailure( "malformed", @@ -98,7 +100,7 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => { unmapped || fileRange === undefined ? undefined : { ...fileRange, address }; - const name = fixedName(nameBytes); + const name = decodeMachoFixedName(nameBytes); if (mapping !== undefined && name !== "__PAGEZERO") { mappings.push(mapping); loadRegions.push({ @@ -199,8 +201,8 @@ const machoImageSection = ({ }): ImageSection => { const { flags, address: sectionAddress, size: sectionSize } = section; const sectionOffset = BigInt(section.offset); - const sectionName = fixedName(section.nameBytes); - const name = `${fixedName(section.segmentNameBytes)},${sectionName}`; + const sectionName = decodeMachoFixedName(section.nameBytes); + const name = `${decodeMachoFixedName(section.segmentNameBytes)},${sectionName}`; const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff); const addressDelta = sectionAddress - segmentAddress; const fileDelta = sectionOffset - segmentFileOffset; @@ -237,8 +239,3 @@ const machoImageSection = ({ compressed: false, }; }; - -const fixedName = (bytes: Buffer): string => { - const nul = bytes.indexOf(0); - return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("latin1"); -}; diff --git a/src/native/metadata/MachoSliceSelection.ts b/src/native/metadata/MachoSliceSelection.ts index cbc6e37fb5..c3c525b18c 100644 --- a/src/native/metadata/MachoSliceSelection.ts +++ b/src/native/metadata/MachoSliceSelection.ts @@ -1,11 +1,9 @@ import { createHash } from "node:crypto"; import { - classifyFatContainerIdentity, - readFatContainerHeader, - readMachoHeaderPrefixFacts, - validateFatSlice, -} from "../../domain/apple/machoContainer.js"; + readUniversalMachoImage, + readUniversalMachoSlice, +} from "../../domain/apple/machoImageSelection.js"; import { MACHO_CPU_TYPES, type MachoSliceArchitecture, @@ -48,37 +46,16 @@ export const selectNativeImage = ( bytes: Buffer, architecture: MachoSliceArchitecture | undefined, ): Result => { - const fat = readFatContainerHeader(bytes, bytes.length); - const identity = - fat.state === "parsed" - ? classifyFatContainerIdentity(fat, (offset) => - offset + 4 > bytes.length ? null : bytes.readUInt32BE(offset), - ) - : undefined; - // A table whose slices declare known Mach-O CPUs but cannot be read is a - // damaged universal file; no architecture selection can repair it. - if (identity?.state === "malformed") - return err({ kind: "malformed", message: identity.reason }); - // A FAT-shaped table is not a universal Mach-O until its slices prove it; - // JVM class files share the magic. Leave such files to the image reader. - if ( - fat.state === "not-fat" || - (fat.state === "parsed" && identity?.state !== "mach-o") - ) + const container = readUniversalMachoImage(bytes); + if (!container.ok) return container; + const fat = container.value; + if (fat === null) return architecture === undefined ? ok({ bytes, fileOffset: 0, selection: null }) : err({ kind: "architecture", message: `architecture ${architecture} was selected, but the file is not a universal Mach-O; omit architecture.`, }); - if (fat.state !== "parsed") - return err({ - kind: "malformed", - message: - fat.state === "malformed" - ? fat.reason - : "Universal Mach-O header is truncated", - }); const available = fat.slices .map(({ cpuType, cpuSubtype }) => cpuName(cpuType, cpuSubtype)) .join(", "); @@ -99,15 +76,10 @@ export const selectNativeImage = ( ? `Universal Mach-O holds ${available}, not ${architecture}.` : `Universal Mach-O holds several ${architecture} slices (${available}); no slice was selected.`, }); - const image = bytes.subarray(slice.offset, slice.offset + slice.size); - const embedded = readMachoHeaderPrefixFacts(image).identity; - const issue = - embedded === null - ? `FAT architecture ${String(slice.index)} does not begin with a Mach-O header` - : validateFatSlice(slice, bytes.length, fat.tableEnd, embedded); - if (issue !== null) return err({ kind: "malformed", message: issue }); + const selected = readUniversalMachoSlice(bytes, fat, slice); + if (!selected.ok) return selected; return ok({ - bytes: image, + bytes: selected.value, fileOffset: slice.offset, selection: { kind: "macho-selected-image-v1", diff --git a/src/native/metadata/NativeSectionDirectory.test.ts b/src/native/metadata/NativeSectionDirectory.test.ts new file mode 100644 index 0000000000..a3e2e69992 --- /dev/null +++ b/src/native/metadata/NativeSectionDirectory.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; +import { pe } from "../../domain/binaryTarget.fixture.js"; +import { readPeSectionDirectory } from "./NativeSectionDirectory.js"; + +const readDirectory = (bytes: Buffer) => { + const reader = readPeSectionDirectory(bytes.length); + let next = reader.next(); + while (!next.done) { + const { offset, size } = next.value; + next = reader.next(bytes.subarray(offset, offset + size)); + } + return next.value; +}; + +it("classifies a confirmed overlapping PE directory as an unsupported layout", () => { + expect(() => readDirectory(pe(0x014c, 4))).toThrow( + expect.objectContaining({ + kind: "unsupported", + message: expect.stringContaining("overlapping PE header"), + }), + ); +}); + +it.each([0x014c, 0x8664])( + "reads a nonoverlapping PE directory for machine %s", + (machine) => { + expect(readDirectory(pe(machine))).toMatchObject({ + format: "pe", + machine, + bits: machine === 0x014c ? 32 : 64, + sections: [], + }); + }, +); + +it("does not classify an unconfirmed pointer overlapping DOS data as a PE layout", () => { + const bytes = pe(0x014c); + bytes.writeUInt32LE(4, 60); + expect(() => readDirectory(bytes)).toThrow( + expect.objectContaining({ kind: "malformed" }), + ); +}); + +it("retains a confirmed overlapping PE when the COFF record extends past EOF", () => { + const bytes = Buffer.alloc(64); + bytes.write("MZ", 0, "ascii"); + bytes.write("PE\0\0", 44, "ascii"); + bytes.writeUInt32LE(44, 60); + expect(() => readDirectory(bytes)).toThrow( + expect.objectContaining({ + kind: "unsupported", + message: expect.stringContaining("overlapping PE header"), + }), + ); +}); diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 7ff4730dea..76000b1391 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -2,7 +2,11 @@ import { observeElfStringTableEntry, elfStringTableText, } from "../../domain/elfStringTable.js"; -import { observeMzWindowsHeader } from "../../domain/dosMz.js"; +import { + mzWindowsHeaderLayoutLimitation, + parseMzHeaderEvidence, + withMzWindowsHeaderSignature, +} from "../../domain/dosMz.js"; import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; @@ -290,18 +294,30 @@ export function* readPeSectionDirectory( ): Generator { const dos = new BinaryReader(yield request(fileSize, 0n, 64n), true); if (dos.u16(0) !== 0x5a4d) reject("Missing DOS MZ header"); - const observed = observeMzWindowsHeader(dos.bytes, fileSize); + const evidence = parseMzHeaderEvidence(dos.bytes, fileSize); + const observed = evidence.windowsHeader; if (observed.state === "absent" || observed.state === "header-needed") return reject("MZ header does not declare a PE image"); + // A signature already observed in the DOS prefix establishes the layout + // limitation even when the later COFF record is truncated. + if (observed.state === "declared" && observed.signature === "PE") { + const limitation = mzWindowsHeaderLayoutLimitation(observed); + if (limitation !== undefined) + throw new BinaryFormatFailure("unsupported", limitation); + } const pe = observed.offset; - if (pe < 64) reject("PE signature overlaps the DOS header"); const coff = new BinaryReader(yield request(fileSize, BigInt(pe), 24n), true); - const confirmed = observeMzWindowsHeader(dos.bytes, fileSize, { - offset: pe, - bytes: coff.bytes, - }); + const confirmed = withMzWindowsHeaderSignature( + dos.bytes, + fileSize, + evidence, + { offset: pe, bytes: coff.bytes }, + ).windowsHeader; if (confirmed.state !== "declared" || confirmed.signature !== "PE") - reject("PE signature is invalid"); + return reject("PE signature is invalid"); + const limitation = mzWindowsHeaderLayoutLimitation(confirmed); + if (limitation !== undefined) + throw new BinaryFormatFailure("unsupported", limitation); const parsedCoff = readPeCoffHeader(coff.bytes.subarray(4, 24)); if (!parsedCoff.ok) return reject(parsedCoff.error.message); const { diff --git a/src/native/parsers/dyldInfo.ts b/src/native/parsers/dyldInfo.ts index 8ba8909939..9bb8074c3b 100644 --- a/src/native/parsers/dyldInfo.ts +++ b/src/native/parsers/dyldInfo.ts @@ -103,6 +103,16 @@ export const parseDyldImports = ( return { items, needsNameInventory }; }; +/** Apple prints two spaces after the address; one-space captures remain readable. */ +export const parseDyldExportAddressRow = ( + line: string, +): { readonly address: string; readonly text: string } | null => { + const match = /^(0x[\da-f]+)(?: {2}|[ \t])(.+)$/isu.exec(line); + return match?.[1] === undefined || match[2] === undefined + ? null + : { address: match[1], text: match[2] }; +}; + /** Decode Apple dyld export rows, projecting export offsets to VM addresses. */ export const parseDyldExports = ( output: string, @@ -133,29 +143,31 @@ export const parseDyldExports = ( : { sourceSymbol: reexport.sourceSymbol }), }, ]; - const exported = /^(0x[\da-f]+)\s+(.+?)(?:\s+\[([^\]]+)\])?$/isu.exec(line); - if (exported?.[1] === undefined || exported[2] === undefined) return []; - const rawName = /^0x[\da-f]+\s+(.+)$/isu.exec(line)?.[1]; - const literalName = rawName !== undefined && knownExportNames.has(rawName); + const row = parseDyldExportAddressRow(line); + if (row === null) return []; + const exported = /^(.+?)(?:\s+\[([^\]]+)\])?$/su.exec(row.text); + if (exported?.[1] === undefined) return []; + const rawName = row.text; + const literalName = knownExportNames.has(rawName); // If nm establishes both interpretations, the textual row cannot bind an address or flags. // Let the caller retain the exact nm names without guessing which one owns this row. if ( literalName && - exported[3] !== undefined && - knownExportNames.has(exported[2]) + exported[2] !== undefined && + knownExportNames.has(exported[1]) ) return []; - const annotation = literalName ? undefined : exported[3]; + const annotation = literalName ? undefined : exported[2]; const absolute = /\babsolute\b/u.test(annotation ?? ""); const address = offsets && !absolute ? imageBase === null ? null - : `0x${(BigInt(imageBase) + BigInt(exported[1])).toString(16)}` - : canonicalHex(exported[1]); + : `0x${(BigInt(imageBase) + BigInt(row.address)).toString(16)}` + : canonicalHex(row.address); return [ { - name: literalName ? rawName : exported[2], + name: literalName ? rawName : exported[1], address, weak: /\bweak-def\b/u.test(annotation ?? "") ? true : null, reexport: false, @@ -185,7 +197,7 @@ const parseReexportRow = ( line: string, knownNames: ReadonlySet, ): ReexportRow | null => { - const prefix = /^\[re-export\]\s+/u.exec(line); + const prefix = /^\[re-export\] /u.exec(line); if (prefix === null) return null; const rest = line.slice(prefix[0].length); if (rest.length === 0) return null; diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 8fd62a06b3..b504c6390a 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -308,7 +308,7 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { fields, }); collectEntrypoint(kind, fields, state); - collectBuild(kind, block, fields, state); + collectBuild(kind, parsed, state); collectDependency(kind, fields, state); if (PAYLOAD_DEPENDENCY_COMMANDS.has(kind)) state.omittedDependencyCommands.push(kind); @@ -387,12 +387,12 @@ const collectEntrypoint = ( const collectBuild = ( kind: string, - block: string, - fields: Readonly>, + parsed: ParsedFields, state: ParsedLoadCommands, ): void => { + const fields = parsed.values; if (kind === "LC_BUILD_VERSION") { - const tools = parseBuildTools(block); + const tools = parseBuildTools(parsed.rows); if (numberField(fields, "ntools") !== tools.length) throw new TypeError("otool build-tool count does not match ntools"); state.builds.push({ @@ -517,8 +517,6 @@ const parseField = (rawLine: string): readonly [string, string] | undefined => { // exactly, including surrounding spaces, under otool's ordinal label. const option = /^\s*(string #[1-9]\d*)(?: (.*))?$/u.exec(rawLine); if (option?.[1] !== undefined) return [option[1], option[2] ?? ""]; - const fixedName = /^\s*(segname|sectname) (.*)$/u.exec(rawLine); - if (fixedName?.[1] !== undefined) return [fixedName[1], fixedName[2] ?? ""]; const line = rawLine.trim(); // These are the compound field labels used by otool. Keeping this list // explicit prevents a one-word field value such as `sectname version` from @@ -586,16 +584,23 @@ const parseSections = (block: string): ParsedSection[] => }; }); +/** Project tool/version pairs from the same ordered facts as command fields. */ const parseBuildTools = ( - block: string, + rows: ParsedFields["rows"], ): Array<{ name: string; version: string }> => { const tools: Array<{ name: string; version: string }> = []; - const lines = block.split(/\r?\n/u).map((line) => line.trim()); - for (let index = 0; index < lines.length - 1; index += 1) { - const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1]; - const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1]; - if (tool !== undefined && version !== undefined) - tools.push({ name: BUILD_TOOL_NAMES.get(tool) ?? tool, version }); + for (let index = 0; index < rows.length; index++) { + const field = rows[index]; + if (field?.[0] === "version") + throw new TypeError("otool build-tool version has no preceding tool"); + if (field?.[0] !== "tool") continue; + const version = rows[++index]; + if (field[1] === "" || version?.[0] !== "version" || version[1] === "") + throw new TypeError("otool build tool is missing its name or version"); + tools.push({ + name: BUILD_TOOL_NAMES.get(field[1]) ?? field[1], + version: version[1], + }); } return tools; }; diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index 2837a0a9e5..d2ca19b19d 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -2,6 +2,10 @@ import { expect, it } from "vitest"; import { buildBinary, type PlistValue } from "plist"; import { MAX_JSON_DEPTH, jsonValueSchema } from "../../domain/jsonValue.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { plistUidValue } from "../../domain/apple/plistUid.js"; +import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; +import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { parseXmlPropertyList } from "../../domain/propertyListKeys.js"; import { decodeBinaryPlistScalars, scanBinaryPlistScalars, @@ -20,6 +24,35 @@ import { binaryPlistWithOverlappingDateAndData, } from "../../../tests/fixtures/binaryPlistObjectTable.js"; +it("retains binary dictionary identity after native plist number projection", () => { + const result = parsePlistBinary( + Buffer.from(buildBinary({ nested: { CF$UID: 7 }, reference: { UID: 7 } })), + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const value = result.value.value as Record; + expect(value).toEqual({ nested: { CF$UID: 7 }, reference: { UID: 7 } }); + expect(plistUidValue(value["nested"])).toBeUndefined(); + expect(plistUidValue(value["reference"])).toBe(7); +}); + +it("projects wide integers in binary CF$UID dictionaries as data", () => { + const bytes = Buffer.alloc(27 + 3 + 32); + bytes.write("bplist00"); + bytes.set([0xd1, 1, 2, 0x56], 8); + bytes.write("CF$UID", 12); + bytes[18] = 0x13; + bytes.writeBigInt64BE(9007199254740993n, 19); + const result = parsePlistBinary( + binaryPlistObjectTable(bytes, [8, 11, 18], 27), + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.value).toEqual({ + CF$UID: { $plist_type: "integer", decimal: "9007199254740993" }, + }); +}); + it.each([ "binary data", "XML data", @@ -877,3 +910,63 @@ it("retains nested own dictionary names consistently in native JSON, XML and bin ); } }); + +it.each([ + [ + "integer", + "9007199254740992", + "9007199254740992", + { $plist_type: "integer", decimal: "9007199254740992" }, + ], + [ + "real", + "9007199254740992", + "9007199254740992", + 9007199254740992, + ], + [ + "integer/real collision", + "90071992547409929007199254740992", + "9007199254740992,9007199254740992", + 9007199254740992, + ], +] as const)( + "shares %s association across native JSON, XML and archive projection", + (_label, elements, json, expected) => { + const xml = `${elements}`; + const nativeJson = readPlistJson(`[${json}]`).project(xml); + const nativeXml = parsePlistXml(xml); + if (!nativeJson.ok) throw nativeJson.error; + if (!nativeXml.ok) throw nativeXml.error; + const decoded = parseXmlPropertyList(xml); + const numbers = createPlistNumberProjection(decoded.numberLiterals); + const archive = numbers.project(projectPlistValue(decoded.value).value); + const values = json.split(",").map(() => expected); + expect(nativeJson.value.value).toEqual(values); + expect(nativeXml.value.value).toEqual(values); + expect(archive).toEqual(values); + }, +); + +it("retains JSON source spelling when XML integers collide after rounding", () => { + const xml = + "90071992547409929007199254740993"; + const json = readPlistJson("[9007199254740992,9007199254740993]").project( + xml, + ); + const converted = parsePlistXml(xml); + if (!json.ok) throw json.error; + if (!converted.ok) throw converted.error; + expect(json.value.value).toEqual([ + { $plist_type: "integer", decimal: "9007199254740992" }, + { $plist_type: "integer", decimal: "9007199254740993" }, + ]); + expect(converted.value.value).toEqual([9007199254740992, 9007199254740992]); + expect(json.value.limitations).toEqual([ + expect.stringContaining("2 integer value(s)"), + ]); + expect(converted.value.limitations).toEqual([ + expect.stringContaining("XML conversion"), + expect.stringContaining("2 number(s)"), + ]); +}); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 0fd1b9c132..c3d4b78822 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -23,6 +23,7 @@ import { err, ok, type Result } from "../../domain/result.js"; import { incompletePlistNumberLimitation, createPlistNumberProjection, + resolvePlistNumberSource, type PlistNumberLiterals, } from "../../domain/apple/plistNumbers.js"; import { @@ -201,17 +202,17 @@ const projectPlistJsonAttempt = ( return negativeZeroReal(); } if (item instanceof PlistJsonIntegerLiteral) { - const integer = - literals?.incomplete === false && - literals.integers.get(item.value)?.has(item.digits) === true; - const real = - literals?.incomplete === false && literals.reals.has(item.value); - if (integer && !real) { + const source = resolvePlistNumberSource( + item.value, + literals, + item.digits, + ); + if (source.kind === "integer") { exactIntegerCount += 1; - return { $plist_type: "integer", decimal: item.digits }; + return { $plist_type: "integer", decimal: source.decimal }; } // An integral decodes exactly; only its JSON spelling is integral. - if (real && !integer) return item.value; + if (source.kind === "real") return item.value; ambiguousNumberCount += 1; return item.value; } @@ -332,20 +333,14 @@ const projectPlistXml = ( let exactIntegerCount = 0; let ambiguousNumberCount = 0; const value = mapJsonNumbers(projected.value, (item) => { - const decimals = literals.integers.get(item); - if (decimals === undefined) return item; - const [decimal] = decimals; - if ( - literals.incomplete || - decimals.size !== 1 || - decimal === undefined || - literals.reals.has(item) - ) { + if (!literals.integers.has(item)) return item; + const source = resolvePlistNumberSource(item, literals); + if (source.kind !== "integer") { ambiguousNumberCount += 1; return item; } exactIntegerCount += 1; - return { $plist_type: "integer", decimal }; + return { $plist_type: "integer", decimal: source.decimal }; }); return ok({ value, @@ -376,6 +371,7 @@ export const parsePlistBinary = ( { budget: "the native plist decode budget", numberProjection: true, + uidProjection: true, maximumDepth: MAX_NATIVE_PLIST_NESTING_DEPTH, fail: (kind, message) => kind === "cycle" diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index aaaca68450..75171ad93b 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -1,3 +1,4 @@ +import { decodeAsn1Oid } from "../../domain/asn1Oid.js"; import { Asn1BerFailure, Asn1BerReader, @@ -85,33 +86,25 @@ const chargeDecodedName = (budget: DecodedNameBudget, bytes: number): void => { /** Decode an OBJECT IDENTIFIER content buffer as dotted decimal. */ export const decodeOid = (content: Buffer): string => { - if (content.length === 0) return fail("Empty OBJECT IDENTIFIER."); - if (content.length > MAX_OID_BYTES) - return fail(`OBJECT IDENTIFIER exceeds ${String(MAX_OID_BYTES)} bytes.`); - // Arcs are unbounded base-128 integers; the first encoded value combines - // the first two arcs and can itself span several bytes (2.999 -> 1079). - const values: bigint[] = []; - let value = 0n; - let arcBytes = 0; - for (let index = 0; index < content.length; index += 1) { - arcBytes += 1; - if (arcBytes > MAX_OID_ARC_BYTES) + const decoded = decodeAsn1Oid(content, { + bytes: MAX_OID_BYTES, + arcBytes: MAX_OID_ARC_BYTES, + }); + if (decoded.ok) return decoded.value; + switch (decoded.error.kind) { + case "empty": + return fail("Empty OBJECT IDENTIFIER."); + case "byte-limit": return fail( - `OBJECT IDENTIFIER arc exceeds ${String(MAX_OID_ARC_BYTES)} bytes.`, + `OBJECT IDENTIFIER exceeds ${String(decoded.error.limit)} bytes.`, ); - const byte = required(content[index], "Truncated OBJECT IDENTIFIER."); - value = (value << 7n) | BigInt(byte & 0x7f); - if ((byte & 0x80) === 0) { - values.push(value); - value = 0n; - arcBytes = 0; - } else if (index === content.length - 1) + case "arc-limit": + return fail( + `OBJECT IDENTIFIER arc exceeds ${String(decoded.error.limit)} bytes.`, + ); + case "truncated": return fail("Truncated OBJECT IDENTIFIER component."); } - const [combined, ...rest] = values; - if (combined === undefined) return fail("Empty OBJECT IDENTIFIER."); - const first = combined < 40n ? 0n : combined < 80n ? 1n : 2n; - return [first, combined - first * 40n, ...rest].join("."); }; const decodeUtf16Be = (bytes: Buffer, budget: DecodedNameBudget): string => { diff --git a/src/pcap/PcapConfiguration.test.ts b/src/pcap/PcapConfiguration.test.ts index 813f5d25e7..5cae66e65d 100644 --- a/src/pcap/PcapConfiguration.test.ts +++ b/src/pcap/PcapConfiguration.test.ts @@ -348,10 +348,12 @@ describe("parseTsharkJsonOutput", () => { expect(result.dnsQueries).toEqual([ { query: "example.com", - response_addresses: ["93.184.216.34"], - response_address_count: 1, + response_addresses: [], + response_address_count: 0, }, ]); + expect(result.unattributedDnsAddresses).toEqual(["93.184.216.34"]); + expect(result.partial).toBe(true); expect(result.tlsSniValues).toEqual(["example.com"]); expect(result.httpRequestUris).toEqual(["http://example.com/page"]); expect(result.protocolDistribution).toEqual( diff --git a/src/pcap/PcapDnsOutput.ts b/src/pcap/PcapDnsOutput.ts new file mode 100644 index 0000000000..738189bed5 --- /dev/null +++ b/src/pcap/PcapDnsOutput.ts @@ -0,0 +1,151 @@ +type Fields = (name: string) => readonly string[]; +interface Attribution { + readonly incomplete: boolean; + readonly assigned: readonly (readonly [string, string])[]; + readonly unattributed: readonly string[]; +} + +const dnsType = (value: string | undefined): number | null => { + if (value === undefined || !/^(?:[0-9]+|0x[0-9a-f]+)$/iu.test(value)) + return null; + const type = Number(value); + return Number.isInteger(type) && type <= 65535 ? type : null; +}; + +// DNS compares ASCII letters without changing retained producer spellings. +const nameKey = (name: string): string => + name.replace(/[A-Z]/gu, (letter) => letter.toLowerCase()); + +/** + * TShark visits answer, authority, then additional RRs, emitting one + * dns.resp.type per RR (including OPT), and type-specific value queues. + * SRV owners use dns.srv.* fields instead of dns.resp.name, so the owner + * queue advances only for other RR types. + * NSEC/NXT bitmaps may add type fields; the cardinality check declines those + * ambiguous flat inventories instead of reconstructing missing hierarchy. + * Validate that correspondence before joining any owner to an address. + */ +export const attributeDnsAddresses = (field: Fields): Attribution => { + const ipv4 = field("dns.a"); + const ipv6 = field("dns.aaaa"); + const addresses = [...ipv4, ...ipv6].filter((value) => value !== ""); + const unknown = (): Attribution => ({ + assigned: [], + unattributed: addresses, + incomplete: + addresses.length > 0 || + field("dns.flags.response").some( + (value) => value !== "0" && value !== "False", + ) || + field("dns.resp.name").length > 0 || + field("dns.resp.type").length > 0 || + ["dns.count.answers", "dns.count.auth_rr", "dns.count.add_rr"].some( + (key) => field(key).some((value) => value !== "0"), + ), + }); + const count = (key: string): number | null => { + const values = field(key); + if (values.length !== 1 || !/^\d+$/u.test(values[0]!)) return null; + const value = Number(values[0]); + return Number.isInteger(value) && value <= 65535 ? value : null; + }; + const response = field("dns.flags.response"); + const questions = field("dns.qry.name"); + const questionTypes = field("dns.qry.type"); + const answers = count("dns.count.answers"); + const authority = count("dns.count.auth_rr"); + const additional = count("dns.count.add_rr"); + if (answers === null || authority === null || additional === null) + return unknown(); + const owners = field("dns.resp.name"); + const types = field("dns.resp.type").map(dnsType); + const aliases = field("dns.cname"); + // OPT stores a UDP payload size instead of dns.resp.class. mDNS uses + // these same class fields with QU/cache-flush bits masked by the producer. + const classes = field("dns.resp.class").map(dnsType); + if ( + types.length !== answers + authority + additional || + owners.length !== types.filter((type) => type !== 33).length || + types.some((value) => value === null) || + types.filter((type) => type === 1).length !== ipv4.length || + types.filter((type) => type === 28).length !== ipv6.length || + types.filter((type) => type === 5).length !== aliases.length || + [...ipv4, ...ipv6, ...aliases].some((value) => value === "") + ) + return unknown(); + if (addresses.length === 0) + return { assigned: [], unattributed: [], incomplete: false }; + // Only IN establishes Internet address semantics. An ANY-class question + // also permits IN answers; other classes retain the producer values without + // interpreting their class-specific RDATA as a resolved Internet address. + const questionClasses = field("dns.qry.class"); + if ( + classes.length !== types.filter((type) => type !== 41).length || + classes.some((value) => value === null) || + questionClasses.length !== 1 || + ![1, 255].includes(dnsType(questionClasses[0]) ?? -1) || + response.length !== 1 || + !["1", "True"].includes(response[0]!) || + count("dns.count.queries") !== 1 || + questions.length !== 1 || + questions[0] === "" || + questionTypes.length !== 1 || + ![1, 28, 255].includes(dnsType(questionTypes[0]) ?? -1) + ) + return unknown(); + let a = 0, + aaaa = 0, + cname = 0, + owner = 0, + rrClass = 0; + const records = types.map((type, index) => ({ + owner: type === 33 ? "" : nameKey(owners[owner++]!), + type, + dnsClass: type === 41 ? null : classes[rrClass++], + answer: index < answers, + value: + type === 1 + ? ipv4[a++] + : type === 28 + ? ipv6[aaaa++] + : type === 5 + ? aliases[cname++] + : undefined, + })); + const reachable = new Set([nameKey(questions[0]!)]); + const links = new Map>(); + for (const record of records) + if (record.answer && record.type === 5 && record.dnsClass === 1) { + if (record.owner === "") return unknown(); + const targets = links.get(record.owner) ?? new Set(); + targets.add(nameKey(record.value!)); + links.set(record.owner, targets); + } + // A conflicting CNAME does not establish which target the question resolves. + let current = nameKey(questions[0]!); + while (links.has(current)) { + const targets = links.get(current)!; + if (targets.size !== 1) return unknown(); + const next = targets.values().next().value!; + if (reachable.has(next)) return unknown(); + reachable.add(next); + current = next; + } + const assigned: (readonly [string, string])[] = []; + const unattributed: string[] = []; + for (const record of records) { + if (record.type !== 1 && record.type !== 28) continue; + if ( + record.answer && + record.dnsClass === 1 && + record.owner !== "" && + reachable.has(record.owner) && + (dnsType(questionTypes[0]) === 255 || + dnsType(questionTypes[0]) === record.type) && + !links.has(record.owner) + ) + assigned.push([questions[0]!, record.value!]); + else unattributed.push(record.value!); + } + return { assigned, unattributed, incomplete: unattributed.length > 0 }; +}; diff --git a/src/pcap/PcapOutput.test.ts b/src/pcap/PcapOutput.test.ts index fa14f13e6f..39d9ccc89e 100644 --- a/src/pcap/PcapOutput.test.ts +++ b/src/pcap/PcapOutput.test.ts @@ -16,6 +16,318 @@ const capture = (layers: ReadonlyArray>) => })), ); +// Representative selected-field output for one A reply with a CNAME answer +// and unrelated additional glue. This is not a recorded real-provider run. +const cnameAndGlue = { + "dns.flags.response": ["1"], + "dns.qry.name": ["www.example"], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["2"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["1"], + "dns.resp.name": ["www.example", "edge.example", "ns.example"], + "dns.resp.type": ["5", "1", "1"], + "dns.resp.class": ["0x0001", "0x0001", "0x0001"], + "dns.cname": ["edge.example"], + "dns.a": ["192.0.2.10", "192.0.2.53"], +}; + +// Source-backed selected-field shape: Wireshark 4.x add_rr_to_tree emits +// dns.srv.* instead of dns.resp.name for SRV owners. Not a provider recording. +// https://github.com/wireshark/wireshark/blob/v4.4.10/epan/dissectors/packet-dns.c#L1926-L1976 +const srvAndAddresses = { + "dns.flags.response": ["1"], + "dns.qry.name": ["WWW.Example"], + "dns.qry.type": ["255"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["4"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["1"], + "dns.resp.type": ["33", "1", "33", "28", "1"], + "dns.resp.class": ["0x0001", "0x0001", "0x0001", "0x0001", "0x0001"], + "dns.resp.name": ["www.example", "www.example", "ns.example"], + "dns.a": ["192.0.2.10", "192.0.2.53"], + "dns.aaaa": ["2001:db8::10"], +}; + +it("keeps address-free SRV answers complete without requiring dns.resp.name", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...srvAndAddresses, + "dns.qry.name": ["_sip._tcp.example"], + "dns.qry.type": ["33"], + "dns.qry.class": ["0x0001"], + "dns.count.answers": ["1"], + "dns.count.add_rr": ["0"], + "dns.resp.type": ["33"], + "dns.resp.class": ["0x0001"], + "dns.resp.name": [], + "dns.a": [], + "dns.aaaa": [], + }, + ]), + ); + expect(result.partial).toBe(false); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); +}); + +it("aligns A and AAAA owners around SRV records while retaining additional glue separately", async () => { + const result = await parseTsharkJsonOutput(capture([srvAndAddresses])); + expect(result.dnsQueries).toEqual([ + { + query: "WWW.Example", + response_addresses: ["192.0.2.10", "2001:db8::10"], + response_address_count: 2, + }, + ]); + expect(result.unattributedDnsAddresses).toEqual(["192.0.2.53"]); + expect(result.partial).toBe(true); +}); + +it.each([ + { "dns.resp.name": ["www.example", "ns.example"] }, + { + "dns.resp.name": [ + "srv.example", + "www.example", + "www.example", + "ns.example", + ], + }, + { "dns.resp.type": ["33", "1", "33", "28"] }, + { "dns.count.answers": ["4", "0"] }, + { "dns.flags.response": ["1", "1"] }, +])("declines ambiguous SRV owner inventories: %j", async (override) => { + const result = await parseTsharkJsonOutput( + capture([{ ...srvAndAddresses, ...override }]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual([ + "192.0.2.10", + "192.0.2.53", + "2001:db8::10", + ]); + expect(result.partial).toBe(true); +}); + +it("reports unknown RR coverage when a DNS response omits all count and address fields", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + "dns.flags.response": ["1"], + "dns.qry.name": ["www.example"], + }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual([]); + expect(result.partial).toBe(true); +}); + +it("keeps a query with an additional OPT record complete", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...cnameAndGlue, + "dns.flags.response": ["0"], + "dns.count.answers": ["0"], + "dns.resp.name": [""], + "dns.resp.type": ["41"], + "dns.resp.class": [], + "dns.cname": [], + "dns.a": [], + }, + ]), + ); + expect(result.partial).toBe(false); +}); + +it("preserves a CNAME answer while separating additional glue", async () => { + const result = await parseTsharkJsonOutput(capture([cnameAndGlue])); + expect(result.dnsQueries).toEqual([ + { + query: "www.example", + response_addresses: ["192.0.2.10"], + response_address_count: 1, + }, + ]); + expect(result.unattributedDnsAddresses).toEqual(["192.0.2.53"]); + expect(result.unattributedDnsPackets).toBe(1); + expect(result.partial).toBe(true); +}); + +it("accepts hexadecimal RR type codes without changing recorded identities", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...cnameAndGlue, + "dns.qry.type": ["0x0001"], + "dns.qry.class": ["0x0001"], + "dns.resp.type": ["0x0005", "0x0001", "0x0001"], + "dns.resp.class": ["0x0001", "0x0001", "0x0001"], + }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual(["192.0.2.10"]); + expect(result.unattributedDnsAddresses).toEqual(["192.0.2.53"]); +}); + +it("retains direct answers with a root-owned OPT and compares DNS names without rewriting them", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...cnameAndGlue, + "dns.qry.name": ["WWW.Example"], + "dns.count.answers": ["1"], + "dns.resp.name": ["www.example", ""], + "dns.resp.type": ["1", "41"], + "dns.resp.class": ["0x0001"], + "dns.cname": [], + "dns.a": ["192.0.2.10"], + }, + ]), + ); + expect(result.dnsQueries).toEqual([ + { + query: "WWW.Example", + response_addresses: ["192.0.2.10"], + response_address_count: 1, + }, + ]); + expect(result.partial).toBe(false); +}); + +it("joins mixed answer types using their own queues and preserves multiple valid answers", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...cnameAndGlue, + "dns.qry.type": ["255"], + "dns.qry.class": ["0x0001"], + "dns.count.answers": ["5"], + "dns.count.add_rr": ["0"], + "dns.resp.name": [ + "www.example", + "edge.example", + "edge.example", + "edge.example", + "edge.example", + ], + "dns.resp.type": ["5", "28", "1", "28", "1"], + "dns.resp.class": ["0x0001", "0x0001", "0x0001", "0x0001", "0x0001"], + "dns.a": ["192.0.2.10", "192.0.2.11"], + "dns.aaaa": ["2001:db8::10", "2001:db8::11"], + }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([ + "2001:db8::10", + "192.0.2.10", + "2001:db8::11", + "192.0.2.11", + ]); + expect(result.partial).toBe(false); +}); + +it.each([ + { "dns.resp.name": ["www.example", "unrelated.example", "ns.example"] }, + { "dns.count.answers": ["1"], "dns.count.auth_rr": ["1"] }, + { "dns.qry.type": ["28"] }, + { "dns.flags.response": ["1", "1"] }, + { "dns.count.answers": ["garbage"] }, + { "dns.count.answers": ["4"] }, + { "dns.count.answers": [] }, + { "dns.resp.name": ["www.example"] }, + { "dns.resp.type": ["5", "28", "1"] }, + { "dns.cname": [] }, + { "dns.cname": ["www.example"] }, + { "dns.count.queries": ["2"] }, + // NSEC/NXT type bitmaps can contribute additional dns.resp.type fields. + { "dns.resp.type": ["5", "1", "47", "1", "28"] }, +] as Record[])( + "retains all addresses when ownership is unsupported or inconsistent: %j", + async (override) => { + const result = await parseTsharkJsonOutput( + capture([{ ...cnameAndGlue, ...override }]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual([ + "192.0.2.10", + "192.0.2.53", + ]); + expect(result.partial).toBe(true); + }, +); + +it("does not follow conflicting CNAME declarations", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...cnameAndGlue, + "dns.count.answers": ["4"], + "dns.count.add_rr": ["0"], + "dns.resp.name": [ + "www.example", + "www.example", + "edge.example", + "other.example", + ], + "dns.resp.type": ["5", "5", "1", "1"], + "dns.resp.class": ["0x0001", "0x0001", "0x0001", "0x0001"], + "dns.cname": ["edge.example", "other.example"], + }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual(["192.0.2.10", "192.0.2.53"]); +}); + +it.each([ + { "dns.a": [] }, + { "dns.a": [], "dns.resp.name": [], "dns.resp.type": [] }, +])( + "marks missing address/owner decoding partial even when no addresses were extracted: %j", + async (override) => { + const result = await parseTsharkJsonOutput( + capture([{ ...cnameAndGlue, ...override }]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual([]); + expect(result.partial).toBe(true); + }, +); + +it.each([ + { "dns.count.answers": ["0"], "dns.resp.name": [], "dns.resp.type": [] }, + { + "dns.count.answers": ["1"], + "dns.resp.name": ["www.example"], + "dns.resp.type": ["2"], + "dns.resp.class": ["0x0001"], + }, +])( + "keeps valid empty or non-address answers complete: %j", + async (override) => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...cnameAndGlue, + "dns.count.add_rr": ["0"], + "dns.cname": [], + "dns.a": [], + ...override, + }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.partial).toBe(false); + }, +); + it.each([ [[], []], [["", ""], []], @@ -64,6 +376,15 @@ it.each(["before", "after"] as const)( "dns.qry.name": ["api.example"], "dns.flags.response": ["True"], "dns.a": ["203.0.113.5"], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["api.example"], + "dns.resp.type": ["1"], + "dns.resp.class": ["0x0001"], }; const result = await parseTsharkJsonOutput( capture(position === "before" ? [empty, response] : [response, empty]), @@ -112,11 +433,29 @@ it("binds interleaved DNS answers to the question in their response packet", asy "dns.qry.name": ["alpha.example"], "dns.flags.response": ["True"], "dns.a": ["192.0.2.10"], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["alpha.example"], + "dns.resp.type": ["1"], + "dns.resp.class": ["0x0001"], }, { "dns.qry.name": ["beta.example"], "dns.flags.response": ["True"], "dns.a": ["192.0.2.20"], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["beta.example"], + "dns.resp.type": ["1"], + "dns.resp.class": ["0x0001"], }, ]), ); @@ -197,6 +536,15 @@ it("preserves all DNS addresses and their exact examined count", async () => { "dns.qry.name": ["rotating.example"], "dns.flags.response": ["True"], "dns.a": [`192.0.2.${String(index)}`], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["rotating.example"], + "dns.resp.type": ["1"], + "dns.resp.class": ["0x0001"], })), ), ); @@ -348,3 +696,79 @@ it("includes decoded HTTP/2 URIs with shared credential handling and reports unr ).partial, ).toBe(true); }); + +// Wireshark 4.4.10 emits dns.aaaa without a class guard, and uses masked +// dns.qry.class/dns.resp.class for mDNS. These are source-backed fixtures, +// not recorded provider runs. +// https://github.com/wireshark/wireshark/blob/v4.4.10/epan/dissectors/packet-dns.c#L3022-L3030 +// https://github.com/wireshark/wireshark/blob/v4.4.10/epan/dissectors/packet-dns.c#L6265-L6308 +const classedAddressReply = { + "dns.flags.response": ["1"], + "dns.qry.name": ["host.example"], + "dns.qry.type": ["28"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["host.example"], + "dns.resp.type": ["28"], + "dns.resp.class": ["0x0001"], + "dns.aaaa": ["2001:db8::9"], +}; + +it.each([ + { "dns.qry.class": ["0x0003"] }, + { "dns.qry.class": [] }, + { "dns.qry.class": ["invalid"] }, + { "dns.qry.class": ["1", "1"] }, + { "dns.resp.class": ["0x0003"] }, + { "dns.resp.class": [] }, + { "dns.resp.class": ["invalid"] }, + { "dns.resp.class": ["1", "1"] }, +])( + "retains addresses with unsupported or unknown DNS classes: %j", + async (override) => { + const result = await parseTsharkJsonOutput( + capture([{ ...classedAddressReply, ...override }]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual(["2001:db8::9"]); + expect(result.partial).toBe(true); + }, +); + +it("does not follow a CNAME from another DNS class", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { ...cnameAndGlue, "dns.resp.class": ["0x0003", "0x0001", "0x0001"] }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual([]); + expect(result.unattributedDnsAddresses).toEqual(["192.0.2.10", "192.0.2.53"]); + expect(result.partial).toBe(true); +}); + +it.each(["0x0001", "0x00ff"])( + "accepts IN answers for question class %s around OPT and SRV", + async (questionClass) => { + const result = await parseTsharkJsonOutput( + capture([ + { + ...classedAddressReply, + "frame.protocols": ["ip:udp:mdns"], + "dns.qry.class": [questionClass], + "dns.qry.qu": ["1"], + "dns.resp.cache_flush": ["1", "0"], + "dns.count.answers": ["3"], + "dns.resp.type": ["41", "33", "28"], + "dns.resp.name": ["", "host.example"], + "dns.resp.class": ["0x0001", "0x0001"], + }, + ]), + ); + expect(result.dnsQueries[0]?.response_addresses).toEqual(["2001:db8::9"]); + expect(result.unattributedDnsAddresses).toEqual([]); + expect(result.partial).toBe(false); + }, +); diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts index ada7ebc8eb..b181580ddc 100644 --- a/src/pcap/PcapOutput.ts +++ b/src/pcap/PcapOutput.ts @@ -6,6 +6,7 @@ import { } from "../domain/analysisErrorCore.js"; import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; +import { attributeDnsAddresses } from "./PcapDnsOutput.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const OPERATION = "inspect_pcap_capture"; @@ -222,6 +223,7 @@ export const parseTsharkJsonOutput = async ( let unresolvedHttpUriTarget = false; const unattributed = new Set(); let unattributedPackets = 0; + let unresolvedDnsOwnership = false; let retainedBytes = 0; const charge = (value: string): void => { retainedBytes += 192 + 2 * Buffer.byteLength(value); @@ -304,21 +306,15 @@ export const parseTsharkJsonOutput = async ( charge(question); dns.set(question, new Set()); } - const addresses = [...field("dns.a"), ...field("dns.aaaa")].filter( - (value) => value !== "", - ); - const response = field("dns.flags.response"); - if (addresses.length > 0) { - if ( - questions.size === 1 && - response.length > 0 && - response.every((value) => value === "True" || value === "1") - ) { - const values = dns.get(questions.values().next().value!)!; - for (const address of addresses) add(values, address); - } else { + { + const attribution = attributeDnsAddresses(field); + unresolvedDnsOwnership ||= attribution.incomplete; + for (const [question, address] of attribution.assigned) + add(dns.get(question)!, address); + if (attribution.unattributed.length > 0) { unattributedPackets += 1; - for (const address of addresses) add(unattributed, address); + for (const address of attribution.unattributed) + add(unattributed, address); } } for (const value of field("tls.handshake.extensions_server_name")) @@ -422,7 +418,7 @@ export const parseTsharkJsonOutput = async ( packetCount: packets.length, partial: observedUris.size > uris.size || - unattributedPackets > 0 || + unresolvedDnsOwnership || unresolvedHttpUriTarget, }; }; diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index ab316ac025..c62112e6d2 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -75,6 +75,16 @@ const FIELDS = [ "ipv6.dst", "dns.flags.response", "dns.qry.name", + "dns.qry.type", + "dns.qry.class", + "dns.count.queries", + "dns.count.answers", + "dns.count.auth_rr", + "dns.count.add_rr", + "dns.resp.name", + "dns.resp.type", + "dns.resp.class", + "dns.cname", "dns.a", "dns.aaaa", "tls.handshake.extensions_server_name", @@ -1059,7 +1069,7 @@ export class PcapProvider { limitations: [ ...this.nativeLimitations, "Both tools read one owned snapshot; target identity comes from its admitted source descriptor", - "DNS address values are associated only with a single question in the same response packet; values may include additional DNS records, not only final resolution answers", + "DNS address attribution requires consistent RR owner/type and section counts for one question in one response; only matching answer owners or unambiguous answer-section CNAME chains are associated. Authority, additional, unrelated and ambiguous addresses are retained separately as unattributed; these packet-local relationships do not prove final resolution", "Only bytes present in the saved capture are examined; encryption and capture truncation can hide higher-level fields", "HTTP URI inventory examines decoded HTTP/1 and HTTP/2 request fields; HTTP/3 URI semantics are outside this profile", ...(fields.partial diff --git a/src/process/HopperLease.ts b/src/process/HopperLease.ts index 382acd3e25..cee9991d2a 100644 --- a/src/process/HopperLease.ts +++ b/src/process/HopperLease.ts @@ -1,8 +1,15 @@ import { realpath } from "node:fs/promises"; import { join } from "node:path"; + +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, +} from "../artifacts/ArtifactFileOperation.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { HopperStartError } from "../domain/hopperErrors.js"; import { acquireOwnedLease, + OwnedLeaseCleanupFailure, type LeaseAcquisition, type LeaseFailure, } from "./OwnedLease.js"; @@ -20,6 +27,7 @@ export const acquireHopperTargetLease = async (input: { readonly loaderArgs: readonly string[]; readonly runId: string; readonly directory?: string; + readonly resources?: ArtifactResourceScope | undefined; }): Promise => { // Canonicalization is required for lease identity. A lexical fallback could // let two aliases reserve independent sockets for the same Hopper document. @@ -46,6 +54,7 @@ export const acquireHopperTargetLease = async (input: { export const acquireLinuxHopperApplicationLease = (input: { readonly runId: string; readonly directory?: string; + readonly resources?: ArtifactResourceScope | undefined; }): Promise => acquireLease({ ...input, identity: { application: "linux-hopper-demo" } }); @@ -54,9 +63,11 @@ export const acquireHopperReleaseCacheLease = async (input: { readonly cacheDirectory: string; readonly launcherSha256: string; readonly runId: string; + readonly resources?: ArtifactResourceScope | undefined; }): Promise => acquireLease({ runId: input.runId, + resources: input.resources, identity: { cacheDirectory: await realpath(input.cacheDirectory), launcherSha256: input.launcherSha256, @@ -68,22 +79,42 @@ const acquireLease = async (input: { readonly identity: Readonly>; readonly runId: string; readonly directory?: string; + readonly resources?: ArtifactResourceScope | undefined; }): Promise => acquireOwnedLease({ identity: input.identity, runId: input.runId, directory: input.directory ?? HOPPER_TARGET_LEASE_DIRECTORY, failure: hopperLeaseFailure, + resources: input.resources, }); const hopperLeaseFailure = ( failure: LeaseFailure, cause?: unknown, -): HopperStartError => - new HopperStartError({ +): HopperStartError => { + const primary = + cause instanceof OwnedLeaseCleanupFailure + ? cause.primary + : cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? cause.fileCleanupFailure.outcome.cause + : undefined; + const error = new HopperStartError({ ...(cause === undefined ? {} : { cause }), - userMessage: hopperLeaseMessage(failure), + ...(primary instanceof HopperStartError ? { cleanupPrimary: primary } : {}), + ...(cause instanceof ArtifactFileCleanupFailure || + cause instanceof OwnedLeaseCleanupFailure + ? { cleanup: cause.cleanup } + : {}), + userMessage: + primary instanceof HopperStartError + ? (primary.userMessage ?? hopperLeaseMessage(failure)) + : hopperLeaseMessage(failure), }); + bindArtifactFileCleanup(cause, error); + return error; +}; const hopperLeaseMessage = (failure: LeaseFailure): string => { switch (failure.kind) { @@ -95,6 +126,10 @@ const hopperLeaseMessage = (failure: LeaseFailure): string => { return "REA's Hopper lease directory permissions could not be secured."; case "directory_access_denied": return `REA could not access its Hopper lease directory at ${JSON.stringify(failure.directory)}. Check its ownership and parent-directory access. For a directory you own, restore owner-only read, write, and search permissions (0700), then retry.`; + case "socket_permissions": + return "REA could not secure its lease socket permissions."; + case "directory_close": + return "REA could not confirm closure of its Hopper lease directory handle."; case "directory_inspect": return "REA could not inspect its private Hopper lease directory."; case "recovery_busy": diff --git a/src/process/OwnedLease.test.ts b/src/process/OwnedLease.test.ts new file mode 100644 index 0000000000..a31f157a74 --- /dev/null +++ b/src/process/OwnedLease.test.ts @@ -0,0 +1,541 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createConnection, Server } from "node:net"; +import { promisify } from "node:util"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; + +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { HopperStartError } from "../domain/hopperErrors.js"; +const createTestTempDirectory = async (prefix: string): Promise => { + const directory = await mkdtemp(join("/tmp", prefix)); + onTestFinished(() => rm(directory, { recursive: true, force: true })); + return directory; +}; + +const injection = vi.hoisted(() => ({ + directory: undefined as string | undefined, + admission: "accepted" as "accepted" | "not_private" | "access_denied", + invalidateOnClose: false, + closeCalls: 0, + recoveryRemoveFailures: 0, + staleRemoveFailure: false, + socketPermissionsFailure: false, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + chmod: async (...args: Parameters) => { + if (injection.socketPermissionsFailure) + throw new Error("injected socket permission failure"); + return actual.chmod(...args); + }, + rm: async (...args: Parameters) => { + if (injection.staleRemoveFailure && String(args[0]).endsWith(".sock")) + throw new Error("injected stale socket removal failure"); + return actual.rm(...args); + }, + rmdir: async (...args: Parameters) => { + if (injection.recoveryRemoveFailures > 0) { + injection.recoveryRemoveFailures -= 1; + throw new Error("injected recovery removal failure"); + } + return actual.rmdir(...args); + }, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + if (args[0] !== injection.directory) return handle; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + injection.closeCalls += 1; + if (injection.closeCalls === 1) { + if (injection.invalidateOnClose) await close(); + throw new Error("injected directory close failure"); + } + await close(); + }); + if (injection.admission !== "accepted") { + const stat = handle.stat.bind(handle); + vi.spyOn(handle, "stat").mockImplementation(async () => { + if (injection.admission === "access_denied") + throw Object.assign(new Error("denied"), { code: "EACCES" }); + const observed = await stat(); + observed.isDirectory = () => false; + return observed; + }); + } + return handle; + }, + }; +}); + +const { acquireLinuxHopperApplicationLease } = await import("./HopperLease.js"); +const { acquireOwnedLease } = await import("./OwnedLease.js"); +const { HopperApplicationLauncher } = + await import("../hopper/BridgeLauncher.js"); + +afterEach(() => { + injection.directory = undefined; + injection.admission = "accepted"; + injection.invalidateOnClose = false; + injection.closeCalls = 0; + injection.recoveryRemoveFailures = 0; + injection.staleRemoveFailure = false; + injection.socketPermissionsFailure = false; + vi.restoreAllMocks(); +}); + +describe.skipIf(process.platform === "win32")( + "accepted lease connections", + () => { + it.each([false, true])( + "closes a held peer and preserves retry after failure=%s", + async (failClose) => { + const directory = await createTestTempDirectory("rea-peer-"); + const input = { + identity: { test: "held-peer" }, + directory, + runId: "held-peer", + failure: (failure: { kind: string }) => new Error(failure.kind), + }; + const acquired = await acquireOwnedLease(input); + if (!acquired.acquired) throw new Error("expected acquired lease"); + onTestFinished(() => acquired.lease.release()); + const contender = await acquireOwnedLease({ + ...input, + runId: "contender", + }); + expect(contender).toEqual({ + acquired: false, + owner: { runId: input.runId, processId: process.pid }, + }); + const peer = createConnection({ + path: acquired.lease.socketPath!, + allowHalfOpen: true, + }); + onTestFinished(() => { + peer.destroy(); + }); + peer.resume(); + await new Promise((resolve, reject) => { + peer.once("end", resolve); + peer.once("error", reject); + }); + expect(peer.writable).toBe(true); + if (failClose) { + const close = Server.prototype.close; + vi.spyOn(Server.prototype, "close") + .mockImplementationOnce(function (this: Server, callback) { + callback?.(new Error("injected socket close failure")); + return this; + }) + .mockImplementation(function (this: Server, callback) { + return close.call(this, callback); + }); + await expect(acquired.lease.release()).rejects.toThrow( + "injected socket close failure", + ); + } + // The peer stays open until the owner releases; a test timeout catches the + // original server.close deadlock without relying on elapsed-time assertions. + await acquired.lease.release(); + const next = await acquireOwnedLease(input); + expect(next.acquired).toBe(true); + if (next.acquired) await next.lease.release(); + }, + ); + }, +); + +describe.skipIf(process.platform === "win32")( + "lease socket recovery finalization", + () => { + it("preserves the failed recovery operation when its directory cleanup also fails", async () => { + const directory = await createTestTempDirectory("rea-recovery-primary-"); + const key = createHash("sha256") + .update(JSON.stringify({ application: "linux-hopper-demo" })) + .digest("hex") + .slice(0, 32); + const socketPath = join(directory, `${key}.sock`); + await promisify(execFile)(process.execPath, [ + "--input-type=module", + "-e", + 'import {createServer} from "node:net"; createServer().listen(process.argv[1],()=>process.exit(0));', + socketPath, + ]); + injection.staleRemoveFailure = true; + injection.recoveryRemoveFailures = 1; + const resources = new ArtifactResourceScope(); + const failure = await acquireLinuxHopperApplicationLease({ + directory, + runId: "recovery-primary", + resources, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(HopperStartError); + if (!(failure instanceof HopperStartError)) return; + expect(failure.cause).toMatchObject({ + primary: expect.objectContaining({ + message: "injected stale socket removal failure", + }), + }); + expect(failure.cleanupIncomplete).toBe(true); + await resources.retryCleanup(); + expect(resources.hasPending).toBe(false); + expect(failure.cleanupIncomplete).toBe(false); + }); + + it.each([false, true])( + "retains a bound socket when permission hardening and final close fail during recovery=%s", + async (recovering) => { + const directory = await createTestTempDirectory( + "rea-socket-permissions-", + ); + if (recovering) { + const key = createHash("sha256") + .update(JSON.stringify({ application: "linux-hopper-demo" })) + .digest("hex") + .slice(0, 32); + await promisify(execFile)(process.execPath, [ + "--input-type=module", + "-e", + 'import {createServer} from "node:net"; createServer().listen(process.argv[1],()=>process.exit(0));', + join(directory, `${key}.sock`), + ]); + injection.recoveryRemoveFailures = 1; + } + const close = Server.prototype.close; + let closeFailures = 1; + vi.spyOn(Server.prototype, "close").mockImplementation(function ( + this: Server, + callback, + ) { + if (closeFailures-- > 0) { + callback?.(new Error("injected socket close failure")); + return this; + } + return close.call(this, callback); + }); + injection.socketPermissionsFailure = true; + const resources = new ArtifactResourceScope(); + const failure = await acquireLinuxHopperApplicationLease({ + directory, + runId: "permissions", + resources, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(HopperStartError); + if (!(failure instanceof HopperStartError)) return; + let primary = failure; + while (primary.cleanupPrimary instanceof HopperStartError) + primary = primary.cleanupPrimary; + expect(primary.cause).toMatchObject({ + message: "injected socket permission failure", + }); + expect(failure.userMessage).toContain("socket permissions"); + expect(failure.cleanupIncomplete).toBe(true); + expect(resources.cleanupResources).toHaveLength(recovering ? 2 : 1); + await resources.retryCleanup(); + expect(failure.cleanupIncomplete).toBe(false); + injection.socketPermissionsFailure = false; + const next = await acquireLinuxHopperApplicationLease({ + directory, + runId: "next", + resources, + }); + expect(next.acquired).toBe(true); + if (next.acquired) await next.lease.release(); + }, + ); + }, +); + +describe.skipIf(process.platform === "win32")( + "lease recovery cleanup retries", + () => { + it.each(["socket", "directory"] as const)( + "retains both failed recovery owners while %s needs another retry", + async (remaining) => { + const directory = await createTestTempDirectory("rea-recovery-close-"); + const key = createHash("sha256") + .update(JSON.stringify({ application: "linux-hopper-demo" })) + .digest("hex") + .slice(0, 32); + const socketPath = join(directory, `${key}.sock`); + await promisify(execFile)(process.execPath, [ + "--input-type=module", + "-e", + 'import {createServer} from "node:net"; createServer().listen(process.argv[1],()=>process.exit(0));', + socketPath, + ]); + const close = Server.prototype.close; + let closeFailures = remaining === "socket" ? 2 : 1; + vi.spyOn(Server.prototype, "close").mockImplementation(function ( + this: Server, + callback, + ) { + if (closeFailures > 0) { + closeFailures -= 1; + callback?.(new Error("injected socket close failure")); + return this; + } + return close.call(this, callback); + }); + injection.recoveryRemoveFailures = remaining === "directory" ? 2 : 1; + const resources = new ArtifactResourceScope(); + const failure = await acquireLinuxHopperApplicationLease({ + directory, + runId: "recovery", + resources, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(HopperStartError); + if (!(failure instanceof HopperStartError)) return; + expect(failure.cleanupIncomplete).toBe(true); + expect(failure.cleanupResources).toEqual( + expect.arrayContaining([socketPath, `${socketPath}.recovery`]), + ); + expect(resources.cleanupResources).toHaveLength(2); + await expect(resources.retryCleanup()).rejects.toThrow(); + const remainingPath = + remaining === "socket" ? socketPath : `${socketPath}.recovery`; + expect(resources.cleanupResources).toEqual([remainingPath]); + expect(failure.cleanupResources).toEqual([remainingPath]); + expect(failure.cleanupIncomplete).toBe(true); + await resources.retryCleanup(); + expect(resources.hasPending).toBe(false); + expect(failure.cleanupIncomplete).toBe(false); + const next = await acquireLinuxHopperApplicationLease({ + directory, + runId: "next", + resources, + }); + expect(next.acquired).toBe(true); + if (next.acquired) await next.lease.release(); + }, + ); + }, +); + +describe.skipIf(process.platform === "win32")( + "lease directory finalization", + () => { + it.each(["accepted", "not_private", "access_denied"] as const)( + "retains failed directory close after admission failure=%s", + async (admission) => { + const root = await createTestTempDirectory("rea-lease-close-"); + injection.directory = join(root, "leases"); + injection.admission = admission; + const resources = new ArtifactResourceScope(); + const failure = await acquireLinuxHopperApplicationLease({ + runId: "directory-close-test", + directory: injection.directory, + resources, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(HopperStartError); + if (!(failure instanceof HopperStartError)) return; + expect(failure.cleanupIncomplete).toBe(true); + expect(resources.cleanupResources).toEqual([injection.directory]); + expect(failure.cleanupResources).toEqual([injection.directory]); + if (admission !== "accepted") { + expect(failure.userMessage).toContain( + admission === "not_private" + ? "not a private directory" + : "could not access", + ); + expect(failure.cleanupPrimary).toBeInstanceOf(HopperStartError); + } else + expect(failure.userMessage).toContain("could not confirm closure"); + await resources.retryCleanup(); + expect(injection.closeCalls).toBe(2); + expect(failure.cleanupIncomplete).toBe(false); + expect(resources.hasPending).toBe(false); + injection.directory = undefined; + const retried = await acquireLinuxHopperApplicationLease({ + runId: "directory-close-retry", + directory: join(root, "leases"), + resources, + }); + expect(retried.acquired).toBe(true); + if (retried.acquired) await retried.lease.release(); + }, + ); + + it("retries the directory owner through the actual Hopper launcher cleanup", async () => { + const root = await createTestTempDirectory("rea-launch-lease-"); + injection.directory = join(root, "leases"); + injection.admission = "not_private"; + const launcher = new HopperApplicationLauncher( + { + environment: {}, + launcherPath: join(root, "hopper"), + targetPath: join(root, "target"), + targetKind: "database", + loaderArgs: [], + bridgeScriptPath: join(root, "bridge.py"), + launchMode: "verified_linux_demo", + demoHelperPath: join(root, "helper"), + }, + { + platform: "linux", + acquireApplicationLease: (input) => + acquireLinuxHopperApplicationLease({ + ...input, + directory: join(root, "leases"), + }), + retryPublisherCleanup: async () => undefined, + }, + ); + const launched = await launcher.launch({ + directory: root, + socketPath: join(root, "bridge.sock"), + token: "fixture-token", + runId: "launcher-lease-close-test", + }); + expect(launched.ok).toBe(false); + if (launched.ok) return; + expect(launched.error.userMessage).toContain("not a private directory"); + expect(launched.error.cleanupIncomplete).toBe(true); + expect(injection.closeCalls).toBe(1); + expect((await launcher.cleanup()).ok).toBe(true); + expect(injection.closeCalls).toBe(2); + expect(launched.error.cleanupIncomplete).toBe(false); + expect((await launcher.cleanup()).ok).toBe(true); + expect(injection.closeCalls).toBe(2); + }); + + it("retains uncertainty when Node invalidates the directory handle before rejecting close", async () => { + const root = await createTestTempDirectory("rea-lease-invalidated-"); + injection.directory = join(root, "leases"); + injection.invalidateOnClose = true; + const resources = new ArtifactResourceScope(); + const failure = await acquireLinuxHopperApplicationLease({ + runId: "invalidated-close-test", + directory: injection.directory, + resources, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(HopperStartError); + await expect(resources.retryCleanup()).rejects.toThrow(); + expect(injection.closeCalls).toBe(1); + expect(resources.hasPending).toBe(true); + if (failure instanceof HopperStartError) + expect(failure.cleanupIncomplete).toBe(true); + }); + }, +); + +describe.skipIf(process.platform === "win32")( + "launcher lease release finalization", + () => { + it.each(["bootstrap", "cancelled", "nested"] as const)( + "retains a lease whose release fails after %s startup failure", + async (stage) => { + const root = await createTestTempDirectory("rea-launch-release-"); + const release = vi + .fn() + .mockRejectedValueOnce(new Error("injected lease release failure")) + .mockResolvedValue(undefined); + const socketPath = join(root, "lease.sock"); + const controller = new AbortController(); + const probeRelease = vi + .fn() + .mockRejectedValueOnce(new Error("probe cleanup still pending")) + .mockResolvedValue(undefined); + const probeLease = new AnalysisCleanupLease(probeRelease, ["probe"]); + const launcher = new HopperApplicationLauncher( + { + environment: {}, + launcherPath: join(root, "hopper"), + targetPath: join(root, "target"), + targetKind: "database", + loaderArgs: [], + bridgeScriptPath: join(root, "bridge.py"), + launchMode: "verified_linux_demo", + demoHelperPath: join(root, "helper"), + }, + { + platform: "linux", + acquireApplicationLease: async () => ({ + acquired: true, + lease: { socketPath, release }, + }), + retryPublisherCleanup: async () => undefined, + verifyLinuxLauncher: async () => ({ + ok: true, + value: { + sha256: "0".repeat(64), + version: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + source: "signed_package_cache", + }, + }), + selectPrivateDisplay: async () => { + controller.abort("cancel during probe finalization"); + return { + ok: false, + strategy: "unavailable", + exitCode: 70, + diagnostic: { + component: "hopper_private_display", + operation: "probe", + status: "error", + failure_code: "private_display_unavailable", + reason: "fixture", + socket_directory: "/tmp/.X11-unix", + socket_directory_mode: null, + mount_read_only: null, + effective_socket_directory_mode: null, + effective_mount_read_only: null, + wsl: false, + strategy: "unavailable", + fallback_reason: null, + xvfb_stderr_bytes: 0, + }, + cleanup: { + status: "pending", + lease: probeLease, + reason: "fixture cleanup", + }, + }; + }, + }, + ); + const signal = + stage === "nested" + ? controller.signal + : AbortSignal.abort("cancel fixture"); + const launched = await launcher.launch( + { + directory: stage === "bootstrap" ? join(root, "missing") : root, + socketPath: join(root, "bridge.sock"), + token: "fixture-token", + runId: "lease-release-test", + }, + stage !== "bootstrap" ? { signal } : {}, + ); + expect(launched.ok).toBe(false); + if (launched.ok) return; + expect(launched.error.cleanupPrimary?._tag).toBe( + stage !== "bootstrap" ? "HopperCancelledError" : "HopperStartError", + ); + expect(launched.error.cleanupIncomplete).toBe(true); + expect(launched.error.cleanupResources).toEqual( + stage === "nested" ? ["probe", socketPath] : [socketPath], + ); + expect(release).toHaveBeenCalledTimes(1); + if (stage === "nested") { + expect((await launcher.cleanup()).ok).toBe(false); + expect(launched.error.cleanupResources).toEqual(["probe"]); + expect(launched.error.cleanupIncomplete).toBe(true); + } + expect((await launcher.cleanup()).ok).toBe(true); + expect(release).toHaveBeenCalledTimes(2); + expect(launched.error.cleanupIncomplete).toBe(false); + expect((await launcher.cleanup()).ok).toBe(true); + expect(release).toHaveBeenCalledTimes(2); + }, + ); + }, +); diff --git a/src/process/OwnedLease.ts b/src/process/OwnedLease.ts index 425f40ace9..2dd40c12d5 100644 --- a/src/process/OwnedLease.ts +++ b/src/process/OwnedLease.ts @@ -9,6 +9,13 @@ import { } from "node:net"; import { join } from "node:path"; +import { + ArtifactFileCleanupFailure, + withArtifactFileHandle, +} from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; + import { safeParseJson } from "../domain/safeJson.js"; /** The process that holds a lease, as that process reports itself. */ @@ -34,6 +41,8 @@ export type LeaseFailure = | { readonly kind: "directory_permissions" } | { readonly kind: "directory_access_denied"; readonly directory: string } | { readonly kind: "directory_inspect" } + | { readonly kind: "directory_close" } + | { readonly kind: "socket_permissions" } | { readonly kind: "recovery_busy"; readonly path: string } | { readonly kind: "recovery_create"; readonly path: string } | { readonly kind: "recovery_remove"; readonly path: string } @@ -45,6 +54,21 @@ export type LeaseFailureFactory = ( cause?: unknown, ) => Error; +/** Recovery finalization retains its operation outcome and every failed owner. */ +export class OwnedLeaseCleanupFailure extends ArtifactReaderFailure { + override readonly cleanupLeases; + constructor( + readonly primary: unknown, + failure: ArtifactReaderFailure, + ) { + super(failure.reason, failure.message, { + cause: primary, + cleanup: failure.cleanup, + }); + this.cleanupLeases = failure.cleanupLeases; + } +} + /** * Hold one named lease for as long as this process keeps it. * @@ -59,54 +83,115 @@ export const acquireOwnedLease = async (input: { /** Private per-user directory for POSIX sockets. Unused on Windows. */ readonly directory: string; readonly failure: LeaseFailureFactory; + readonly resources?: ArtifactResourceScope | undefined; }): Promise => { const produced = new WeakSet(); + const cleanupCauses = new WeakMap(); const fail = (failure: LeaseFailure, cause?: unknown): Error => { const error = input.failure(failure, cause); produced.add(error); + if (cause instanceof ArtifactReaderFailure) cleanupCauses.set(error, cause); return error; }; const key = createHash("sha256") .update(JSON.stringify(input.identity)) .digest("hex") .slice(0, 32); + const resources = input.resources ?? new ArtifactResourceScope(); const owner = { runId: input.runId, processId: process.pid }; if (process.platform === "win32") { // A named pipe disappears with its last handle, so no stale pipe needs // recovery. A pipe that vanished between bind and owner lookup is retried. const pipePath = `\\\\.\\pipe\\rea-lease-${key}`; for (let attempt = 0; attempt < 2; attempt += 1) { - const acquisition = await tryAcquireLease(pipePath, owner, fail, false); + const acquisition = await tryAcquireLease( + pipePath, + owner, + fail, + false, + resources, + ); if (typeof acquisition !== "string") return acquisition; } throw fail({ kind: "unrecoverable" }); } - await ensureLeaseDirectory(input.directory, fail, produced); + await ensureLeaseDirectory(input.directory, fail, produced, input.resources); const socketPath = join(input.directory, `${key}.sock`); - const acquisition = await tryAcquireLease(socketPath, owner, fail, true); + const acquisition = await tryAcquireLease( + socketPath, + owner, + fail, + true, + resources, + ); if (typeof acquisition !== "string") return acquisition; // Serialize stale cleanup through the replacement bind: otherwise two // recoverers can unlink the socket that one of them has just acquired. const recoveryPath = `${socketPath}.recovery`; await reserveRecovery(recoveryPath, fail); + const recoveryOwner = { + kind: "lease" as const, + resource: recoveryPath, + lease: { release: () => rmdir(recoveryPath) }, + }; let recovered: LeaseAcquisition | "missing" | "stale" | undefined; + let primary: unknown; + let failed = false; try { - recovered = await tryAcquireLease(socketPath, owner, fail, true); + recovered = await tryAcquireLease(socketPath, owner, fail, true, resources); if (recovered === "stale") await rm(socketPath, { force: true }); if (typeof recovered === "string") - recovered = await tryAcquireLease(socketPath, owner, fail, true); - if (typeof recovered !== "string") return recovered; - throw fail({ kind: "unrecoverable" }); - } finally { - try { - await rmdir(recoveryPath); - } catch (cause: unknown) { - if (typeof recovered !== "string" && recovered?.acquired) - await recovered.lease.release(); - throw fail({ kind: "recovery_remove", path: recoveryPath }, cause); - } + recovered = await tryAcquireLease( + socketPath, + owner, + fail, + true, + resources, + ); + if (typeof recovered === "string") throw fail({ kind: "unrecoverable" }); + } catch (cause: unknown) { + failed = true; + primary = cause; } + const directoryCleanup = await resources.release(recoveryOwner); + if (directoryCleanup.kind === "failed") { + const failures = [directoryCleanup]; + if (typeof recovered !== "string" && recovered?.acquired) { + const socketCleanup = await resources.release({ + kind: "lease", + resource: recovered.lease.socketPath ?? socketPath, + lease: recovered.lease, + }); + if (socketCleanup.kind === "failed") failures.push(socketCleanup); + } + const outcome = failed + ? primary + : fail( + { kind: "recovery_remove", path: recoveryPath }, + directoryCleanup.cause, + ); + throw fail( + { kind: "recovery_remove", path: recoveryPath }, + new OwnedLeaseCleanupFailure( + outcome, + resources.cleanupFailure( + { + kind: "failed", + cause: + outcome instanceof Error + ? (cleanupCauses.get(outcome) ?? outcome) + : outcome, + }, + [failures[0]!, ...failures.slice(1)], + ), + ), + ); + } + if (failed) throw primary; + if (typeof recovered !== "string" && recovered !== undefined) + return recovered; + throw fail({ kind: "unrecoverable" }); }; const reserveRecovery = async ( @@ -134,8 +219,16 @@ const tryAcquireLease = async ( owner: LeaseOwner, fail: (failure: LeaseFailure, cause?: unknown) => Error, securePath: boolean, + resources: ArtifactResourceScope, ): Promise => { - const server = createServer((socket) => sendOwner(socket, owner)); + const connections = new Set(); + const server = createServer((socket) => { + connections.add(socket); + socket.once("close", () => connections.delete(socket)); + // A failed owner reply ends only this connection, not the lease owner. + socket.on("error", () => socket.destroy()); + sendOwner(socket, owner); + }); // The lease lives exactly as long as its owning process. A lease retained // after unconfirmed cleanup must not keep a completed one-shot command // alive; process exit leaves a stale socket that later acquirers recover. @@ -146,87 +239,118 @@ const tryAcquireLease = async ( ? existingOwner : { acquired: false, owner: existingOwner }; } + let released = false; + const lease: OwnedLease = { + socketPath, + release: async () => { + if (released) return; + // Owner replies do not require peers to close their writable half. End + // every accepted connection before awaiting the listening server. + for (const socket of connections) socket.destroy(); + // Node removes the bound socket; never unlink a later owner's socket. + await closeServer(server); + released = true; + }, + }; if (securePath) try { await chmod(socketPath, 0o600); } catch (cause: unknown) { - await closeServer(server); - throw cause; + const primary = fail({ kind: "socket_permissions" }, cause); + const cleanup = await resources.release({ + kind: "lease", + resource: socketPath, + lease, + }); + if (cleanup.kind === "failed") + throw fail( + { kind: "socket_permissions" }, + new OwnedLeaseCleanupFailure( + primary, + resources.cleanupFailure({ kind: "failed", cause: primary }, [ + cleanup, + ]), + ), + ); + throw primary; } - let released = false; - return { - acquired: true, - lease: { - socketPath, - release: async () => { - if (released) return; - // Node removes a bound Unix socket on close. A second unlink here - // could remove a new owner's socket bound after that close. - await closeServer(server); - released = true; - }, - }, - }; + return { acquired: true, lease }; }; const ensureLeaseDirectory = async ( directory: string, fail: (failure: LeaseFailure, cause?: unknown) => Error, produced: WeakSet, + resources: ArtifactResourceScope | undefined, ): Promise => { try { await mkdir(directory, { recursive: true, mode: 0o700 }); } catch (cause: unknown) { throw fail({ kind: "directory_create" }, cause); } - // Use O_DIRECTORY|O_NOFOLLOW to open the directory without following symlinks, - // then inspect and secure that descriptor. Later socket operations still use - // the path and are not made atomic by this directory validation. - let handle; - try { - handle = await open( - directory, - fsConstants.O_RDONLY | - (fsConstants.O_DIRECTORY ?? 0) | - (fsConstants.O_NOFOLLOW ?? 0), - ); - const directoryStat = await handle.stat(); - if ( - directoryStat.isSymbolicLink() || - directoryStat.isDirectory() === false || - (process.getuid !== undefined && directoryStat.uid !== process.getuid()) - ) - throw fail({ kind: "directory_not_private" }); - // POSIX permission hardening: chmod on the already-opened descriptor avoids - // a second TOCTOU window where the directory could be swapped between check - // and permission fix. - const posixPermissionsApply = process.getuid !== undefined; - if (posixPermissionsApply && (directoryStat.mode & 0o077) !== 0) { - await handle.chmod(0o700); - // Re-verify permissions on the same opened directory. - const afterChmod = await handle.stat(); - if ( - afterChmod.isSymbolicLink() || - afterChmod.isDirectory() === false || - (afterChmod.mode & 0o077) !== 0 - ) - throw fail({ kind: "directory_permissions" }); - } - } catch (cause: unknown) { - if (cause instanceof Error && produced.has(cause)) throw cause; + const inspectionFailure = (cause: unknown): Error => { + if (cause instanceof Error && produced.has(cause)) return cause; const accessDenied = process.getuid !== undefined && cause instanceof Error && "code" in cause && cause.code === "EACCES"; - throw fail( + return fail( accessDenied ? { kind: "directory_access_denied", directory } : { kind: "directory_inspect" }, cause, ); - } finally { - await handle?.close(); + }; + // Use O_DIRECTORY|O_NOFOLLOW to open the directory without following symlinks, + // then inspect and secure that descriptor. Later socket operations still use + // the path and are not made atomic by this directory validation. + try { + const handle = await open( + directory, + fsConstants.O_RDONLY | + (fsConstants.O_DIRECTORY ?? 0) | + (fsConstants.O_NOFOLLOW ?? 0), + ); + await withArtifactFileHandle( + directory, + handle, + resources, + undefined, + async (handle) => { + try { + const directoryStat = await handle.stat(); + if ( + directoryStat.isSymbolicLink() || + directoryStat.isDirectory() === false || + (process.getuid !== undefined && + directoryStat.uid !== process.getuid()) + ) + throw fail({ kind: "directory_not_private" }); + // POSIX permission hardening: chmod on the already-opened descriptor avoids + // a second TOCTOU window where the directory could be swapped between check + // and permission fix. + const posixPermissionsApply = process.getuid !== undefined; + if (posixPermissionsApply && (directoryStat.mode & 0o077) !== 0) { + await handle.chmod(0o700); + // Re-verify permissions on the same opened directory. + const afterChmod = await handle.stat(); + if ( + afterChmod.isSymbolicLink() || + afterChmod.isDirectory() === false || + (afterChmod.mode & 0o077) !== 0 + ) + throw fail({ kind: "directory_permissions" }); + } + } catch (cause: unknown) { + throw inspectionFailure(cause); + } + }, + ); + } catch (cause: unknown) { + if (cause instanceof ArtifactFileCleanupFailure) + throw fail({ kind: "directory_close" }, cause); + throw inspectionFailure(cause); } }; diff --git a/src/process/OwnedWorkerRuntime.ts b/src/process/OwnedWorkerRuntime.ts index 4bdb302ead..75f98e61cf 100644 --- a/src/process/OwnedWorkerRuntime.ts +++ b/src/process/OwnedWorkerRuntime.ts @@ -97,7 +97,6 @@ export class OwnedWorkerRuntime { #root: PrivateRuntimeRoot | undefined; #cleanupEpoch: OwnedWorkerRuntimeCleanupEpoch | undefined; #supervisor: ProviderProcessSupervisor | undefined; - #processId: number | null = null; #acquisition: Promise | undefined; #cleanupRetryAttempt: Promise | undefined; #stopAttempt: Promise | undefined; @@ -312,7 +311,6 @@ export class OwnedWorkerRuntime { }); this.#inputFailure = undefined; this.#observeStdin(spawned.process.stdin); - this.#processId = spawned.process.pid ?? null; this.#cleanupEpoch.setRunId(spawned.ownership?.runId ?? spawnOptions.runId); this.#supervisor = new ProviderProcessSupervisor( { @@ -357,7 +355,6 @@ export class OwnedWorkerRuntime { } supervisor.dispose(); this.#supervisor = undefined; - this.#processId = null; this.#cleanupEpoch?.setRunId(undefined); return stopped; } diff --git a/src/process/ProviderProcessOutput.test.ts b/src/process/ProviderProcessOutput.test.ts index b8a8235c7d..f8a71a749a 100644 --- a/src/process/ProviderProcessOutput.test.ts +++ b/src/process/ProviderProcessOutput.test.ts @@ -752,9 +752,15 @@ it("unreferences process and stream handles when stop reports incomplete cleanup } }); -it.each(["close", "abort"] as const)( - "awaits output without a deadline until %s", - async (outcome) => { +it.each([ + { outcome: "close", timeoutMs: undefined }, + { outcome: "abort", timeoutMs: undefined }, + { outcome: "close", timeoutMs: 5000 }, + { outcome: "abort", timeoutMs: 5000 }, +] as const)( + "releases output wait resources after $outcome with timeout $timeoutMs", + async ({ outcome, timeoutMs }) => { + vi.useFakeTimers(); const producer = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), @@ -770,7 +776,7 @@ it.each(["close", "abort"] as const)( const removeListener = vi.spyOn(controller.signal, "removeEventListener"); let settled = false; const pending = owner - .waitForOutputClose(undefined, controller.signal) + .waitForOutputClose(timeoutMs, controller.signal) .then((result) => { settled = true; return result; @@ -781,11 +787,13 @@ it.each(["close", "abort"] as const)( if (outcome === "close") producer.emit("close", 0, null); else controller.abort(); expect(await pending).toBe(outcome === "close"); + expect(vi.getTimerCount()).toBe(0); expect(removeListener).toHaveBeenCalledWith( "abort", expect.any(Function), ); } finally { + vi.useRealTimers(); removeListener.mockRestore(); owner.dispose(); producer.stdout.destroy(); diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index 46e6ea4a3e..2d5a3d891b 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -632,3 +632,107 @@ it.skipIf(process.platform === "win32")( expect(snapshot.coverage.enumeration_truncated).toBe(false); }, ); + +it.skipIf(process.platform === "win32")( + "preserves non-UTF-8 symlink target bytes through snapshot effects and schema parsing", + async () => { + const { processFilesystemSnapshotSchema } = + await import("../../domain/process/processCapture.js"); + const root = await createTestTempDirectory("rea-fs-symlink-bytes-"); + const path = join(root, "link"); + const first = Buffer.from([0x61, 0xff]); + const second = Buffer.from([0x61, 0xfe]); + await symlink(first, path); + const scenario = processScenarioSchema.parse({ + executable: process.execPath, + filesystem_observation_paths: [root], + }); + const before = processFilesystemSnapshotSchema.parse( + await snapshotRoots(scenario), + ); + await rm(path); + await symlink(second, path); + const after = processFilesystemSnapshotSchema.parse( + await snapshotRoots(scenario), + ); + const link = before.files.find((file) => file.type === "symlink"); + expect(link).toMatchObject({ + symlink_target: null, + symlink_target_hex: "61ff", + }); + expect( + classifyFilesystemEffects(before, after).find( + ({ path }) => path === "root_0:link", + )?.status, + ).toBe("modified"); + expect( + classifyFilesystemEffects(before, before).find( + ({ path }) => path === "root_0:link", + )?.status, + ).toBe("unchanged"); + expect( + processFilesystemSnapshotSchema.safeParse({ + ...before, + files: [{ ...link, symlink_target: "a�" }], + }).success, + ).toBe(false); + expect( + processFilesystemSnapshotSchema.safeParse({ + ...before, + files: [{ ...link, symlink_target_hex: "f" }], + }).success, + ).toBe(false); + expect( + processFilesystemSnapshotSchema.safeParse({ + ...before, + files: [{ ...link, symlink_target_hex: undefined }], + }).success, + ).toBe(false); + const legacy = processFilesystemSnapshotSchema.parse({ + ...before, + files: [{ ...link, symlink_target: "a�", symlink_target_hex: undefined }], + }); + expect(classifyFilesystemEffects(legacy, legacy)[0]?.status).toBe( + "unknown", + ); + const differentLegacy = processFilesystemSnapshotSchema.parse({ + ...legacy, + files: legacy.files.map((file) => ({ ...file, symlink_target: "b�" })), + }); + expect(classifyFilesystemEffects(legacy, differentLegacy)[0]?.status).toBe( + "modified", + ); + const valid = processFilesystemSnapshotSchema.parse({ + ...before, + files: [ + { + ...link, + symlink_target: "a�", + symlink_target_hex: Buffer.from("a�").toString("hex"), + }, + ], + }); + expect(classifyFilesystemEffects(valid, valid)[0]?.status).toBe( + "unchanged", + ); + const oldValid = processFilesystemSnapshotSchema.parse({ + ...before, + files: [ + { ...link, symlink_target: "target", symlink_target_hex: undefined }, + ], + }); + const newValid = processFilesystemSnapshotSchema.parse({ + ...before, + files: [ + { + ...link, + symlink_target: "target", + symlink_target_hex: Buffer.from("target").toString("hex"), + }, + ], + }); + expect(classifyFilesystemEffects(oldValid, newValid)[0]?.status).toBe( + "unchanged", + ); + }, +); diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index 9d72f584c9..1bc48beb84 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -1,9 +1,12 @@ +import { observedSymlinkTarget } from "../../domain/process/symlinkTarget.js"; import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { lstat, open, opendir, readlink, realpath } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; import type { FileHandle } from "node:fs/promises"; import { appendCallerPath } from "../../filesystem/callerPath.js"; +import { decodeDirectoryEntryName } from "../../filesystem/decodeDirectoryEntryName.js"; +import { sameRegularFileState } from "../../filesystem/RegularFile.js"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js"; import type { Stats } from "node:fs"; @@ -40,16 +43,6 @@ const hasSameIdentity = ( before.ino === after.ino && (expected === "directory" ? after.isDirectory() : after.isSymbolicLink()); -const hasSameFileState = (before: Stats, after: Stats): boolean => - before.dev === after.dev && - before.ino === after.ino && - before.isFile() && - after.isFile() && - before.mode === after.mode && - before.size === after.size && - before.mtimeMs === after.mtimeMs && - before.ctimeMs === after.ctimeMs; - const filesystemSystemCode = (cause: unknown): string | undefined => { if (!(cause instanceof Error) || !("code" in cause)) return undefined; const code = cause.code; @@ -104,14 +97,19 @@ const observeSymlink = async ( path: string, before: Stats, signal?: AbortSignal, -): Promise> => { - const target = await readEntryObservation(() => readlink(path), signal); +): Promise< + Result<{ readonly text: string | null; readonly hex: string }, EntryOmission> +> => { + const target = await readEntryObservation( + () => readlink(path, { encoding: "buffer" }), + signal, + ); if (!target.ok) return target; const after = await readEntryObservation(() => lstat(path), signal); if (!after.ok) return after; - return hasSameIdentity(before, after.value, "symlink") - ? target - : err({ reason: "identity_changed", system_code: null }); + if (!hasSameIdentity(before, after.value, "symlink")) + return err({ reason: "identity_changed", system_code: null }); + return ok(observedSymlinkTarget(target.value)); }; type PathLookup = @@ -297,7 +295,7 @@ export const hashFile = async ( if (!noFollowAvailable) { const named = await lstat(path); signal?.throwIfAborted(); - if (!hasSameFileState(expected, named)) + if (!expected.isFile() || !sameRegularFileState(expected, named)) return { state: "omitted", reason: "file_changed_or_short_read", @@ -306,7 +304,11 @@ export const hashFile = async ( } const stats = await ownedHandle.stat(); signal?.throwIfAborted(); - if (!hasSameFileState(expected, stats) || stats.size > maxBytes) + if ( + !expected.isFile() || + !sameRegularFileState(expected, stats) || + stats.size > maxBytes + ) return { state: "omitted", reason: "file_changed_or_short_read", @@ -334,11 +336,11 @@ export const hashFile = async ( } const after = await ownedHandle.stat(); signal?.throwIfAborted(); - let unchanged = hasSameFileState(stats, after); + let unchanged = sameRegularFileState(stats, after); if (unchanged && !noFollowAvailable) { const namedAfter = await lstat(path); signal?.throwIfAborted(); - unchanged = hasSameFileState(after, namedAfter); + unchanged = sameRegularFileState(after, namedAfter); } if (!unchanged) return { @@ -386,6 +388,7 @@ export const snapshotRoots = async ( const completeRoots: string[] = []; let remainingBytes = scenario.limits.file_bytes; let pendingNames = 0; + let unrepresentableNames = 0; let truncated = false; const enumerationReasons = new Set< FilesystemCoverage["enumeration_reasons"][number] @@ -471,11 +474,11 @@ export const snapshotRoots = async ( signal?.throwIfAborted(); if (await omitObserverPath(root, rootAlias, path)) return true; if ( - entries.length >= scenario.limits.files || + entries.length + unrepresentableNames >= scenario.limits.files || depth > scenario.limits.filesystem_depth ) { truncated = true; - if (entries.length >= scenario.limits.files) + if (entries.length + unrepresentableNames >= scenario.limits.files) enumerationReasons.add("files_limit"); if (depth > scenario.limits.filesystem_depth) enumerationReasons.add("depth_limit"); @@ -506,7 +509,8 @@ export const snapshotRoots = async ( mode: stats.mode, size: stats.size, sha256: null, - symlink_target: target.value, + symlink_target: target.value.text, + symlink_target_hex: target.value.hex, }); unobservedSubtrees.push({ path: `${rootAlias}:${relativePath}`, @@ -577,11 +581,16 @@ export const snapshotRoots = async ( if (type !== "directory") return true; const children: string[] = []; let complete = true; - const remainingEntries = - scenario.limits.files - entries.length - pendingNames; + let remainingEntries = + scenario.limits.files - + entries.length - + pendingNames - + unrepresentableNames; let directory; try { - directory = await opendir(path); + directory = await opendir(path, { + encoding: "buffer" as BufferEncoding, + }); } catch (cause: unknown) { const systemCode = fileUnavailableSystemCode(cause); if (systemCode === undefined) throw cause; @@ -607,12 +616,14 @@ export const snapshotRoots = async ( signal?.throwIfAborted(); const child = await ownedDirectory.read(); if (child === null) break; + const decoded = decodeDirectoryEntryName(child.name); if ( - await omitObserverPath( + decoded.kind === "decoded" && + (await omitObserverPath( root, rootAlias, - appendCallerPath(path, child.name), - ) + appendCallerPath(path, decoded.name), + )) ) continue; if ( @@ -627,7 +638,21 @@ export const snapshotRoots = async ( enumerationReasons.add("depth_limit"); break; } - children.push(child.name); + if (decoded.kind === "unrepresentable") { + unrepresentableNames += 1; + remainingEntries -= 1; + truncated = true; + complete = false; + enumerationReasons.add("unrepresentable_name"); + enumerationOmissions.push({ + path: `${rootAlias}:${relativePath}`, + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: decoded.rawHex, + }); + continue; + } + children.push(decoded.name); pendingNames += 1; } }, diff --git a/src/process/capture/ProcessCaptureEnvironment.test.ts b/src/process/capture/ProcessCaptureEnvironment.test.ts new file mode 100644 index 0000000000..b31da256cd --- /dev/null +++ b/src/process/capture/ProcessCaptureEnvironment.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; + +import { processScenarioSchema } from "../../domain/process/processScenario.js"; +import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js"; + +it("uses Windows environment identity for caller overrides and capture-owned values", () => { + const scenario = processScenarioSchema.parse({ + executable: "node", + environment: { + path: "caller-bin", + term: "caller-terminal", + rea_process_run_id: "caller-token", + }, + }); + expect( + makeProcessCaptureEnvironment({ + scenario, + hostEnvironment: { + Path: "host-bin", + TERM: "host-terminal", + REA_PROCESS_RUN_ID: "host-token", + OMITTED: undefined, + }, + runId: "capture-token", + platform: "win32", + }), + ).toEqual({ + PATH: "caller-bin", + TERM: "caller-terminal", + REA_PROCESS_RUN_ID: "capture-token", + }); +}); + +it("preserves distinct POSIX names and supplies the terminal default", () => { + const scenario = processScenarioSchema.parse({ + executable: "node", + environment: { path: "caller-bin", term: "lowercase-terminal" }, + }); + expect( + makeProcessCaptureEnvironment({ + scenario, + hostEnvironment: { PATH: "host-bin", OMITTED: undefined }, + runId: "capture-token", + platform: "linux", + }), + ).toEqual({ + PATH: "host-bin", + path: "caller-bin", + term: "lowercase-terminal", + TERM: "xterm-256color", + REA_PROCESS_RUN_ID: "capture-token", + }); +}); diff --git a/src/process/capture/ProcessCaptureEnvironment.ts b/src/process/capture/ProcessCaptureEnvironment.ts index 92892d47b1..541da979f0 100644 --- a/src/process/capture/ProcessCaptureEnvironment.ts +++ b/src/process/capture/ProcessCaptureEnvironment.ts @@ -1,24 +1,36 @@ import type { ProcessScenario } from "../../domain/process/processScenario.js"; +import { + mergeEnvironmentOverrides, + snapshotEnvironment, +} from "../snapshotEnvironment.js"; interface ProcessCaptureEnvironmentOptions { readonly scenario: ProcessScenario; readonly runId: string; readonly hostEnvironment: Readonly>; + readonly platform?: NodeJS.Platform; } /** Build the child environment from the host plus caller-selected overrides. */ export const makeProcessCaptureEnvironment = ( options: ProcessCaptureEnvironmentOptions, -): Record => ({ - ...Object.fromEntries( - Object.entries(options.hostEnvironment).filter( - (entry): entry is [string, string] => entry[1] !== undefined, +): Record => { + const platform = options.platform ?? process.platform; + const environment = snapshotEnvironment( + mergeEnvironmentOverrides( + options.hostEnvironment, + options.scenario.environment, + platform, ), - ), - ...options.scenario.environment, - TERM: - options.scenario.environment.TERM ?? - options.hostEnvironment.TERM ?? - "xterm-256color", - REA_PROCESS_RUN_ID: options.runId, -}); + platform, + ); + return { + ...Object.fromEntries( + Object.entries(environment).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ), + TERM: environment.TERM ?? "xterm-256color", + REA_PROCESS_RUN_ID: options.runId, + }; +}; diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 9b09adbbd3..f7cd45751c 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -1184,7 +1184,9 @@ class ProcessCaptureResourceOwner { } get cleanupResources(): readonly string[] { - return unresolvedCleanupResources(this.report); + return unresolvedCleanupResources(this.report).map((resource) => + resource === "temporary_root" ? this.options.temporaryRoot : resource, + ); } get report(): ProcessCaptureCleanupReport { diff --git a/src/process/capture/ProcessFilesystemEffects.ts b/src/process/capture/ProcessFilesystemEffects.ts index e515e49820..7c6de35520 100644 --- a/src/process/capture/ProcessFilesystemEffects.ts +++ b/src/process/capture/ProcessFilesystemEffects.ts @@ -1,3 +1,4 @@ +import { symlinkTargetIdentity } from "../../domain/process/symlinkTarget.js"; import type { FilesystemCheckpoint, ProcessFilesystemSnapshot, @@ -73,8 +74,35 @@ export const classifyFilesystemEffects = ( const metadataChanged = beforeFile.type !== afterFile.type || beforeFile.mode !== afterFile.mode || - beforeFile.size !== afterFile.size || + beforeFile.size !== afterFile.size; + const beforeTarget = + beforeFile.type === "symlink" + ? symlinkTargetIdentity(beforeFile) + : null; + const afterTarget = + afterFile.type === "symlink" ? symlinkTargetIdentity(afterFile) : null; + // Different decoded spellings prove different bytes even when an old + // capture cannot recover either target's complete byte identity. + const targetTextChanged = + beforeFile.type === "symlink" && + afterFile.type === "symlink" && + beforeFile.symlink_target !== null && + afterFile.symlink_target !== null && beforeFile.symlink_target !== afterFile.symlink_target; + if ( + !metadataChanged && + !targetTextChanged && + beforeFile.type === "symlink" && + afterFile.type === "symlink" && + (beforeTarget === null || afterTarget === null) + ) + return { + path, + status: "unknown", + before: beforeFile, + after: afterFile, + reason: "A legacy symlink target has no unambiguous byte identity.", + } as const; if ( !metadataChanged && beforeFile.type === "file" && @@ -92,7 +120,10 @@ export const classifyFilesystemEffects = ( return { path, status: - metadataChanged || beforeFile.sha256 !== afterFile.sha256 + metadataChanged || + targetTextChanged || + beforeTarget !== afterTarget || + beforeFile.sha256 !== afterFile.sha256 ? "modified" : "unchanged", before: beforeFile, diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index a0833e380a..fc9141ec5f 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -5,7 +5,16 @@ import { captureProcessScenario, retainRootSignaller, } from "./ProcessHarness.js"; -import { settleProcessCaptureJournal } from "./ProcessCaptureLifecycle.js"; +import { + settleProcessCaptureJournal, + releaseProcessResources, + ProcessCaptureResourceScope, + createProcessCaptureObservationBuffer, + prepareProcessCapture, + resolveProcessResult, + type ProcessCaptureCleanupOutcome, + type ProcessCaptureCleanupHost, +} from "./ProcessCaptureLifecycle.js"; import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../domain/process/processCaptureExample.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; @@ -19,15 +28,6 @@ import { normalizeCaptureFailure, processCaptureCancelled, } from "./ProcessCaptureError.js"; -import { - releaseProcessResources, - ProcessCaptureResourceScope, - createProcessCaptureObservationBuffer, - prepareProcessCapture, - resolveProcessResult, - type ProcessCaptureCleanupOutcome, - type ProcessCaptureCleanupHost, -} from "./ProcessCaptureLifecycle.js"; import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js"; import { @@ -353,7 +353,10 @@ it("keeps empty cleanup exception messages actionable in the report", async () = it("retains observations and retries the temporary root when process cleanup throws", async () => { const cleanupFailure = new Error("termination inspection failed"); - const removeTemporaryRoot = vi.fn(async () => undefined); + let rootRemovalFails = true; + const removeTemporaryRoot = vi.fn(async () => { + if (rootRemovalFails) throw new Error("root removal denied"); + }); let failing = true; const scope = new ProcessCaptureResourceScope(); const host: ProcessCaptureCleanupHost = { @@ -382,6 +385,10 @@ it("retains observations and retries the temporary root when process cleanup thr state: "failed", reason: cleanupFailure.message, }); + expect(cleanup.cleanupLease?.cleanupResources).toEqual([ + "owned_process_group", + "/fixture/root", + ]); const executionFailure = new Error("capture snapshot failed"); const capture = parseProcessCapture({ ...accountFullyObservedProcessCapture({ @@ -401,6 +408,7 @@ it("retains observations and retries the temporary root when process cleanup thr ).toMatchObject({ code: "cleanup_incomplete", details: { + resources: ["owned_process_group", "/fixture/root"], cleanup_report: cleanup.report, execution_failure: "capture snapshot failed", partial_observation: { @@ -410,10 +418,15 @@ it("retains observations and retries the temporary root when process cleanup thr }); } failing = false; + await expect(scope.retryCleanup()).rejects.toMatchObject({ + cleanupResources: ["/fixture/root"], + }); + expect(cleanup.cleanupLease?.cleanupResources).toEqual(["/fixture/root"]); + rootRemovalFails = false; await scope.retryCleanup(); - expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith("/fixture/root"); + expect(removeTemporaryRoot).toHaveBeenCalledWith("/fixture/root"); await scope.close(); - expect(removeTemporaryRoot).toHaveBeenCalledTimes(1); + expect(removeTemporaryRoot).toHaveBeenCalledTimes(2); }); it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => { diff --git a/src/providers/AnalysisClientCleanup.ts b/src/providers/AnalysisClientCleanup.ts index 6bb3057687..c80e5cd168 100644 --- a/src/providers/AnalysisClientCleanup.ts +++ b/src/providers/AnalysisClientCleanup.ts @@ -1,24 +1,31 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, type Result } from "../domain/result.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; -import type { - AnalysisClient, - ExecutionOptions, -} from "../application/AnalysisProvider.js"; +import type { AnalysisClient } from "../application/AnalysisProvider.js"; /** Close one provider client and normalize unexpected adapter rejection. */ export const closeAnalysisClient = async ( client: AnalysisClient, providerId: string, - options: Pick & { - readonly retainDocument?: boolean; - readonly onCleanupFailure?: (failure: AnalysisError) => void; - } = {}, + options: Parameters[0] = {}, ): Promise> => { + // Nested/composite clients report failures before their final result settles. + // Own notification identity for this close attempt, including the fallback + // for clients that only return or reject with their cleanup failure. + const reported = new Set(); + const report = (failure: AnalysisError): void => { + if (reported.has(failure)) return; + reported.add(failure); + options.onCleanupFailure?.(failure); + }; + let result: Result; try { - const result = await client.close(options); - if (!result.ok) options.onCleanupFailure?.(result.error); - return result; + result = await client.close({ + ...options, + ...(options.onCleanupFailure === undefined + ? {} + : { onCleanupFailure: report }), + }); } catch (cause: unknown) { // A typed cleanup failure already names its leftover resources. const failure = @@ -30,7 +37,8 @@ export const closeAnalysisClient = async ( { reason: "provider client close rejected unexpectedly" }, { cause }, ); - options.onCleanupFailure?.(failure); - return err(failure); + result = err(failure); } + if (!result.ok) report(result.error); + return result; }; diff --git a/src/providers/AnalysisClientCreation.ts b/src/providers/AnalysisClientCreation.ts index 023392b097..352b9d6250 100644 --- a/src/providers/AnalysisClientCreation.ts +++ b/src/providers/AnalysisClientCreation.ts @@ -7,7 +7,6 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisClient, - ExecutionOptions, RetainedResourceScope, } from "../application/AnalysisProvider.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -206,9 +205,7 @@ export class AnalysisClientCreation { } close( - options?: Pick & { - readonly retainDocument?: boolean; - }, + options?: Parameters[0], ): Promise> { this.#closed = true; this.#closeOptions = options; diff --git a/src/providers/AnalysisClientFailure.test.ts b/src/providers/AnalysisClientFailure.test.ts new file mode 100644 index 0000000000..aebe5f4807 --- /dev/null +++ b/src/providers/AnalysisClientFailure.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "vitest"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js"; +import { analysisClientFailureForOperation } from "./AnalysisClientFailure.js"; + +const observation: FileOffsetPartialObservation = { + operation: "address_to_file_offset", + address: "0x1000", + provider_file_offset: 32, + provider_source_path: "/selected/image", + provider_image_header_hex: null, + original_file_offset: { available: false, reason: "not mapped" }, +}; + +it.each([false, true])( + "preserves completed cleanup evidence when the source is already resolved: %s", + (resolved) => { + const source = new ProviderCleanupError( + "native", + ["snapshot descriptor"], + { reason: "descriptor close failed" }, + { operation: "provider_mapping", completedObservation: observation }, + ); + if (resolved) source.resolveCleanup(); + + const attributed = analysisClientFailureForOperation( + source, + "native", + "address_to_file_offset", + ); + expect(attributed).toBeInstanceOf(ProviderCleanupError); + if (!(attributed instanceof ProviderCleanupError)) + throw new Error("Expected the cleanup subtype"); + expect(attributed.operation).toBe("address_to_file_offset"); + expect(attributed.completedObservation).toBe(observation); + expect(attributed.cleanupIncomplete).toBe(!resolved); + expect(attributed.cleanupResources).toEqual(["snapshot descriptor"]); + + // Attribution must keep the source owner's later cleanup confirmation live. + source.resolveCleanup(); + expect(attributed.cleanupIncomplete).toBe(false); + if (!resolved) expect(attributed.cleanupResources).toEqual([]); + expect(attributed.completedObservation).toBe(observation); + }, +); + +it("does not promote ordinary partial evidence into a completed result", () => { + const source = new ProviderCleanupError( + "native", + ["snapshot descriptor"], + {}, + { partialObservation: observation }, + ); + const attributed = analysisClientFailureForOperation( + source, + "native", + "address_to_file_offset", + ); + expect(attributed).toBeInstanceOf(ProviderCleanupError); + if (!(attributed instanceof ProviderCleanupError)) + throw new Error("Expected the cleanup subtype"); + expect(attributed.partialObservation).toBe(observation); + expect(attributed.completedObservation).toBeUndefined(); +}); diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts index 2eee52e538..54cb303bc0 100644 --- a/src/providers/AnalysisClientFailure.ts +++ b/src/providers/AnalysisClientFailure.ts @@ -175,6 +175,9 @@ export const analysisClientFailureForOperation = ( { ...outcomeContext, operation, + ...(cause.completedObservation === undefined + ? {} + : { completedObservation: cause.completedObservation }), ...(cause.remediationAction === undefined ? {} : { remediationAction: cause.remediationAction }), diff --git a/src/providers/OwnedCommandCleanup.ts b/src/providers/OwnedCommandCleanup.ts index 10ce79d1c6..02dfdfe150 100644 --- a/src/providers/OwnedCommandCleanup.ts +++ b/src/providers/OwnedCommandCleanup.ts @@ -47,27 +47,37 @@ export const retainOwnedCommandCleanup = ( commandFailure.cleanupFailure !== null) ) { const supervisor = commandFailure.cleanupOwner; - const cleanup = new ProviderCleanupError( - providerId, - commandFailure.resources, - { - reason: - commandFailure.cleanupFailure ?? - "Provider prerequisite cleanup remains incomplete", - ...(commandFailure.cleanupResult === undefined - ? {} - : { - process_cleanup: jsonValueSchema.parse( - commandFailure.cleanupResult, - ), - }), - }, - { cause, operation }, + const failureForStop = ( + reason: string, + result: OwnedCommandFailure["cleanupResult"], + ): ProviderCleanupError => + new ProviderCleanupError( + providerId, + commandFailure.resources, + { + reason, + ...(result === undefined + ? {} + : { + process_cleanup: jsonValueSchema.parse(result), + }), + }, + { cause, operation }, + ); + const cleanup = failureForStop( + commandFailure.cleanupFailure ?? + "Provider prerequisite cleanup remains incomplete", + commandFailure.cleanupResult, ); const lease = new AnalysisCleanupLease(async () => { if (supervisor === undefined) throw cleanup; const stopped = await supervisor.stop(); - if (stopped.status === "incomplete") throw cleanup; + if (stopped.status === "incomplete") { + const failure = failureForStop(stopped.reason, stopped.cleanupResult); + failure.bindCleanupResourceOwner(lease); + lease.bindCleanupError(failure); + throw failure; + } }, commandFailure.resources); cleanup.bindCleanupResourceOwner(lease); lease.bindCleanupError(cleanup); diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index cdcbe9b8ea..e26454afeb 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -1186,6 +1186,10 @@ export class RizinDebugSessionManager { platform: this.#platform, }), ) + .catch((cause: unknown) => ({ + status: "incomplete" as const, + reason: `Owned Rizin debugger shutdown failed: ${cause instanceof Error ? cause.message : String(cause)}`, + })) .then((stopped) => { if (stopped.status !== "incomplete") { for (const failure of session.cleanupFailures) diff --git a/src/server/JavaScriptRouteAdvertisedSchema.test.ts b/src/server/JavaScriptRouteAdvertisedSchema.test.ts new file mode 100644 index 0000000000..39215371e2 --- /dev/null +++ b/src/server/JavaScriptRouteAdvertisedSchema.test.ts @@ -0,0 +1,89 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { createRequire } from "node:module"; +import { expect, it } from "vitest"; + +import { toolContract } from "../contracts/toolContracts.js"; +import { toolOutputSchemaWithMetadata } from "../contracts/toolSchemaMetadata.js"; +import { javaScriptRouteCandidateSchema } from "../domain/javascript/javascriptRouteFacts.js"; +import { javaScriptInventoryRouteSchema } from "../domain/javascript/javascriptRouteInventory.js"; +import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; + +it("advertises JavaScript route outputs with standard JSON Schema path constraints", async () => { + const server = new RequestTrackingMcpServer({ + name: "route-schema", + version: "1", + }); + const contract = toolContract("analyze_javascript_application"); + server.registerTool( + contract.name, + { + outputSchema: toolOutputSchemaWithMetadata(contract), + }, + () => ({ content: [] }), + ); + const client = new Client({ name: "route-schema-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === contract.name, + )?.outputSchema; + expect(advertised).toBeDefined(); + if (advertised === undefined) + throw new Error("Missing advertised route schema"); + const ajv = new Ajv2020({ + strictSchema: true, + strictTypes: false, + code: { optimize: false }, + }); + const require = createRequire(import.meta.url); + const addFormats: unknown = require("ajv-formats"); + if (typeof addFormats !== "function") + throw new Error("Pinned AJV formats plugin is unavailable"); + addFormats(ajv); + expect(() => ajv.compile(advertised as AnySchema)).not.toThrow(); + + // The inventory path is nested inside the advertised complete Evidence branch. + const patterns: Record[] = []; + const visit = (value: unknown): void => { + if (value === null || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + const record = value as Record; + if (record.pattern === "^\\/") patterns.push(record); + Object.values(record).forEach(visit); + }; + visit(advertised); + expect(patterns.length).toBeGreaterThan(0); + for (const schema of patterns) { + const validate = ajv.compile(schema); + for (const path of ["/", "/users", "/users\nsettings", "/\r\n"]) { + expect(validate(path), path).toBe(true); + expect( + javaScriptRouteCandidateSchema.shape.path.safeParse(path).success, + ).toBe(true); + expect( + javaScriptInventoryRouteSchema.shape.path.safeParse(path).success, + ).toBe(true); + } + for (const path of ["", "users", "\n/users", "https://example.test/"]) { + expect(validate(path), path).toBe(false); + expect( + javaScriptRouteCandidateSchema.shape.path.safeParse(path).success, + ).toBe(false); + expect( + javaScriptInventoryRouteSchema.shape.path.safeParse(path).success, + ).toBe(false); + } + } + } finally { + await client.close(); + await server.close(); + } +}); diff --git a/src/server/admittedToolOperation.ts b/src/server/admittedToolOperation.ts index f8afacc6c3..124c35ac12 100644 --- a/src/server/admittedToolOperation.ts +++ b/src/server/admittedToolOperation.ts @@ -13,7 +13,7 @@ import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; * that tool's target-content provenance like any other of its results. */ export const runAdmittedToolOperation = async ( - server: EvidenceMcpServer, + server: Pick, admission: WithAdmittedAnalysis | undefined, operationName: string, signal: ServerContext["mcpReq"]["signal"], diff --git a/src/server/createServer.ts b/src/server/createServer.ts index bace9e2ff5..1c521b91ad 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -792,8 +792,12 @@ export const createServer = ( const closeServer = server.close.bind(server); server.close = async () => { const results = await Promise.allSettled([closeServer(), closeProviders()]); - for (const result of results) - if (result.status === "rejected") throw result.reason; + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason as unknown] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "REA server cleanup failed"); }; registerCaptureTools( server, diff --git a/src/server/registerBinaryDiagnosticsTools.ts b/src/server/registerBinaryDiagnosticsTools.ts index b97f910b99..a4a8ae40c0 100644 --- a/src/server/registerBinaryDiagnosticsTools.ts +++ b/src/server/registerBinaryDiagnosticsTools.ts @@ -7,13 +7,11 @@ import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { Logger } from "pino"; -import { deliverEvidenceResult } from "./contractToolHandler.js"; -import { logToolExecution } from "./toolLogging.js"; +import { runEvidenceToolOperation } from "./contractToolHandler.js"; import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { err } from "../domain/result.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; -import { runAdmittedToolOperation } from "./admittedToolOperation.js"; import { registerByteEntropyTool } from "./registerByteEntropyTool.js"; /** Bind offline layout inspection to its named contract and session Evidence owner. */ @@ -58,32 +56,18 @@ export const registerBinaryDiagnosticsTools = ( ); const { detail, ...request } = parsed.data; const parameters = { detail }; - return runAdmittedToolOperation( - server, - withAdmittedAnalysis, - contract.name, - context.mcpReq.signal, - async () => { - const result = await logToolExecution( - logger, - contract.name, - async () => { - const ready = delivery.preflight(parameters); - if (!ready.ok) return ready; - const inspected = await service.inspect(request, { - signal: context.mcpReq.signal, - }); - return inspected.ok - ? delivery.projectEvidence(parameters, inspected.value) - : inspected; - }, - ); - return deliverEvidenceResult( - server, - result, - contract, - recordEvidence, - ); + return runEvidenceToolOperation( + { server, logger, recordEvidence, withAdmittedAnalysis }, + contract, + undefined, + { input: request, context }, + async (input, { signal }) => { + const ready = delivery.preflight(parameters); + if (!ready.ok) return ready; + const inspected = await service.inspect(input, { signal }); + return inspected.ok + ? delivery.projectEvidence(parameters, inspected.value) + : inspected; }, ); }, diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index 033f98a938..1004c6b600 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -1,7 +1,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; -import { readImmutableEvidenceBundle } from "../application/EvidenceBundleFiles.js"; +import { readComparisonEvidenceBundles } from "../application/EvidenceBundleFiles.js"; import { toolContract } from "../contracts/toolContracts.js"; import { compareBundles } from "../domain/bundleComparison.js"; import { createEvidence } from "../domain/evidence.js"; @@ -31,34 +31,27 @@ export const registerBundleComparisonTool = ( contract.name, context.mcpReq.signal, async () => { - const [left, right] = await Promise.all([ - readImmutableEvidenceBundle( - input.left_bundle_path, - context.mcpReq.signal, - resources, - contract.name, - ), - readImmutableEvidenceBundle( - input.right_bundle_path, - context.mcpReq.signal, - resources, - contract.name, - ), - ]); - if (!left.ok) return server.delivery.toCallToolResult(left, contract); - if (!right.ok) - return server.delivery.toCallToolResult(right, contract); + const loaded = await readComparisonEvidenceBundles( + input.left_bundle_path, + input.right_bundle_path, + context.mcpReq.signal, + resources, + contract.name, + ); + if (!loaded.ok) + return server.delivery.toCallToolResult(loaded, contract); + const { left, right } = loaded.value; const computed = await runDerivedOperation( context, contract.name, - () => compareBundles(left.value, right.value, input.record_pairs), + () => compareBundles(left, right, input.record_pairs), ); if (!computed.ok) return server.delivery.toCallToolResult(computed, contract); const comparison = computed.value; const sourceRecords = [ ...new Map( - [...left.value.records, ...right.value.records].map((record) => [ + [...left.records, ...right.records].map((record) => [ record.evidence_id, record, ]), diff --git a/src/server/registerByteEntropyTool.ts b/src/server/registerByteEntropyTool.ts index be22ec9a2f..ce3618daf1 100644 --- a/src/server/registerByteEntropyTool.ts +++ b/src/server/registerByteEntropyTool.ts @@ -1,16 +1,13 @@ import type { ByteEntropyService } from "../application/binaryDiagnostics/ByteEntropyService.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import { retainedSummaryDelivery } from "./retainedSummaryDelivery.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { Logger } from "pino"; -import { deliverEvidenceResult } from "./contractToolHandler.js"; -import { logToolExecution } from "./toolLogging.js"; +import { createRetainedSummaryToolHandler } from "./contractToolHandler.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; -import { runAdmittedToolOperation } from "./admittedToolOperation.js"; /** Reject an unretained byte-entropy summary before reading the selected file. */ export const registerByteEntropyTool = ( @@ -22,42 +19,16 @@ export const registerByteEntropyTool = ( withAdmittedAnalysis: WithAdmittedAnalysis | undefined, ): void => { const contract = toolContract("inspect_byte_entropy"); - const delivery = retainedSummaryDelivery(contract.name, { - recordEvidence, - evidenceById, - }); server.registerTool( contract.name, toolRegistrationOptions(contract), - async ({ detail, ...request }, context) => - runAdmittedToolOperation( - server, - withAdmittedAnalysis, - contract.name, - context.mcpReq.signal, - async () => { - const result = await logToolExecution( - logger, - contract.name, - async () => { - const parameters = { detail }; - const ready = delivery.preflight(parameters); - if (!ready.ok) return ready; - const inspected = await entropyService.inspect(request, { - signal: context.mcpReq.signal, - }); - return inspected.ok - ? delivery.projectEvidence(parameters, inspected.value) - : inspected; - }, - ); - return deliverEvidenceResult( - server, - result, - contract, - recordEvidence, - ); - }, - ), + createRetainedSummaryToolHandler( + server, + contract, + (input, options) => entropyService.inspect(input, options), + logger, + { recordEvidence, evidenceById }, + withAdmittedAnalysis, + ), ); }; diff --git a/src/server/registerCloseLifecycleTool.ts b/src/server/registerCloseLifecycleTool.ts index 4a8fd5a2b8..b410d39d71 100644 --- a/src/server/registerCloseLifecycleTool.ts +++ b/src/server/registerCloseLifecycleTool.ts @@ -1,3 +1,7 @@ +import { + retainSavedAnalysisSnapshot, + type SavedAnalysisSnapshot, +} from "../domain/savedAnalysisSnapshot.js"; import { reportOperationEnd, reportOperationStart, @@ -33,6 +37,7 @@ export const registerCloseLifecycleTool = ({ }: LifecycleToolRegistration): void => { const closeWithResources = async ( closeSession: () => Promise>, + savedSnapshot?: (value: Value) => SavedAnalysisSnapshot, ): Promise> => { const [sessionClose, resourceClose] = await Promise.allSettled([ closeResult(closeSession), @@ -69,7 +74,15 @@ export const registerCloseLifecycleTool = ({ ); return closed; } - return resources.ok ? closed : resources; + if (resources.ok) return closed; + return savedSnapshot === undefined + ? resources + : err( + retainSavedAnalysisSnapshot( + resources.error, + savedSnapshot(closed.value), + ), + ); }; server.registerTool( closeContract.name, @@ -89,10 +102,12 @@ export const registerCloseLifecycleTool = ({ return server.delivery.toCallToolResult(closed, closeContract); } const closing = logToolExecution(logger, closeContract.name, () => - closeWithResources(() => - session.closeWithSnapshot(snapshotPath, input.overwrite, { - progress, - }), + closeWithResources( + () => + session.closeWithSnapshot(snapshotPath, input.overwrite, { + progress, + }), + (receipt) => receipt, ), ); await reportOperationStart( diff --git a/src/server/registerReverseEngineeringTools.ts b/src/server/registerReverseEngineeringTools.ts index 0aa32821b8..4e0ea35ef8 100644 --- a/src/server/registerReverseEngineeringTools.ts +++ b/src/server/registerReverseEngineeringTools.ts @@ -1,9 +1,5 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import type { - CallToolResult, - McpServer, - ServerContext, -} from "@modelcontextprotocol/server"; +import type { McpServer, ServerContext } from "@modelcontextprotocol/server"; import type { Logger } from "pino"; import { @@ -13,11 +9,11 @@ import { import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { toolContract } from "../contracts/toolContracts.js"; -import type { ToolContract } from "../contracts/toolContractTypes.js"; import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { ToolResultDelivery } from "./ToolResultDelivery.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { runAdmittedToolOperation } from "./admittedToolOperation.js"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { Result } from "../domain/result.js"; @@ -48,11 +44,11 @@ export const registerReverseEngineeringTools = ( objdump.name, toolRegistrationOptions(objdump), (input, context: ServerContext) => - runAdmitted( - objdump, - context, + runAdmittedToolOperation( + { delivery }, withAdmittedAnalysis, - delivery, + objdump.name, + context.mcpReq.signal, async () => { const result = await logToolExecution(logger, objdump.name, () => service.inspectWithObjdump( @@ -144,11 +140,11 @@ export const registerReverseEngineeringTools = ( recordEvidence?.(result.value), ); } - return runAdmitted( - rizin, - context, + return runAdmittedToolOperation( + { delivery }, withAdmittedAnalysis, - delivery, + rizin.name, + context.mcpReq.signal, async () => { if (invalidateSnapshotForExternalMutation !== undefined && parsed.ok) invalidateSnapshotForExternalMutation(); @@ -175,21 +171,3 @@ export const registerReverseEngineeringTools = ( }, ); }; - -const runAdmitted = async ( - contract: ToolContract, - context: ServerContext, - admission: WithAdmittedAnalysis | undefined, - delivery: ToolResultDelivery, - operation: () => Promise, -): Promise => { - if (admission === undefined) return operation(); - const admitted = await admission( - contract.name, - context.mcpReq.signal, - async () => operation(), - ); - return admitted.ok - ? admitted.value - : delivery.toErrorToolResult(admitted.error, contract.returnsTargetContent); -}; diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index a3b938391d..ae745bf5f4 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -8,7 +8,6 @@ import { writeEvidenceBundle, } from "../application/EvidenceBundleFiles.js"; import { toolContract } from "../contracts/toolContracts.js"; -import type { EvidenceBundle } from "../domain/evidenceBundle.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; @@ -117,11 +116,6 @@ const registerImportEvidenceTool = ({ ); if (!loaded.ok) return server.delivery.toCallToolResult(loaded, importContract); - const retainedUnknownRevisions = new Set( - session - .immutableEvidenceBundle() - .unknowns.map((unknown) => unknownRevisionKey(unknown)), - ); if (signal.aborted) return server.delivery.toCallToolResult( err( @@ -135,14 +129,9 @@ const registerImportEvidenceTool = ({ return imported.ok ? server.delivery.toCallToolResult( ok({ - imported: imported.value, - unknowns_added: loaded.value.unknowns.filter( - (unknown) => - !retainedUnknownRevisions.has( - unknownRevisionKey(unknown), - ), - ).length, - total: session.immutableEvidenceBundle().records.length, + imported: imported.value.recordsAdded, + unknowns_added: imported.value.unknownsAdded, + total: imported.value.totalRecords, }), importContract, ) @@ -152,10 +141,6 @@ const registerImportEvidenceTool = ({ ); }; -const unknownRevisionKey = ( - unknown: EvidenceBundle["unknowns"][number], -): string => `${unknown.unknown_id}:${String(unknown.revision)}`; - interface UnknownToolRegistration { readonly server: EvidenceMcpServer; readonly session: BinarySessionPort; diff --git a/src/wasm/WabtComment.ts b/src/wasm/WabtComment.ts new file mode 100644 index 0000000000..0224e25ba3 --- /dev/null +++ b/src/wasm/WabtComment.ts @@ -0,0 +1,28 @@ +/** Skip a WAT comment without interpreting its punctuation as forms or strings. */ +export const watCommentEnd = ( + text: string, + start: number, +): number | undefined => { + if (text.startsWith(";;", start)) { + // WAT admits LF, CR, and CRLF as newlines; neither CR nor LF is + // comment content, even when a producer uses CR-only output. + for (let index = start + 2; index < text.length; index++) { + if (text[index] === "\n") return index + 1; + if (text[index] === "\r") + return index + (text[index + 1] === "\n" ? 2 : 1); + } + return text.length; + } + if (!text.startsWith("(;", start)) return undefined; + let depth = 1; + for (let index = start + 2; index < text.length - 1; index++) { + if (text.startsWith("(;", index)) { + depth++; + index++; + } else if (text.startsWith(";)", index)) { + if (--depth === 0) return index + 2; + index++; + } + } + throw new Error("Incomplete WABT WAT comment."); +}; diff --git a/src/wasm/WabtInterface.test.ts b/src/wasm/WabtInterface.test.ts index 7ad73834fa..07cb08ac69 100644 --- a/src/wasm/WabtInterface.test.ts +++ b/src/wasm/WabtInterface.test.ts @@ -69,6 +69,44 @@ it("does not bind recursive-type index spaces by guessing their cardinality", () ]); }); +it.each(["\t", "\n", " (; outer (; nested ;) ;) "])( + "uses parsed group heads for type uses and mutable globals with %j separators", + (separator) => { + const imported = `(import "env" "f" (func (${separator}type${separator}$signature)))`; + const global = `(global $g (${separator}mut${separator}i64) (i64.const 1))`; + const result = parseWabtWat(`(module + (type $signature (func (param i32) (result i64))) + ${imported} + (func (${separator}type${separator}99)) + ${global} + (tag (${separator}type${separator}$signature)) + (export "unknown" (func 1)) + (export "global" (global $g)) + (export "tag" (tag 0)))`); + expect(result.imports).toEqual([imported]); + expect(result.interface.state).toBe("complete"); + expect(result.interface.entries).toMatchObject([ + { + name: "f", + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + wat_form: imported, + issue: null, + }, + { name: "unknown", index: 1, signature: null, issue: expect.any(String) }, + { + name: "global", + signature: { kind: "global", value_type: "i64", mutable: true }, + issue: null, + }, + { + name: "tag", + signature: { kind: "tag", params: ["i32"], results: ["i64"] }, + issue: null, + }, + ]); + }, +); + it("preserves custom memory page sizes and treats unadmitted nested declarations as unknown", () => { const custom = parseWabtWat( '(module (memory 1 2 (pagesize 1)) (export "m" (memory 0)))', @@ -147,3 +185,43 @@ it("keeps collided labels unknown in each index space and preserves numeric and expect.stringContaining("func index space"), ]); }); + +it.each(["\n", "\r", "\r\n"])( + "shares comment framing across module and nested interface declarations with %j newlines", + (newline) => { + const source = `(module + (type (func + (param (; unmatched ) and quote " (; nested ;) ;) i32) + ;; unmatched ( and quote " + (result i64))) + (import "env" "f" (func (type 0))) + (export "f" (func 0))) ;; trailing comment without newline`; + const result = parseWabtWat(source.replaceAll("\n", newline)); + expect(result.imports).toEqual(['(import "env" "f" (func (type 0)))']); + expect(result.exports).toEqual(['(export "f" (func 0))']); + expect(result.interface.entries).toMatchObject([ + { + name: "f", + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + }, + { + name: "f", + signature: { kind: "func", params: ["i32"], results: ["i64"] }, + }, + ]); + }, +); + +it("rejects unfinished nested comments rather than projecting a truncated interface", () => { + for (const source of [ + "(module (type (func (param (; unfinished ) i32))))", + "(module) (; outer (; inner ;) still open", + ]) + expect(() => parseWabtWat(source)).toThrow("Incomplete WABT WAT comment"); +}); + +it("rejects a second module after a CR-terminated line comment", () => { + expect(() => parseWabtWat("(module) ;; comment\r(module)")).toThrow( + "Unexpected second WABT WAT module", + ); +}); diff --git a/src/wasm/WabtInterface.ts b/src/wasm/WabtInterface.ts index b387211a00..9ba9260cc4 100644 --- a/src/wasm/WabtInterface.ts +++ b/src/wasm/WabtInterface.ts @@ -8,6 +8,7 @@ import type { WasmInterfaceValueType, } from "../domain/wasm/wasmInterface.js"; import { watString, watStringEnd } from "./WabtString.js"; +import { watCommentEnd } from "./WabtComment.js"; /** Split direct WAT children only; function instruction bodies are never materialized. */ const parts = (form: string): string[] => { @@ -19,23 +20,9 @@ const parts = (form: string): string[] => { i++; continue; } - if (form.startsWith("(;", i)) { - let depth = 1; - i += 2; - while (i < form.length && depth > 0) { - if (form.startsWith("(;", i)) { - depth++; - i += 2; - } else if (form.startsWith(";)", i)) { - depth--; - i += 2; - } else i++; - } - continue; - } - if (form.startsWith(";;", i)) { - const end = form.indexOf("\n", i); - i = end < 0 ? form.length : end + 1; + const commentEnd = watCommentEnd(form, i); + if (commentEnd !== undefined) { + i = commentEnd; continue; } const start = i; @@ -45,7 +32,9 @@ const parts = (form: string): string[] => { let depth = 1; i++; while (i < form.length && depth > 0) { - if (form[i] === '"') i = watStringEnd(form, i + 1); + const commentEnd = watCommentEnd(form, i); + if (commentEnd !== undefined) i = commentEnd; + else if (form[i] === '"') i = watStringEnd(form, i + 1); else { if (form[i] === "(") depth++; else if (form[i] === ")") depth--; @@ -154,7 +143,9 @@ const decodeWabtInterface = ( const p = parts(form); const kind = p[0]; if (kind === "func" || kind === "tag") { - const use = p.find((t) => t.startsWith("(type ")); + // Interpret group heads through the same comment/whitespace framing as + // their values; raw prefixes can mistake an explicit type for no type. + const use = p.find((t) => t.startsWith("(") && head(t) === "type"); if (use === undefined) return functionType(form, kind); const reference = parts(use)[1]; const index = @@ -220,11 +211,10 @@ const decodeWabtInterface = ( } if (kind === "global") { const declaration = p[1]?.startsWith("$") === true ? p[2] : p[1]; - const mutable = declaration?.startsWith("(mut ") === true; - const value_type = - mutable && declaration !== undefined - ? parts(declaration)[1] - : declaration; + const group = + declaration?.startsWith("(") === true ? parts(declaration) : undefined; + const mutable = group?.[0] === "mut"; + const value_type = mutable ? group?.[1] : declaration; return value_type !== undefined && isValueType(value_type) ? { kind, value_type, mutable } : null; diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts index cf8487a4c9..cbb0a3edc5 100644 --- a/src/wasm/WabtWat.ts +++ b/src/wasm/WabtWat.ts @@ -1,5 +1,6 @@ import { parseWabtInterface } from "./WabtInterface.js"; import { watString, watStringEnd } from "./WabtString.js"; +import { watCommentEnd } from "./WabtComment.js"; import { err, ok, type Result } from "../domain/result.js"; interface WabtNameMetadataFailure { @@ -77,42 +78,20 @@ export const parseWabtWat = (text: string) => { "not-present"; let depth = 0; let start = 0; - let commentDepth = 0; - let lineComment = false; let closed = false; const boundaries = /[()";]/gu; for (let index = 0; index < text.length; index++) { // Instructions and whitespace inside a form cannot change its framing. - if (depth > 0 && !lineComment && commentDepth === 0) { + if (depth > 0) { boundaries.lastIndex = index; const boundary = boundaries.exec(text); if (boundary === null) break; index = boundary.index; } const char = text[index]; - const next = text[index + 1]; - if (lineComment) { - if (char === "\n") lineComment = false; - continue; - } - if (commentDepth > 0) { - if (char === "(" && next === ";") { - commentDepth++; - index++; - } else if (char === ";" && next === ")") { - commentDepth--; - index++; - } - continue; - } - if (char === ";" && next === ";") { - lineComment = true; - index++; - continue; - } - if (char === "(" && next === ";") { - commentDepth++; - index++; + const commentEnd = watCommentEnd(text, index); + if (commentEnd !== undefined) { + index = commentEnd - 1; continue; } if (char === '"') { @@ -174,8 +153,7 @@ export const parseWabtWat = (text: string) => { } else if (depth === 0 && char !== undefined && !/\s/u.test(char)) throw new Error("Unexpected text outside WABT WAT module."); } - if (depth !== 0 || commentDepth !== 0) - throw new Error("Incomplete WABT WAT output."); + if (depth !== 0) throw new Error("Incomplete WABT WAT output."); return { imports, exports, diff --git a/tests/acceptance/applications/artifactComparisonCoverage.test.ts b/tests/acceptance/applications/artifactComparisonCoverage.test.ts index b60d46da19..e84138a9a8 100644 --- a/tests/acceptance/applications/artifactComparisonCoverage.test.ts +++ b/tests/acceptance/applications/artifactComparisonCoverage.test.ts @@ -3,12 +3,14 @@ import { createServer } from "node:net"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { expect } from "vitest"; +import { machoImage } from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../../../src/domain/artifactGraph.js"; import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { artifactIdForContent } from "../../../src/domain/artifactIdentity.js"; import { createEvidence, parseEvidence, @@ -16,6 +18,7 @@ import { } from "../../../src/domain/evidence.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; @@ -134,6 +137,214 @@ const assertCoverageControls = async ({ ).toThrow(); }; +const assertMalformedImportsRejected = async ( + left: Evidence, + right: Evidence, + inventory: ArtifactInventoryResult, + call: Awaited>["call"], +) => { + // Reauthenticate an imported malformed carrier; this does not claim that + // the real inventory producer emitted members beyond its commitment. + const sha256 = "f".repeat(64); + const oversized = createEvidence( + { + path: right.subject!.local_path, + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + right.provider, + { + operation: "inventory_artifact", + parameters: {}, + result: { + ...inventory, + nodes: [ + ...inventory.nodes, + { + ...inventory.nodes[0]!, + sha256, + artifact_id: artifactIdForContent(sha256), + }, + ], + }, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const rejected = await call("compare_artifacts", { + left, + right: oversized, + }); + expect(rejected.isError, JSON.stringify(rejected)).toBe(true); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); + // Missing members do not prevent checking identities whose complete inputs + // are already present on an imported page. + for (const result of [ + ...[ + { occurrence_id: `occ_${"f".repeat(64)}` }, + { artifact_id: null }, + { artifact_format: "unknown" }, + { entry_kind: "directory" }, + { parent_occurrence_id: `occ_${"f".repeat(64)}` }, + ].map((patch) => ({ + ...inventory, + nodes: [], + occurrences: inventory.occurrences.map((occurrence) => ({ + ...occurrence, + ...patch, + })), + })), + { + ...inventory, + nodes: [], + manifest: { + ...inventory.manifest, + manifest_id: `agm_${"f".repeat(64)}`, + }, + }, + { + ...inventory, + nodes: [], + manifest: { + ...inventory.manifest, + root_artifact_id: artifactIdForContent(sha256), + }, + }, + ]) { + const malformed = createEvidence( + { + path: right.subject!.local_path, + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + right.provider, + { + operation: "inventory_artifact", + parameters: {}, + result, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const response = await call("compare_artifacts", { + left, + right: malformed, + }); + expect(response.isError, JSON.stringify(response)).toBe(true); + expect(parseMcpToolError(response)).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); + } +}; + +cliTest( + "validates supplied edge commitments on imported partial inventories", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-partial-edge-"); + const path = join(root, "input.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("a.txt", new TextReader("abc")); + await writer.add("b.txt", new TextReader("def")); + await writeFile(path, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const source = inventoryEvidence(inspected.json); + const inventory = artifactInventoryResultSchema.parse( + source.normalized_result, + ); + const edge = inventory.edges[0]!; + const occurrence = inventory.occurrences.find( + (item) => item.occurrence_id === edge.occurrence_id, + )!; + const omittedNode = inventory.nodes.find( + (node) => + node.artifact_id !== inventory.manifest.root_artifact_id && + node.artifact_id !== occurrence.artifact_id, + )!; + const partial = { + ...inventory, + nodes: inventory.nodes.filter( + (node) => node.artifact_id !== omittedNode.artifact_id, + ), + }; + const derive = (result: unknown) => + createEvidence( + { + path, + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + source.provider, + { + operation: "inventory_artifact", + parameters: {}, + result, + confidence: "derived", + authority: "shipped-artifact", + }, + ); + const { call } = await connectLocalToolsMcp(); + const valid = await call("compare_artifacts", { + left: derive(partial), + right: derive(partial), + }); + expect(valid.isError, JSON.stringify(valid)).not.toBe(true); + expect( + parseEvidence(valid.structuredContent).normalized_result, + ).toMatchObject({ + status: "truncated", + summary: { changed: 0 }, + }); + const tampered = { + ...partial, + edges: partial.edges.map((item) => + item === edge ? { ...item, relation: "loads" } : item, + ), + }; + const rejected = await call("compare_artifacts", { + left: derive(partial), + right: derive(tampered), + }); + expect(rejected.isError, JSON.stringify(rejected)).toBe(true); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); + const nullChild = await call("compare_artifacts", { + left: derive(partial), + right: derive({ + ...partial, + occurrences: partial.occurrences.map((item) => + item === occurrence ? { ...item, artifact_id: null } : item, + ), + }), + }); + expect(nullChild.isError, JSON.stringify(nullChild)).toBe(true); + expect(parseMcpToolError(nullChild)).toMatchObject({ + error: { code: "evidence_integrity_mismatch" }, + }); + // Without the referenced occurrence, the digest cannot yet be checked. + const unresolved = await call("compare_artifacts", { + left: derive(partial), + right: derive({ + ...tampered, + occurrences: tampered.occurrences.filter( + (item) => item.occurrence_id !== edge.occurrence_id, + ), + }), + }); + expect(unresolved.isError, JSON.stringify(unresolved)).not.toBe(true); + expect( + parseEvidence(unresolved.structuredContent).normalized_result, + ).toMatchObject({ + status: "truncated", + }); + }, +); + cliTest( "compares fully observed roots without inventing coverage gaps through CLI and MCP", async ({ cli }) => { @@ -166,6 +377,7 @@ cliTest( const inventory = artifactInventoryResultSchema.parse( right.normalized_result, ); + await assertMalformedImportsRejected(left, right, inventory, call); expect(inventory.coverage).toEqual({ scope: "root", status: "complete" }); expect(inventory.manifest).toMatchObject({ root_format: "plist", @@ -294,9 +506,9 @@ cliTest.skipIf(process.platform === "win32")( [leftPath, rightPath].flatMap((path) => [ writeFile( join(path, "Info.plist"), - "CFBundleExecutablemain.js", + "CFBundleExecutablemain", ), - writeFile(join(path, "main.js"), "export const value = 1;"), + writeFile(join(path, "main"), machoImage({})), ]), ); const fifo = await processes.run("mkfifo", [join(leftPath, "p")]); diff --git a/tests/acceptance/applications/bundleResourceTargetIdentity.test.ts b/tests/acceptance/applications/bundleResourceTargetIdentity.test.ts new file mode 100644 index 0000000000..7a857f24e4 --- /dev/null +++ b/tests/acceptance/applications/bundleResourceTargetIdentity.test.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + "decode_interface_builder", + "inspect_keyed_archive", + ...(process.platform === "darwin" + ? ["inspect_asset_catalog", "inspect_plist"] + : []), +] as const)( + "binds live %s Evidence to the open executable through stdio MCP", + async (operation) => { + const root = await createTestTempDirectory("rea-bundle-resource-identity-"); + const app = join(root, "Probe.app"); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + const executable = join(contents, "MacOS", "Probe"); + const original = thinMach(0xfeedfacf, 0x0100000c); + await writeFile(executable, original); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableProbe", + ); + const nib = await readFile( + join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"), + "utf8", + ); + const nibPath = join(resources, "Main.nib"); + await writeFile(nibPath, nib); + const { call } = await connectLocalToolsMcp(); + const parameters = + operation === "inspect_keyed_archive" + ? { path: "Contents/Resources/Main.nib" } + : {}; + expect((await call("open_binary", { path: app })).isError).not.toBe(true); + const before = await call(operation, parameters); + expect(before.isError).not.toBe(true); + const initial = parseEvidence(before.structuredContent); + expect(initial.subject?.digest.sha256).toBe( + createHash("sha256").update(original).digest("hex"), + ); + + const changed = Buffer.from(original); + changed[31] = 1; + await writeFile(executable, changed); + const changedNib = nib.replaceAll("UIViewController", "ChangedController"); + expect(changedNib).not.toBe(nib); + await writeFile(nibPath, changedNib); + if (operation === "inspect_plist") + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableProbeMarkerChangedController", + ); + const stale = await call(operation, parameters); + expect(stale.isError).toBe(true); + expect(JSON.stringify(stale)).toContain("integrity_mismatch"); + + expect((await call("open_binary", { path: app })).isError).not.toBe(true); + const after = await call(operation, parameters); + expect(after.isError).not.toBe(true); + const refreshed = parseEvidence(after.structuredContent); + expect(refreshed.subject?.digest.sha256).toBe( + createHash("sha256").update(changed).digest("hex"), + ); + if (operation !== "inspect_asset_catalog") + expect(JSON.stringify(refreshed.normalized_result)).toContain( + "ChangedController", + ); + if (operation !== "inspect_asset_catalog" && operation !== "inspect_plist") + expect(JSON.stringify(refreshed.normalized_result)).toContain( + createHash("sha256").update(changedNib).digest("hex"), + ); + }, +); diff --git a/tests/acceptance/applications/mobileFrameworkConventions.test.ts b/tests/acceptance/applications/mobileFrameworkConventions.test.ts index 0691067ccf..e99597010b 100644 --- a/tests/acceptance/applications/mobileFrameworkConventions.test.ts +++ b/tests/acceptance/applications/mobileFrameworkConventions.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { @@ -89,7 +90,9 @@ cliTest( await writer.add(name, new Uint8ArrayReader(fixture.nativeBytes)); await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const input = { inventory_evidence: [inventory] }; const result = await cli.run({ diff --git a/tests/acceptance/applications/nativePlistBinaryScalars.test.ts b/tests/acceptance/applications/nativePlistBinaryScalars.test.ts index d659bb1110..0b17b4b893 100644 --- a/tests/acceptance/applications/nativePlistBinaryScalars.test.ts +++ b/tests/acceptance/applications/nativePlistBinaryScalars.test.ts @@ -31,6 +31,9 @@ const makeBinaryPlist = () => { negative: 1.5, positive: 0.25, wide: 9007199254740992, + // The writer shares this scalar with wide; patching its one source record + // tests exact integers inside an ordinary binary dictionary, not a UID. + wideDictionary: { CF$UID: 9007199254740992 }, wide128: Buffer.alloc(16), plusInfinity: Number.POSITIVE_INFINITY, minusInfinity: Number.NEGATIVE_INFINITY, @@ -143,6 +146,9 @@ describe.skipIf(process.platform !== "darwin")( negative: { $plist_type: "real", value: "-0" }, positive: 0, wide: { $plist_type: "integer", decimal: "9007199254740993" }, + wideDictionary: { + CF$UID: { $plist_type: "integer", decimal: "9007199254740993" }, + }, wide128: { $plist_type: "integer", decimal: "18446744073709551658", @@ -157,7 +163,7 @@ describe.skipIf(process.platform !== "darwin")( expect.arrayContaining([ expect.stringContaining("1 negative-zero real value(s)"), expect.stringContaining("3 non-finite real value(s)"), - expect.stringContaining("2 unambiguous integer value(s)"), + expect.stringContaining("3 unambiguous integer value(s)"), ]), ); expect(binaryCliResult.limitations).not.toContainEqual( diff --git a/tests/acceptance/artifactAdmittedIdentityMcp.test.ts b/tests/acceptance/artifactAdmittedIdentityMcp.test.ts index 392f8203fd..84b59a483e 100644 --- a/tests/acceptance/artifactAdmittedIdentityMcp.test.ts +++ b/tests/acceptance/artifactAdmittedIdentityMcp.test.ts @@ -1,12 +1,14 @@ +import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { expect, it } from "vitest"; import { artifactExtractionResultSchema } from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; import { parseEvidence } from "../../src/domain/evidence.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { createSingleEntryZip } from "../fixtures/zipNames.js"; it("refuses changed admitted archives over stdio MCP before publishing extraction and accepts an explicit reopen", async () => { const root = await createTestTempDirectory("rea-admitted-archive-mcp-"); @@ -14,11 +16,11 @@ it("refuses changed admitted archives over stdio MCP before publishing extractio await mkdir(temporary); const path = join(root, "selected.zip"); const writeArchive = async (value: string) => { - const zip = new ZipWriter(new Uint8ArrayWriter()); - await zip.add("payload.txt", new TextReader(value)); - await writeFile(path, await zip.close()); + const bytes = await createSingleEntryZip("payload.txt", value); + await writeFile(path, bytes); + return createHash("sha256").update(bytes).digest("hex"); }; - await writeArchive("admitted version one"); + const initialDigest = await writeArchive("admitted version one"); const { call } = await connectLocalToolsMcp({ TMPDIR: temporary, TEMP: temporary, @@ -26,8 +28,39 @@ it("refuses changed admitted archives over stdio MCP before publishing extractio REA_MCP_TOOL_FAMILIES: "session,artifact", }); expect((await call("open_binary", { path })).isError).not.toBe(true); + const inspect = async (digest: string, payload: string) => { + const inspected = await call("inspect_artifact", { detail: "complete" }); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const evidence = parseEvidence(inspected.structuredContent); + expect(evidence.subject?.digest.sha256).toBe(digest); + const inspection = artifactInspectionResultSchema.parse( + evidence.normalized_result, + ); + expect(inspection.subject).toMatchObject({ + root_sha256: digest, + root_format: "zip", + }); + const inventoryStep = inspection.substeps.find( + (substep) => substep.operation === "inventory_artifact", + ); + expect(inventoryStep).toBeDefined(); + if (inventoryStep === undefined) + throw new Error("Missing inventory substep"); + const inventory = inventoryStep.evidence.normalized_result; + const member = inventory.occurrences.find( + (occurrence) => occurrence.logical_path === "payload.txt", + ); + expect(member).toBeDefined(); + expect( + inventory.nodes.find( + (artifact) => artifact.artifact_id === member?.artifact_id, + )?.sha256, + ).toBe(createHash("sha256").update(payload).digest("hex")); + return evidence; + }; + const initial = await inspect(initialDigest, "admitted version one"); const openedTemporaryBaseline = (await readdir(temporary)).sort(); - await writeArchive("changed version two"); + const changedDigest = await writeArchive("changed version two"); for (const operation of ["inspect_artifact", "extract_artifact"]) { const refused = parseMcpToolError(await call(operation, {})); expect(refused.error).toMatchObject({ @@ -38,6 +71,8 @@ it("refuses changed admitted archives over stdio MCP before publishing extractio expect((await readdir(temporary)).sort()).toEqual(openedTemporaryBaseline); } expect((await call("open_binary", { path })).isError).not.toBe(true); + const reopened = await inspect(changedDigest, "changed version two"); + expect(reopened.evidence_id).not.toBe(initial.evidence_id); const extracted = await call("extract_artifact", {}); expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true); const result = artifactExtractionResultSchema.parse( diff --git a/tests/acceptance/gpuCodeObjectsCli.test.ts b/tests/acceptance/gpuCodeObjectsCli.test.ts index 652219d235..d8cc4a9733 100644 --- a/tests/acceptance/gpuCodeObjectsCli.test.ts +++ b/tests/acceptance/gpuCodeObjectsCli.test.ts @@ -20,6 +20,7 @@ import { ccobFixture, ptxFixture, amdFixture, + amdNoteFixture, cubinFixture, hipFixture, elfFixture, @@ -1186,3 +1187,150 @@ cliTest( }); }, ); + +cliTest( + "retains AMD target declarations and examined note coverage through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-amd-target-sources-"); + const { call } = await connectLocalToolsMcp(); + const first = "amdgcn-amd-amdhsa--gfx900"; + const second = "amdgcn-amd-amdhsa--gfx1100"; + const legacyNote = Buffer.alloc(20); + legacyNote.writeUInt32LE(4, 0); + legacyNote.writeUInt32LE(4, 4); + legacyNote.writeUInt32LE(10, 8); + legacyNote.write("AMD\0", 12); + const headerConflict = amdFixture(); + headerConflict.writeUInt32LE(0x141, 48); + for (const [name, bytes, declarations, coverage, effective] of [ + [ + "note-conflict", + amdFixture(false, amdNoteFixture(false, second)), + [first, second], + "complete", + null, + ], + ["header-conflict", headerConflict, [first], "complete", null], + [ + "duplicate", + amdFixture(false, amdNoteFixture()), + [first, first], + "complete", + first, + ], + ["legacy-tail", amdFixture(false, legacyNote), [first], "partial", first], + [ + "invalid-target", + amdFixture(false, undefined, 123), + [null], + "partial", + null, + ], + [ + "absent-target", + amdFixture(false, undefined, null), + [null], + "complete", + "gfx900", + ], + [ + "truncated-tail", + amdFixture(false, Buffer.alloc(3)), + [first], + "partial", + first, + ], + ] as const) { + const path = join(root, name); + await writeFile(path, bytes); + const response = await cli.run({ + arguments: [ + "inspect-gpu-code-objects", + path, + "--analysis", + "kernels", + "--detail", + "complete", + "--json", + ], + }); + expect(response.exitCode, response.stderr).toBe(0); + const reply = await call("inspect_gpu_code_objects", { + path, + analysis: "kernels", + detail: "complete", + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + for (const value of [response.json, reply.structuredContent]) { + const report = gpuCodeObjectsSchema.parse( + parseEvidence(value).normalized_result, + ); + const row = report.objects[0]!; + expect(row).toMatchObject({ + target: effective, + payload_target: effective, + }); + const metadata = row.amd_target_metadata!; + expect(metadata).toMatchObject({ + flags: name === "header-conflict" ? "0x00000141" : "0x0000012c", + processor: name === "header-conflict" ? "gfx1100" : "gfx900", + header_location: { container_id: row.id, offset: 0, size: 64 }, + note_coverage: coverage, + }); + expect(metadata.notes.map(({ target }) => target)).toEqual( + declarations, + ); + expect( + new Set(metadata.notes.map(({ location }) => location.offset)).size, + ).toBe(declarations.length); + for (const note of metadata.notes) { + expect(note.location.container_id).toBe(row.id); + expect(note.location.section_name).toBe(".note"); + const source = bytes.subarray( + note.location.offset, + note.location.offset + note.location.size, + ); + if (note.target !== null) + expect(source.includes(Buffer.from(note.target))).toBe(true); + expect(note.target_state).toBe( + name === "invalid-target" + ? "invalid" + : name === "absent-target" + ? "absent" + : "declared", + ); + } + expect(report.kernels).toHaveLength(declarations.length); + if (coverage === "partial") + expect(report.coverage.payloads).toBe("partial"); + } + } + }, +); + +it("does not interpret non-AMD ELF flags as AMD processor declarations through MCP", async () => { + const root = await createTestTempDirectory("rea-amd-machine-"); + const path = join(root, "wrong-machine"); + const payload = amdFixture(); + payload.writeUInt16LE(62, 18); + await writeFile(path, hipFixture(payload, "hip-amdgcn-amd-amdhsa--")); + const { call } = await connectLocalToolsMcp(); + const reply = await call("inspect_gpu_code_objects", { + path, + analysis: "kernels", + detail: "complete", + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const report = gpuCodeObjectsSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(report.objects.find(({ kind }) => kind === "amdgpu")).toMatchObject({ + target: null, + payload_target: null, + amd_target_metadata: null, + target_consistency: "unknown", + inspection: "malformed", + }); + expect(report.kernels).toEqual([]); + expect(report.coverage.payloads).toBe("partial"); +}); diff --git a/tests/acceptance/setup/droidSelectedHomeCli.test.ts b/tests/acceptance/setup/droidSelectedHomeCli.test.ts new file mode 100644 index 0000000000..0cf43456d5 --- /dev/null +++ b/tests/acceptance/setup/droidSelectedHomeCli.test.ts @@ -0,0 +1,57 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "registers Droid in its selected home and preserves peers and backups", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-droid-home-"); + const selected = join(home, "selected"); + const config = join(selected, ".factory", "mcp.json"); + const original = JSON.stringify({ + mcpServers: { peer: { command: "peer" } }, + }); + await mkdir(join(selected, ".factory"), { recursive: true }); + await writeFile(config, original); + const run = () => + cli.run({ + arguments: [ + "setup", + "--client", + "droid", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + FACTORY_HOME_OVERRIDE: selected, + HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"), + GHIDRA_INSTALL_DIR: join(home, "missing-ghidra"), + JAVA_HOME: join(home, "missing-java"), + }, + }); + const first = await run(); + expect(first.exitCode, first.stdout).toBe(0); + const configured = await readFile(config, "utf8"); + expect(JSON.parse(configured)).toMatchObject({ + mcpServers: { + peer: { command: "peer" }, + rea: { args: expect.arrayContaining(["mcp"]) }, + }, + }); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + await expect( + access(join(home, ".factory", "mcp.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + const second = await run(); + expect(second.exitCode, second.stdout).toBe(0); + expect(await readFile(config, "utf8")).toBe(configured); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + }, +); diff --git a/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts b/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts index 97a3611361..796d1aa5f7 100644 --- a/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts +++ b/tests/acceptance/setup/duplicateJsonMemberSetup.test.ts @@ -40,10 +40,7 @@ for (const [layout, original] of [ "a repeated server table", `{\n ${shadowParent}\n "mcpServers": {"peer": {"command": "/usr/bin/true"}}\n}\n`, ], - [ - "a repeated rea member", - `{\n "mcpServers": {${shadowLeaf} "rea": {"command": "/usr/bin/true"}}\n}\n`, - ], + ["a repeated rea member", `{\n "mcpServers": {${shadowLeaf} ${owned}}\n}\n`], ] as const) cliTest( `Claude Code setup edits the effective member of ${layout}`, @@ -254,11 +251,11 @@ cliTest( ); cliTest( - "OpenCode setup replaces every repeated V1 entry while preserving the V2 peer", + "OpenCode setup migrates every owned repeated V1 entry while preserving the V2 peer", async ({ cli }) => { const home = await createTestTempDirectory("rea-json-opencode-migration-"); const config = join(home, ".config", "opencode", "opencode.json"); - const original = `{"mcp":{"servers":{"peer":{"type":"local","command":["/usr/bin/true"]}},"rea":${openCodeForeign},"rea":${openCodeForeign}}}\n`; + const original = `{"mcp":{"servers":{"peer":{"type":"local","command":["/usr/bin/true"]}},"rea":${openCodeOwned},"rea":${openCodeOwned}}}\n`; await writeConfiguration(config, original); const setup = await runIn(cli, home)(setupArguments("opencode")); diff --git a/tests/acceptance/setup/registrationOwnershipSetup.test.ts b/tests/acceptance/setup/registrationOwnershipSetup.test.ts new file mode 100644 index 0000000000..ffa3b34461 --- /dev/null +++ b/tests/acceptance/setup/registrationOwnershipSetup.test.ts @@ -0,0 +1,332 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const setup = ["setup", "--client", "claude_code", "--skill=false", "--json"]; + +cliTest( + "setup preserves a replacement foreign registration and its original backup", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-ownership-"); + const config = join(home, ".claude.json"); + const original = '{"mcpServers":{"peer":{"command":"peer"}}}\n'; + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home }, + }); + expect((await run([...setup, "--yes"])).exitCode).toBe(0); + const replacement = + '{"mcpServers":{"rea":{"command":"peer-owned-server","args":["mcp"]},"peer":{"command":"peer-edited"}},"theme":"edited"}\n'; + await writeFile(config, replacement); + for (const mode of ["--dry-run", "--yes"]) { + const result = await run([...setup, mode]); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(config, "utf8")).toBe(replacement); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + expect( + (await readdir(home)).filter((name) => name.includes(".recovery")), + ).toEqual([]); + } + expect((await run(["uninstall", "--json"])).exitCode).toBe(0); + expect(await readFile(config, "utf8")).toBe(replacement); + }, +); + +for (const [identity, entry] of [ + ["foreign", { command: "other-server", args: ["mcp"] }], + [ + "unknown", + { + command: process.execPath, + args: ["/missing/old-rea/scripts/rea.mjs", "mcp"], + }, + ], +] as const) + cliTest( + `setup blocks ${identity} ownership before any backup or skill writes`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-ownership-"); + const config = join(home, ".claude.json"); + const original = JSON.stringify({ mcpServers: { rea: entry } }); + await writeFile(config, original); + const result = await cli.run({ + arguments: ["setup", "--client", "claude_code", "--yes", "--json"], + cwd: home, + environment: { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(`${identity} ownership`), + }); + expect(await readFile(config, "utf8")).toBe(original); + const doctor = await cli.run({ + arguments: [ + "doctor", + "--client", + "claude_code", + "--skill=false", + "--json", + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home }, + }); + expect(doctor.exitCode, doctor.stdout).toBe(1); + expect(doctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "claude_code", + state: identity === "unknown" ? "unknown" : "stale", + remediation: expect.stringContaining("manually rename or remove"), + }), + ]), + }, + }); + expect(await readdir(home)).not.toContain(".claude.json.rea.backup"); + await expect( + readFile( + join( + home, + ".claude", + "skills", + "reverse-engineer-anything", + "SKILL.md", + ), + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + +cliTest( + "setup refreshes a proven owned stale launcher and preserves peer settings", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-owned-refresh-"); + const config = join(home, ".claude.json"); + const original = JSON.stringify({ + mcpServers: { + rea: { + command: "npx", + args: ["-y", "rea-agents@6.6.0", "mcp"], + env: { CUSTOM: "keep" }, + }, + peer: { command: "peer" }, + }, + }); + await writeFile(config, original); + const result = await cli.run({ + arguments: [...setup, "--yes"], + cwd: home, + environment: { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home }, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(JSON.parse(await readFile(config, "utf8"))).toMatchObject({ + mcpServers: { + rea: { command: process.execPath, env: { CUSTOM: "keep" } }, + peer: { command: "peer" }, + }, + }); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + }, +); + +for (const repeated of [false, true]) + cliTest( + `OpenCode setup preserves foreign legacy fallback (shadowed=${repeated})`, + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-legacy-ownership-"); + const config = join(home, ".config", "opencode", "opencode.json"); + await mkdir(dirname(config), { recursive: true }); + const foreign = '"rea":{"type":"local","command":["other"]}'; + const owned = '"rea":{"type":"local","command":["rea","mcp"]}'; + const original = `{"mcp":{${foreign},${repeated ? `${owned},` : ""}"servers":{${owned}}}}`; + await writeFile(config, original); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + +cliTest( + "ZCode setup preserves an unrelated fallback registration before its first native entry", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-zcode-ownership-"); + const fallback = join(home, ".agents", "mcp.json"); + await mkdir(dirname(fallback), { recursive: true }); + const original = + '{"mcpServers":{"rea":{"command":"other"},"peer":{"command":"peer"}}}'; + await writeFile(fallback, original); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "zcode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + remediation: expect.stringContaining(fallback), + }); + expect(await readFile(fallback, "utf8")).toBe(original); + await expect( + readFile(join(home, ".zcode", "cli", "config.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(`${fallback}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "OpenCode setup preserves an inherited foreign registration instead of shadowing it", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-layered-ownership-"); + const directory = join(home, ".config", "opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "config.json"); + const upper = join(directory, "opencode.json"); + const originalLower = + '{"mcp":{"rea":{"type":"local","command":["other"]}}}'; + const originalUpper = "{}"; + await writeFile(lower, originalLower); + await writeFile(upper, originalUpper); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + remediation: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(lower, "utf8")).toBe(originalLower); + expect(await readFile(upper, "utf8")).toBe(originalUpper); + await expect(readFile(`${upper}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest( + "OpenCode setup refreshes an owned inherited launcher through a policy fragment", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-setup-owned-layer-"); + const directory = join(home, ".config", "opencode"); + await mkdir(directory, { recursive: true }); + const lower = join(directory, "config.json"); + const upper = join(directory, "opencode.json"); + const originalLower = JSON.stringify({ + mcp: { rea: { type: "local", command: ["rea", "mcp"] } }, + }); + const originalUpper = JSON.stringify({ + mcp: { rea: { environment: { CUSTOM: "keep" } } }, + }); + await writeFile(lower, originalLower); + await writeFile(upper, originalUpper); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(lower, "utf8")).toBe(originalLower); + expect(await readFile(`${upper}.rea.backup`, "utf8")).toBe(originalUpper); + expect(JSON.parse(await readFile(upper, "utf8"))).toMatchObject({ + mcp: { rea: { type: "local", environment: { CUSTOM: "keep" } } }, + }); + const foreignFragment = JSON.stringify({ + mcp: { rea: { command: ["other", "mcp"] } }, + }); + await writeFile(upper, foreignFragment); + const blocked = await cli.run({ + arguments: [ + "setup", + "--client", + "opencode", + "--skill=false", + "--yes", + "--json", + ], + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }, + }); + expect(blocked.exitCode, blocked.stdout).toBe(1); + expect(blocked.json).toMatchObject({ + status: "needs_human", + remediation: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(upper, "utf8")).toBe(foreignFragment); + expect(await readFile(`${upper}.rea.backup`, "utf8")).toBe(originalUpper); + }, +); diff --git a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts index edbeb257a3..e6ded3b083 100644 --- a/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts +++ b/tests/acceptance/setup/registrationRuntimeEnvironmentCli.test.ts @@ -455,8 +455,8 @@ for (const scenario of scenarios) }; const registration = (env: Readonly>) => { const server = { - command: "stale-launcher", - args: ["mcp"], + command: "npx", + args: ["-y", "rea-agents@6.6.0", "mcp"], env: { CALLER_SETTING: "keep exactly", ["__proto__"]: "retain caller key", diff --git a/tests/acceptance/sqliteDatabaseCli.test.ts b/tests/acceptance/sqliteDatabaseCli.test.ts index 7662d78abb..558c203393 100644 --- a/tests/acceptance/sqliteDatabaseCli.test.ts +++ b/tests/acceptance/sqliteDatabaseCli.test.ts @@ -34,6 +34,99 @@ import { cliTest, runFileCli } from "../support/cli/cliFixture.js"; const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); +cliTest( + "unions SQLite cell slices with numeric cell paths", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-mixed-filter-"); + const path = join(root, "selected.db"); + const database = new DatabaseSync(path); + try { + database.exec( + "CREATE TABLE selected (first TEXT, second TEXT); INSERT INTO selected VALUES ('left', 'right')", + ); + } finally { + database.close(); + } + const base = "normalized_result.rows.values[0,1]"; + for (const filter of [ + `${base}[0,1].value,${base}.1.value`, + `${base}.1.value,${base}[0,1].value`, + ]) { + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "selected", + "--json", + "--filter-output", + filter, + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toEqual({ + normalized_result: { + rows: { values: [[{ value: "left" }, { value: "right" }]] }, + }, + }); + } + const refused = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "selected", + "--json", + "--filter-output", + `${base}[0,1].value,${base}.length`, + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(refused.exitCode).toBe(1); + expect(refused.json).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("non-index property"), + }); + }, +); + +cliTest( + "projects selected SQLite matrix cells through consecutive output slices", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sqlite-matrix-filter-"); + const path = join(root, "selected.db"); + const database = new DatabaseSync(path); + try { + database.exec( + "CREATE TABLE selected (first TEXT, second TEXT); INSERT INTO selected VALUES ('left', 'right')", + ); + } finally { + database.close(); + } + const result = await cli.run({ + arguments: [ + "inspect-sqlite-database", + path, + "--table", + "selected", + "--column", + "second", + "--column", + "first", + "--json", + "--filter-output", + "normalized_result.rows.values[0,1][0,1].value", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(result.json).toEqual({ + normalized_result: { rows: { values: [[{ value: "right" }]] } }, + }); + }, +); + for (const expectedErrno of ["ELOOP", "ENAMETOOLONG"] as const) { cliTest.skipIf(expectedErrno === "ELOOP" && process.platform === "win32")( `reports an initial ${expectedErrno} path failure through CLI and MCP`, diff --git a/tests/boundary/android/androidManifestComponents.test.ts b/tests/boundary/android/androidManifestComponents.test.ts index 956872d55e..0819837a11 100644 --- a/tests/boundary/android/androidManifestComponents.test.ts +++ b/tests/boundary/android/androidManifestComponents.test.ts @@ -71,6 +71,122 @@ it("keeps pre-projection package parses independent", () => { } }); +it("projects exact declared component attributes without inferring runtime defaults", () => { + const xml = + ''; + const result = parseAndroidManifestComponents(xml, "complete", "33"); + expect(result.items[0]).toMatchObject({ + enabled: true, + process: " :worker ", + grant_uri_permissions: null, + }); + expect(result.items[1]).toMatchObject({ + enabled: null, + process: null, + grant_uri_permissions: false, + }); + expect(result.items[2]).toMatchObject({ + enabled: null, + process: "", + grant_uri_permissions: null, + }); + expect(result.status).toBe("complete"); + expect(result.limitations.join(" ")).toContain("nonliteral boolean"); + expect(result.limitations.join(" ")).toContain("URI-grant state is unknown"); +}); + +it("ignores foreign namespace aliases of component access attributes", () => { + const result = parseAndroidManifestComponents( + '', + "complete", + "33", + ); + expect(result.status).toBe("complete"); + expect(result.items[0]).toMatchObject({ + enabled: null, + process: null, + grant_uri_permissions: null, + }); +}); + +it("bounds the total copied component process declarations", () => { + const oversized = "a".repeat(ANDROID_MANIFEST_LIMITS.processBytes + 1); + const result = parseAndroidManifestComponents( + ``, + "complete", + "33", + ); + expect(result.items.map((item) => item.process)).toEqual([null, ":valid"]); + expect(result.status).toBe("partial"); + expect(result.limitations.join(" ")).toContain( + "process declarations are capped", + ); +}); + +it("enforces the process copy budget across individually valid components", () => { + const processName = "p".repeat(2_050); + const declarations = Array.from( + { length: ANDROID_MANIFEST_LIMITS.components }, + (_, index) => + ``, + ).join(""); + const result = parseAndroidManifestComponents( + `${declarations}`, + "complete", + "33", + ); + expect(result.items).toHaveLength(ANDROID_MANIFEST_LIMITS.components); + expect(result.items[0]?.process).toBe(processName); + expect(result.items.at(-2)?.process).toBe(processName); + expect(result.items.at(-1)?.process).toBeNull(); + expect(result.status).toBe("partial"); + expect(result.limitations.join(" ")).toContain( + "process declarations are capped", + ); + expect( + result.items.reduce( + (bytes, item) => bytes + Buffer.byteLength(item.process ?? ""), + 0, + ), + ).toBeLessThanOrEqual(ANDROID_MANIFEST_LIMITS.processBytes); +}); + +it("keeps historical declaration omissions unknown rather than absent", () => { + const parsed = + androidResultSchemas.inspect_android_package.shape.components.parse({ + status: "complete", + items: [ + { + kind: "receiver", + declared_name: ".Receiver", + name: "com.example.Receiver", + exported: null, + exported_default: false, + permission: null, + permission_source: "none", + intent_filter_count: 0, + actions: [], + categories: [], + foreground_service_type: null, + authorities: null, + target_activity: null, + }, + ], + limitations: [], + }); + expect(parsed.status).toBe("complete"); + expect(parsed.items[0]?.declaration_coverage).toBe("unknown"); + for (const field of [ + "enabled", + "process", + "grant_uri_permissions", + "intent_filters", + "meta_data", + "location", + ]) + expect(parsed.items[0]).not.toHaveProperty(field); +}); + const MANIFEST = ` @@ -1065,3 +1181,144 @@ it.each([ expect(inventory.items[0]?.foreground_service_type).toBe(declared); expect(inventory.status).toBe("complete"); }); + +it("keeps intent-filter relationships and exact declarations in one source-located projection", () => { + const xml = `\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n`; + const result = parseAndroidManifestComponents(xml, "complete", "30"); + expect(result.status).toBe("complete"); + const component = result.items[0]!; + expect(component).toMatchObject({ + declaration_index: 0, + declaration_coverage: "complete", + enabled: null, + enabled_declaration: "@bool/ready", + process: ":worker", + actions: ["VIEW", "EDIT"], + }); + expect(component.location?.offset).toBe(xml.indexOf(" { + const result = parseAndroidManifestComponents( + '', + "complete", + "30", + ); + expect(result.status).toBe("partial"); + expect(result.items.map((item) => item.declaration_index)).toEqual([1, 2]); +}); + +it("makes unsupported modern filter constructs partial while retaining their source entry", () => { + const xml = + ''; + const result = parseAndroidManifestComponents(xml, "complete", "30"); + expect(result.status).toBe("partial"); + expect(result.items[0]).toMatchObject({ + declaration_coverage: "partial", + intent_filters: [{ actions: ["VIEW"], data: [], coverage: "partial" }], + }); + expect(result.limitations.join(" ")).toContain("unsupported constructs"); +}); + +it.each([ + ["a:sspPrefix", "partial"], + ["foreign:sspPrefix", "complete"], + ["android:sspPrefix", "complete"], +] as const)( + "reports unprojected data constraints using namespace identity for %s", + (attribute, coverage) => { + const declaration = ``; + const xml = `${declaration}`; + const result = parseAndroidManifestComponents(xml, "complete", "30"); + expect(result.status).toBe(coverage); + const component = result.items[0]!; + expect(component.declaration_coverage).toBe(coverage); + const filter = component.intent_filters[0]!; + expect(filter).toMatchObject({ + actions: ["VIEW"], + coverage, + data: [{ scheme: "mailto", host: null, path: null }], + }); + expect(filter.data[0]?.location?.offset).toBe(xml.indexOf(declaration)); + expect( + result.limitations.join(" ").includes("unsupported constructs"), + ).toBe(coverage === "partial"); + }, +); + +it("charges nested declarations across components and preserves unknown capped facts", () => { + const name = "x".repeat(2050); + const declarations = Array.from( + { length: 512 }, + (_, i) => + ``, + ).join(""); + const result = parseAndroidManifestComponents( + `${declarations}`, + "complete", + "30", + ); + expect(result.status).toBe("partial"); + expect(result.items.at(-2)).toMatchObject({ + declaration_coverage: "complete", + meta_data: [{ name }], + }); + expect(result.items.at(-1)).toMatchObject({ + declaration_coverage: "partial", + meta_data: [{ name: null }], + }); + expect(result.limitations.join(" ")).toContain("shared copy bound"); +}); + +it("reports shared nested-record omissions without erasing the legacy action set", () => { + const values = Array.from( + { length: 64 }, + (_, i) => ``, + ).join(""); + const xml = `${values}`; + const result = parseAndroidManifestComponents(xml, "complete", "30"); + expect(result.status).toBe("partial"); + expect(result.items[0]?.actions).toHaveLength(64); + expect(result.items[0]).toMatchObject({ + declaration_coverage: "partial", + meta_data: [], + intent_filters: [{ coverage: "complete" }], + }); +}); diff --git a/tests/boundary/browser/browserComparisonEvidence.test.ts b/tests/boundary/browser/browserComparisonEvidence.test.ts new file mode 100644 index 0000000000..891dbb4104 --- /dev/null +++ b/tests/boundary/browser/browserComparisonEvidence.test.ts @@ -0,0 +1,98 @@ +import { expect, it } from "vitest"; +import { + compareWebCaptureEvidence, + compareWebScreenshotEvidence, +} from "../../../src/application/BrowserObservationService.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { compareWebCapturesInputSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; +import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js"; +import { scenarioCaptureFixture } from "../../fixtures/browserScenarioCapture.js"; +import { passiveNetworkCapture } from "../../fixtures/passiveNetworkCapture.js"; +import { browserCaptureComparisonInputSchema } from "../../../src/domain/browserCaptureComparison.js"; +import { compareWebScreenshotsInputSchema } from "../../../src/domain/webScreenshot.js"; +import { BROWSER_TOOL_CONTRACTS } from "../../../src/contracts/browserToolContracts.js"; +import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ok } from "../../../src/domain/result.js"; + +it("retains distinct offline comparison inputs even when their diff reports match", async () => { + const browser = new CdpBrowserProvider(); + const compare = async (path: string) => { + const inspection = passiveNetworkCapture([path]); + const result = await compareWebCaptureEvidence( + browser, + compareWebCapturesInputSchema.parse({ + before: { inspection }, + after: { inspection }, + }), + ); + if (!result.ok) throw result.error; + return parseEvidence(result.value); + }; + const first = await compare("/customer-a"); + const second = await compare("/customer-b"); + expect(first.normalized_result).toEqual(second.normalized_result); + expect(first.evidence_id).not.toBe(second.evidence_id); + expect(first.parameters.comparison_input_sha256).not.toBe( + second.parameters.comparison_input_sha256, + ); + expect(first).toMatchObject({ + confidence: "derived", + authority: "analyst-inference", + parameters: { before_target_id: "page", after_target_id: "page" }, + }); + expect((await compare("/customer-a")).evidence_id).toBe(first.evidence_id); + const ledger = new EvidenceLedger(); + expect(ledger.record(first)).toEqual(ok("added")); + expect(ledger.record(second)).toEqual(ok("added")); +}); + +it("commits complete scenario comparison inputs beyond the diff projection", async () => { + const compare = async (duration: number) => { + const capture = { ...scenarioCaptureFixture(), duration_ms: duration }; + const input = browserCaptureComparisonInputSchema.parse({ + before_scenario: capture, + after_scenario: capture, + }); + const result = await compareWebCaptureEvidence( + new CdpBrowserProvider(), + input, + ); + if (!result.ok) throw result.error; + return result.value; + }; + const first = await compare(1); + const second = await compare(2); + expect(first.normalized_result).toEqual(second.normalized_result); + expect(first.evidence_id).not.toBe(second.evidence_id); + expect(first).toMatchObject({ + confidence: "derived", + authority: "analyst-inference", + parameters: { comparison_kind: "browser_scenario" }, + }); +}); + +it("commits complete screenshot comparison inputs and reports derived provenance", async () => { + const contract = BROWSER_TOOL_CONTRACTS.find( + ({ name }) => name === "compare_web_screenshots", + ); + const input = compareWebScreenshotsInputSchema.parse( + contract?.examples[0]?.input, + ); + const result = await compareWebScreenshotEvidence( + new CdpBrowserProvider(), + input, + ); + if (!result.ok) throw result.error; + expect(parseEvidence(result.value)).toMatchObject({ + confidence: "derived", + authority: "analyst-inference", + parameters: { + comparison_input_sha256: digestCanonicalValue(input), + before_artifact_sha256: input.before.sha256, + after_artifact_sha256: input.after.sha256, + channel_threshold: input.channel_threshold, + }, + normalized_result: { status: "identical", compared_pixels: 1 }, + }); +}); diff --git a/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts b/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts index f04b8210da..9751306a55 100644 --- a/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.browserSocket.test.ts @@ -67,6 +67,67 @@ describe("CdpBrowserProvider: browser socket without HTTP discovery", () => { }); }); +it("retains browser-socket siblings and reports invalid attachment as unknown, not omitted", async () => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Target.getTargets" + ? { + targetInfos: [ + { + targetId: "first", + type: "page", + title: 3, + url: "https://example.test/first", + attached: "unknown", + }, + { + targetId: "second", + type: "page", + title: "Good", + url: "https://example.test/second", + attached: false, + }, + { + targetId: 1, + type: "page", + url: "https://example.test/invalid", + }, + { id: "alias", type: "page", url: "https://example.test/alias" }, + ], + } + : undefined, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().listTargets( + listBrowserTargetsInputSchema.parse({ + cdp_endpoint: browser.browserWebSocketUrl, + }), + ); + if (!result.ok) throw result.error; + expect( + result.value.targets.map(({ target_id, attached, title }) => [ + target_id, + attached, + title, + ]), + ).toEqual([ + ["first", null, ""], + ["second", false, "Good"], + ]); + const limitations = result.value.limitations.join("\n"); + expect(limitations).toContain( + 'reported malformed attached metadata "unknown"', + ); + expect(limitations).toContain("reported malformed title metadata 3"); + expect( + result.value.limitations.filter((item) => + item.includes("target identity is malformed"), + ), + ).toHaveLength(2); + expect(limitations).not.toContain("omitted attachment"); + await vi.waitFor(() => expect(browser.activeConnectionCount()).toBe(0)); +}); + describe("CdpBrowserProvider: browser socket failures and cleanup", () => { it("closes an unadopted browser socket when the target is missing", async () => { const browser = await startFakeCdpBrowser(); diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index ec459f9f10..0cdd28ae37 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -270,7 +270,12 @@ it("checks trailer credentials before hidden metadata blocks analysis and withho const error = parseMcpToolError(response).error; expect(error.code).toBe("capability_unavailable"); expect(JSON.stringify(response)).not.toContain("content_base64"); - expect(error.details).not.toHaveProperty("partial_observation"); + expect(error.details?.partial_observation).toEqual({ + kind: "file-snapshot-hash", + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }); } const opaqueReply = await call("inspect_web_network_capture", { capture_path: path, @@ -884,9 +889,13 @@ it.each([ }, }); expect(error).not.toHaveProperty("normalized_result"); - expect(error.details).not.toHaveProperty("partial_observation"); + expect(error.details.partial_observation).toEqual({ + kind: "file-snapshot-hash", + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }); expect(mcpFailure).not.toHaveProperty("normalized_result"); - expect(mcpFailure.error.details).not.toHaveProperty("partial_observation"); expect(Buffer.compare(await readFile(path), bytes)).toBe(0); } expect(JSON.stringify(result)).not.toContain("malformed-private-input"); diff --git a/tests/boundary/browser/webWorkerFinalizationCancellation.test.ts b/tests/boundary/browser/webWorkerFinalizationCancellation.test.ts new file mode 100644 index 0000000000..bb762af981 --- /dev/null +++ b/tests/boundary/browser/webWorkerFinalizationCancellation.test.ts @@ -0,0 +1,131 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +it.each([ + [false, false, false], + [true, false, false], + [false, true, false], + [true, true, false], + [false, true, true], +])( + "retains completed web worker facts when cancellation arrives during final job cleanup (cleanup failure: %s; prior failure: %s; stop cleanup failure: %s)", + async (failCleanup, priorFailure, stopCleanupFailure) => { + const loaderUrl = new URL( + "../../../scripts/lib/verifier-source-loader.mjs", + import.meta.url, + ).href; + const workerUrl = new URL( + "../../../src/browser/WebBundleAnalysisWorker.ts", + import.meta.url, + ).href; + const processUrl = new URL( + "../../../src/process/ProviderProcess.ts", + import.meta.url, + ).href; + // Isolate the filesystem seam in a Node process. Both analyses use the real + // analyzer. Failed final removal retains the real file for owning close retry. + const script = ` + import fs from 'node:fs'; + import { createHash } from 'node:crypto'; + import { syncBuiltinESMExports } from 'node:module'; + const { importVerifierSource } = await import(${JSON.stringify(loaderUrl)}); + const controller = new AbortController(); + const reason = new Error('cancel during final worker cleanup'); + const nativeRm = fs.promises.rm; + let cancelOnCleanup = false; + let completedRemovals = 0; + fs.promises.rm = async (path, options) => { + const finalJobRemoval = String(path).includes('rea-web-bundle-analysis-') && String(path).endsWith('.result'); + if (finalJobRemoval && cancelOnCleanup && ${JSON.stringify(failCleanup)}) { + controller.abort(reason); + throw new Error('fixture final removal denied'); + } + await nativeRm(path, options); + if (finalJobRemoval) { + completedRemovals += 1; + if (cancelOnCleanup) controller.abort(reason); + } + }; + syncBuiltinESMExports(); + const { WebBundleAnalysisWorker } = await importVerifierSource(new URL(${JSON.stringify(workerUrl)})); + const { spawnOwnedProviderProcess } = await importVerifierSource(new URL(${JSON.stringify(processUrl)})); + const { createWebTextArtifact } = await importVerifierSource(new URL('../../../src/domain/webContentArtifact.ts', ${JSON.stringify(import.meta.url)})); + const value = 'export const observed = 7;'; + const source = path => ({ script_key: 'scr_' + createHash('sha256').update(value).digest('hex'), + frame_id: 'frame-1', url: 'https://app.example.test/' + path, origin: 'https://app.example.test', + cdp_hash: null, length: Buffer.byteLength(value), is_module: true, language: 'JavaScript', source_map_url: null, + resource_reconciliation: {status: 'unmatched', reason: 'no_exact_sanitized_url'}, + source: {included: true, artifact: createWebTextArtifact(value, 'application/javascript')} }); + let root; + let child; + let launches = 0; + const worker = new WebBundleAnalysisWorker(128, async request => { + root = request.cwd; + const spawned = await spawnOwnedProviderProcess(${JSON.stringify(priorFailure)} ? { + ...request, + arguments: ['-e', 'const send = value => process.stdout.write(JSON.stringify(value) + "\\n"); send({kind:"ready",heap_limit_bytes:require("node:v8").getHeapStatistics().heap_size_limit}); require("node:readline").createInterface({input:process.stdin}).on("line", line => { const request=JSON.parse(line); send({kind:"failure",id:request.id,reason:"output",message:"fixture output failure"}); });'], + } : request); + child = spawned.process; + launches += 1; + return { + ...spawned, + cleanup: async () => { + if (cancelOnCleanup && ${JSON.stringify(stopCleanupFailure)}) { + controller.abort(reason); + return { cleaned: false, reason: 'fixture process cleanup denied' }; + } + const { cleanupOwnedProcessGroup } = await importVerifierSource(new URL('../../../src/process/ProcessOwnership.ts', ${JSON.stringify(import.meta.url)})); + return spawned.cleanup ? spawned.cleanup() : cleanupOwnedProcessGroup(spawned.ownership); + }, + }; + }); + let observed; + try { + const control = await worker.analyze(source('control.mjs'), 5000); + + cancelOnCleanup = true; + const cancelled = await worker.analyze(source('cancelled.mjs'), 5000, controller.signal); + const facts = cancelled.ok ? cancelled.value : cancelled.error; + const primary = cancelled.ok ? undefined : (cancelled.error.error.cleanupPrimary ?? cancelled.error.error); + const cancellation = primary?._tag === 'AnalysisCancelledError' ? primary : primary?.secondaryFailures.find(failure => failure._tag === 'AnalysisCancelledError'); + observed = { primaryOutput: primary?._tag === 'AnalysisOutputError', secondaryCount: primary?.secondaryFailures.length ?? 0, controlOk: control.ok, completedRemovals, launches, + cancelled: cancellation !== undefined, + exactReason: cancellation?.cause === reason, + cleanupIncomplete: !cancelled.ok && cancelled.error.error.cleanupIncomplete, + hasFacts: facts.status === 'analyzed' || facts.completed?.status === 'analyzed' }; + } finally { + cancelOnCleanup = false; + fs.promises.rm = nativeRm; + syncBuiltinESMExports(); + await worker.close(); + } + const rootRemoved = await fs.promises.stat(root).then(() => false, cause => { + if (cause.code !== 'ENOENT') throw cause; + return true; + }); + process.stdout.write(JSON.stringify({ ...observed, rootRemoved, + childExited: child.exitCode !== null || child.signalCode !== null })); + `; + const { stdout } = await promisify(execFile)( + process.execPath, + ["--input-type=module", "--eval", script], + { timeout: 20_000 }, + ); + const observed: unknown = JSON.parse(stdout); + expect(observed).toEqual({ + primaryOutput: priorFailure, + secondaryCount: priorFailure ? 1 : 0, + controlOk: !priorFailure, + completedRemovals: failCleanup || stopCleanupFailure ? 1 : 2, + launches: priorFailure ? 2 : 1, + cancelled: true, + exactReason: true, + cleanupIncomplete: failCleanup || stopCleanupFailure, + hasFacts: !priorFailure, + rootRemoved: true, + childExited: true, + }); + }, +); diff --git a/tests/boundary/cli/androidPackageViewCli.test.ts b/tests/boundary/cli/androidPackageViewCli.test.ts new file mode 100644 index 0000000000..1a297e2298 --- /dev/null +++ b/tests/boundary/cli/androidPackageViewCli.test.ts @@ -0,0 +1,41 @@ +import { expect } from "vitest"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + ["complete", '{"kind":"android","facet":"components"}', "view"], + ["summary", '{"kind":"android","facet":"components"}', "evidence_output"], + [ + "summary", + '{"kind":"android","facet":"components","fields":["invented"]}', + "view", + ], + ["summary", "{broken", "view"], +] as const)( + "rejects invalid Android delivery before missing APK/JAR work (%s, %s)", + async ([detail, view, field], { cli }) => { + const result = await cli.run({ + arguments: [ + "inspect-android-package", + "/nonexistent/selected.apk", + "--detail", + detail, + "--view", + view, + "--json", + ], + environment: { + REA_JADX_MCP_JAR: "/nonexistent/selected-engine.jar", + REA_LOG_LEVEL: "silent", + }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + issues: expect.arrayContaining([ + expect.objectContaining({ path: expect.arrayContaining([field]) }), + ]), + }, + }); + }, +); diff --git a/tests/boundary/cli/browserScenarioSummaryCli.test.ts b/tests/boundary/cli/browserScenarioSummaryCli.test.ts index 905b37bd78..36c3218fb9 100644 --- a/tests/boundary/cli/browserScenarioSummaryCli.test.ts +++ b/tests/boundary/cli/browserScenarioSummaryCli.test.ts @@ -7,9 +7,93 @@ import { expect, it } from "vitest"; import { registerBrowserScenarioCommands } from "../../../src/cliBrowserScenarioCommands.js"; import { ok } from "../../../src/domain/result.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; +import { AnalysisCleanupLease } from "../../../src/artifacts/AnalysisCleanupLease.js"; +import { PlaywrightBrowserScenarioProvider } from "../../../src/browser/PlaywrightBrowserScenarioProvider.js"; import { scenarioCaptureFixture } from "../../fixtures/browserScenarioCapture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it.each([false, true])( + "retains startup failure and reports failed cleanup retry when retryFails=%s", + async (retryFails) => { + let refuseCleanup = retryFails; + let cleanupAttempts = 0; + const lease = new AnalysisCleanupLease(async () => { + cleanupAttempts += 1; + if (refuseCleanup) throw new Error("profile removal denied"); + }, ["owned-profile"]); + // The production provider owns resources retained by a partially failed opener. + // No browser is launched by this boundary regression. + const provider = new PlaywrightBrowserScenarioProvider( + async (_scenario, options) => { + options.retainCleanup?.(lease); + throw new AnalysisOutputError( + "capture_browser_scenario", + "browser startup failed", + ); + }, + ); + const cli = Cli.create("rea-browser-startup-cleanup-test", { sync: false }); + let stdout = ""; + registerBrowserScenarioCommands( + cli, + pino({ level: "silent" }), + {}, + { + createProvider: () => provider, + }, + ); + await cli.serve( + [ + "capture-browser-scenario", + JSON.stringify({ + browser: { mode: "launch", executable_path: process.execPath }, + start_url: { url: "http://127.0.0.1:3101/" }, + actions: [ + { step_id: "browse", action: "wait_for_timeout", duration_ms: 1 }, + ], + }), + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + const failure = JSON.parse(stdout); + expect(failure).toMatchObject({ + code: "cleanup_incomplete", + details: { reason: "browser startup failed" }, + }); + if (retryFails) { + expect(failure).toMatchObject({ + details: { + diagnostics: { + primary_error: { + code: "cleanup_incomplete", + details: { reason: "browser startup failed" }, + }, + cleanup_error: { + details: { diagnostics: { reason: "profile removal denied" } }, + }, + }, + }, + }); + expect(lease.released).toBe(false); + refuseCleanup = false; + await provider.close(); + expect(cleanupAttempts).toBe(2); + } else { + expect(JSON.stringify(failure)).not.toContain("profile removal denied"); + expect(cleanupAttempts).toBe(1); + } + expect(lease.released).toBe(true); + }, +); + it.each(["complete", "summary"] as const)( "retains complete Evidence on cancellation during CLI cleanup with %s delivery", async (detail) => { diff --git a/tests/boundary/cli/browserScenarioValidation.test.ts b/tests/boundary/cli/browserScenarioValidation.test.ts new file mode 100644 index 0000000000..b318a39845 --- /dev/null +++ b/tests/boundary/cli/browserScenarioValidation.test.ts @@ -0,0 +1,442 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Cli } from "incur"; +import pino from "pino"; +import { expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { registerBrowserScenarioCommands } from "../../../src/cliBrowserScenarioCommands.js"; +import { BROWSER_SCENARIO_START_STEP_ID } from "../../../src/domain/browserScenarioValues.js"; + +const serveJson = async ( + registered: ReturnType, + command: string, + input: unknown, +): Promise => { + let stdout = ""; + await registered.cli.serve([command, JSON.stringify(input), "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }); + expect(registered.createProvider).not.toHaveBeenCalled(); + expect(registered.readSecret).not.toHaveBeenCalled(); + return JSON.parse(stdout) as unknown; +}; + +/** Register only the scenario commands, with a provider that must never run. */ +const registerValidatingCli = () => { + const cli = Cli.create("rea-scenario-validation-test", { sync: false }); + const createProvider = vi.fn((): never => { + throw new Error("validation must not construct a browser provider"); + }); + const readSecret = vi.fn((): never => { + throw new Error("validation must not resolve secret environment values"); + }); + const environment = Object.defineProperty({}, "SESSION_TOKEN", { + enumerable: true, + get: readSecret, + }); + registerBrowserScenarioCommands(cli, pino({ level: "silent" }), environment, { + createProvider, + }); + return { cli, createProvider, readSecret }; +}; + +const launchScenario = { + browser: { mode: "launch", executable_path: "browser" }, + start_url: { url: "http://127.0.0.1:3000" }, + actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }], +} as const; + +it("accepts a valid scenario without launching or attaching to a browser", async () => { + const output = await serveJson( + registerValidatingCli(), + "validate-browser-scenario", + launchScenario, + ); + expect(output).toEqual({ valid: true, actions: 1, secrets: 0 }); +}); + +it.each(["validate-browser-scenario", "capture-browser-scenario"])( + "%s rejects a literal Basic username containing a colon before creating a provider", + async (command) => { + const output = await serveJson(registerValidatingCli(), command, { + ...launchScenario, + actions: [ + { + step_id: "authenticate", + action: "request", + method: "GET", + path: "/api", + headers: [ + { + name: "Authorization", + value: { + source: "basic_credentials", + parts: [ + { source: "literal", value: "ops:admin" }, + { source: "literal", value: "" }, + ], + }, + }, + ], + }, + ], + }); + expect(output).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["actions", 0, "headers", 0, "value", "parts", 0, "value"], + message: expect.stringContaining( + "Basic username cannot contain a colon", + ), + }), + ], + }, + }); + }, +); + +it("counts declared secrets without resolving their environment values", async () => { + const cli = registerValidatingCli(); + const output = await serveJson(cli, "validate-browser-scenario", { + ...launchScenario, + actions: [ + { + step_id: "sign-in", + action: "fill", + locator: { kind: "css", selector: "#token" }, + value: { source: "secret", secret_id: "session_token" }, + }, + ], + secrets: [ + { secret_id: "session_token", environment_variable: "SESSION_TOKEN" }, + { secret_id: "api_key", environment_variable: "API_KEY" }, + ], + }); + expect(output).toEqual({ valid: true, actions: 1, secrets: 2 }); +}); + +it("rejects a duplicate action step identity with the offending field path", async () => { + const output = await serveJson( + registerValidatingCli(), + "validate-browser-scenario", + { + ...launchScenario, + actions: [ + { step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }, + { step_id: "settle", action: "wait_for_timeout", duration_ms: 2 }, + ], + }, + ); + expect(output).toMatchObject({ + error: "Browser scenario validation failed", + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ + path: ["actions"], + reason: "invalid_value", + message: "Action step IDs must be unique", + }), + ], + }, + }); +}); + +it("rejects an action that reuses the reserved initial step identity", async () => { + const output = await serveJson( + registerValidatingCli(), + "validate-browser-scenario", + { + ...launchScenario, + actions: [ + { + step_id: BROWSER_SCENARIO_START_STEP_ID, + action: "wait_for_timeout", + duration_ms: 1, + }, + ], + }, + ); + expect(output).toMatchObject({ + error: "Browser scenario validation failed", + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ + path: ["actions", 0, "step_id"], + reason: "invalid_value", + }), + ], + }, + }); +}); + +it("rejects an undeclared secret reference without exposing any secret value", async () => { + const cli = registerValidatingCli(); + const output = await serveJson(cli, "validate-browser-scenario", { + ...launchScenario, + actions: [ + { + step_id: "sign-in", + action: "fill", + locator: { kind: "css", selector: "#token" }, + value: { source: "secret", secret_id: "session_token" }, + }, + ], + }); + const serialized = JSON.stringify(output); + expect(output).toMatchObject({ + error: "Browser scenario validation failed", + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ + path: ["secrets"], + reason: "invalid_value", + message: expect.stringContaining("session_token"), + }), + ], + }, + }); + // The declaration is reported by identity only; no environment variable name + // or resolved value belongs in a validation reply. + expect(serialized).not.toContain("SESSION_TOKEN"); + expect(serialized).not.toContain("environment_variable"); +}); + +it("rejects an unknown field instead of ignoring it", async () => { + const output = await serveJson( + registerValidatingCli(), + "validate-browser-scenario", + { + ...launchScenario, + navigation: { timeout_ms: 5 }, + }, + ); + expect(output).toMatchObject({ + error: "Browser scenario validation failed", + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ + path: ["navigation"], + reason: "unknown_argument", + }), + ], + }, + }); +}); + +it("rejects a scenario whose input file cannot be read", async () => { + const cli = registerValidatingCli(); + let stdout = ""; + await cli.cli.serve( + ["validate-browser-scenario", "missing-scenario.json", "--json"], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + expect(cli.createProvider).not.toHaveBeenCalled(); + expect(cli.readSecret).not.toHaveBeenCalled(); + expect(JSON.parse(stdout) as unknown).toMatchObject({ + input_path: "missing-scenario.json", + input_reason: "read-failed", + }); +}); + +it("accepts a disconnected CDP target without constructing a provider", async () => { + expect( + await serveJson(registerValidatingCli(), "validate-browser-scenario", { + ...launchScenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:1", + target_id: "unavailable-target", + }, + }), + ).toEqual({ valid: true, actions: 1, secrets: 0 }); +}); + +it.each([ + [ + { secret_id: "token", environment_variable: "FIRST" }, + { secret_id: "token", environment_variable: "SECOND" }, + ], + [ + { secret_id: "first", environment_variable: "SAME" }, + { secret_id: "second", environment_variable: "SAME" }, + ], +])( + "rejects duplicate secret identities or environment references (%j)", + async (first, second) => { + expect( + await serveJson(registerValidatingCli(), "validate-browser-scenario", { + ...launchScenario, + secrets: [first, second], + }), + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["secrets"], + reason: "invalid_value", + }), + ], + }, + }); + }, +); + +const savedSteps = [ + { + step_id: "create", + action: "request", + method: "POST", + path: "/api/items", + json: { title: "test" }, + save: [{ name: "id", json_pointer: "/id" }], + }, + { + step_id: "read", + action: "request", + method: "GET", + path: "/api/items/{saved:id}", + }, +]; +it("accepts saved response references only after their producing step", async () => { + const valid = { ...launchScenario, actions: savedSteps }; + expect( + await serveJson( + registerValidatingCli(), + "validate-browser-scenario", + valid, + ), + ).toEqual({ valid: true, actions: 2, secrets: 0 }); + expect( + await serveJson(registerValidatingCli(), "validate-browser-scenario", { + ...valid, + actions: [...savedSteps].reverse(), + }), + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["actions", 0], + message: expect.stringContaining("no earlier step saves"), + }), + ], + }, + }); +}); + +cliTest.for(["launch", "connect"] as const)( + "validates %s through the public CLI while runtime resources are unavailable", + async (mode, { cli }) => { + const result = await cli.run({ + arguments: [ + "validate-browser-scenario", + JSON.stringify({ + ...launchScenario, + browser: + mode === "launch" + ? { mode, executable_path: "/nonexistent/rea-validation-browser" } + : { + mode, + cdp_endpoint: "http://127.0.0.1:1", + target_id: "not-attached", + }, + secrets: [ + { + secret_id: "session_token", + environment_variable: "SESSION_TOKEN", + }, + ], + }), + "--json", + ], + environment: { + REA_LOG_LEVEL: "silent", + SESSION_TOKEN: "validation-unread-secret", + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toEqual({ valid: true, actions: 1, secrets: 1 }); + expect(result.stdout).not.toContain("validation-unread-secret"); + }, +); + +cliTest( + "validates stdin and caller-relative file paths with canonical diagnostics", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-scenario-validation-"); + const input = JSON.stringify(launchScenario); + await writeFile(join(root, "valid.json"), input); + await writeFile( + join(root, "invalid.json"), + JSON.stringify({ + ...launchScenario, + actions: [...launchScenario.actions, ...launchScenario.actions], + }), + ); + await writeFile(join(root, "malformed.json"), '{"private-value":'); + for (const arguments_ of [ + ["validate-browser-scenario", "valid.json", "--json"], + ["validate-browser-scenario", "-", "--json"], + ]) { + const result = await cli.run({ + arguments: arguments_, + cwd: root, + ...(arguments_[1] === "-" ? { input } : {}), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toEqual({ valid: true, actions: 1, secrets: 0 }); + } + const rejected = await cli.run({ + arguments: ["validate-browser-scenario", "invalid.json", "--json"], + cwd: root, + }); + expect(rejected.exitCode).toBe(1); + expect(rejected.json).toMatchObject({ + error: "Browser scenario validation failed", + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["actions"], + message: "Action step IDs must be unique", + }), + ], + }, + }); + const malformed = await cli.run({ + arguments: ["validate-browser-scenario", "malformed.json", "--json"], + cwd: root, + }); + expect(malformed.exitCode).toBe(1); + expect(malformed.json).toMatchObject({ + code: "invalid_request", + input_path: "malformed.json", + input_reason: "invalid-json", + }); + expect(malformed.stdout).not.toContain("private-value"); + }, +); diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 568b6c3eb7..130b505403 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; +import { normalizeCliArguments } from "../../../src/cliBuiltinArguments.js"; import { - normalizeCliArguments, renderCliOutputArgumentError, renderCliDocument, renderEmptyFilteredCliOutput, @@ -35,10 +35,10 @@ describe("CLI output argument boundary", () => { }); it("formats arrays of empty objects without column keys as JSON in Markdown", () => { - expect(renderCliDocument([{}], "md", false)).toBe("[{}]\n"); - expect(renderCliDocument([{}, {}], "md", false)).toBe("[{},{}]\n"); + expect(renderCliDocument([{}], "md", false)).toBe("\\[{}\\]\n"); + expect(renderCliDocument([{}, {}], "md", false)).toBe("\\[{},{}\\]\n"); expect(renderCliDocument({ empty: [{}] }, "md", false)).toBe( - "## empty\n\n[{}]\n", + "## empty\n\n\\[{}\\]\n", ); }); diff --git a/tests/boundary/cli/directAnalysisStatus.test.ts b/tests/boundary/cli/directAnalysisStatus.test.ts index 720b8062c2..1b1ebcebf2 100644 --- a/tests/boundary/cli/directAnalysisStatus.test.ts +++ b/tests/boundary/cli/directAnalysisStatus.test.ts @@ -43,7 +43,7 @@ it.each(["capabilities", "providers"])( readConfiguration: () => parseConfig({}), createBinarySession: () => session, }), - runProviderAnalysis: async () => null, + runProviderAnalysis: async () => ({ ok: false, error: null }), }, ); let stdout = ""; @@ -132,7 +132,7 @@ it.each(["capabilities", "providers"])( }), createBinarySession: () => session, }), - runProviderAnalysis: async () => null, + runProviderAnalysis: async () => ({ ok: false, error: null }), }, ); let stdout = ""; diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index e617ee6ab2..215bf2e36f 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -8,7 +8,7 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import type { TestWorkspace } from "../../support/workspace/workspaceFixture.js"; const CLI_OUTPUT_RUNTIME = - "export const cliConfigurationError = () => undefined; export const normalizeCliArguments = (args) => args; export const parseCliOutputArguments = () => ({ validation: { ok: true } }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined; export const renderVersionReport = () => undefined;\n"; + "export const cliConfigurationError = () => undefined; export const parseCliOutputArguments = () => ({ validation: { ok: true } }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined; export const renderVersionReport = () => undefined;\n"; const installCliRuntime = async ( workspace: TestWorkspace, diff --git a/tests/boundary/cli/electronScopedIpc.test.ts b/tests/boundary/cli/electronScopedIpc.test.ts index 78721ce5d4..beab7c507f 100644 --- a/tests/boundary/cli/electronScopedIpc.test.ts +++ b/tests/boundary/cli/electronScopedIpc.test.ts @@ -112,3 +112,119 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ).toEqual([]); }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "pairs literal IPC within proven application owners through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-application-ipc-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ name: "a", main: "main.cjs" }), + "a/main.cjs": `const { ipcMain, clipboard } = require("electron"); + ipcMain.handle("shared", (event, value) => clipboard.writeText(value));`, + "a/preload.cjs": `const { ipcRenderer, contextBridge } = require("electron"); + contextBridge.exposeInMainWorld("api", { read: (value) => ipcRenderer.invoke("shared", value) });`, + "b/package.json": JSON.stringify({ name: "b", main: "main.cjs" }), + "b/main.cjs": `const { ipcMain, clipboard } = require("electron"); + ipcMain.handle("shared", (event, value) => clipboard.writeText(value));`, + "b/preload.cjs": `const { ipcRenderer, contextBridge } = require("electron"); + contextBridge.exposeInMainWorld("api", { read: (value) => ipcRenderer.invoke("shared", value) });`, + }); + const application = await analyzeJavaScriptDirectory(surface, cli, root); + expect(application.summary.ipc).toMatchObject({ + operations: 4, + paired_renderer_transmissions: 2, + ambiguous_renderer_transmissions: 0, + }); + const pairs = application.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "pair-ipc-literal-channel", + ); + const joins = application.graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "join-electron-wrapper-paths", + ); + expect(joins).toHaveLength(2); + expect( + new Set(joins.map(({ target_node_id }) => target_node_id)).size, + ).toBe(2); + const nodes = new Map( + application.graph.nodes.map((node) => [node.node_id, node]), + ); + for (const join of joins) { + const target = nodes.get(join.target_node_id); + expect(target?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + evidence: expect.objectContaining({ + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + source: join.properties.handler_path, + }), + }), + }), + }), + ]), + ); + } + expect(pairs).toHaveLength(2); + expect( + new Set(pairs.map(({ target_node_id }) => target_node_id)).size, + ).toBe(2); + expect( + application.graph.nodes.filter(({ kind }) => kind === "ipc-handler"), + ).toHaveLength(2); + for (const edge of pairs) { + expect(edge.evidence.location.available).toBe(true); + const location = edge.evidence.location; + if (!location.available || location.value.kind !== "source-range") + throw new Error("IPC pairing omitted its authored source location"); + expect(edge.properties.handler_path).toBe( + location.value.source.startsWith("a/") ? "a/main.cjs" : "b/main.cjs", + ); + } + expect( + application.graph.nodes.filter(({ kind }) => kind === "ipc-channel"), + ).toHaveLength(2); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains unknown IPC applicability across distinct or shared application owners through %s", + async (surface, { cli }) => { + for (const sharedSource of [false, true]) { + const root = await createTestTempDirectory( + "rea-unknown-application-ipc-", + ); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ name: "a", main: "main.cjs" }), + "a/main.cjs": sharedSource + ? `require("../shared.cjs");` + : `module.exports = {};`, + "b/package.json": JSON.stringify({ name: "b", main: "main.cjs" }), + "b/main.cjs": `const { ipcMain } = require("electron"); + ${sharedSource ? 'require("../shared.cjs");' : ""} + ipcMain.handle("shared", () => "result");`, + [sharedSource ? "shared.cjs" : "a/preload.cjs"]: + `const { ipcRenderer } = require("electron"); ipcRenderer.invoke("shared");`, + }); + const application = await analyzeJavaScriptDirectory(surface, cli, root); + expect(application.summary.ipc).toMatchObject({ + operations: 2, + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 1, + unpaired_literal_renderer_transmissions: 0, + }); + expect( + application.graph.edges.filter(({ evidence }) => + ["pair-ipc-literal-channel", "join-electron-wrapper-paths"].includes( + evidence.extractor.operation, + ), + ), + ).toEqual([]); + expect( + application.graph.nodes.filter(({ kind }) => kind === "ipc-handler"), + ).toHaveLength(1); + } + }, +); diff --git a/tests/boundary/cli/filteredArrayOutput.test.ts b/tests/boundary/cli/filteredArrayOutput.test.ts index a345449ca4..7f318cdc5a 100644 --- a/tests/boundary/cli/filteredArrayOutput.test.ts +++ b/tests/boundary/cli/filteredArrayOutput.test.ts @@ -100,7 +100,7 @@ it("formats arrays of empty objects without columns as JSON in Markdown", async const json = await runFilteredCli(value, "json"); expect(JSON.parse(json)).toEqual(value); const markdown = await runFilteredCli(value, "md"); - expect(markdown).toBe("[{},{}]\n"); + expect(markdown).toBe("\\[{},{}\\]\n"); }); it.each( @@ -322,6 +322,43 @@ const overlappingPaths = [ "rows[1,4].right,rows[0,3].left", ] as const; +it("projects consecutive slices through nested root arrays and merges cell fields", async () => { + const value = freezeRecursively([ + [ + { left: 1, right: 2 }, + { left: 3, right: 4 }, + ], + [{ left: 5, right: 6 }], + ]); + const output = await runFilteredCli( + value, + "json", + "[0,2][0,1].left,[0,1][0,2].right", + ); + expect(JSON.parse(output)).toEqual([ + [{ left: 1, right: 2 }, { right: 4 }], + [{ left: 5 }], + ]); +}); + +it.each([ + ["rows[0,2][-2,-1]", { rows: [[1], [3]] }], + ["rows[0,2][1,1]", { rows: [[], []] }], + ["rows[0,2][invalid,1]", { rows: [[1], [3]] }], +] as const)("retains parsed slice bounds for %s", async (filter, expected) => { + const output = await runFilteredCli( + { + rows: [ + [1, 2], + [3, 4], + ], + }, + "json", + filter, + ); + expect(JSON.parse(output)).toEqual(expected); +}); + it.each(["json", "jsonl", "yaml", "toon", "md"] as const)( "unions overlapping sibling slices independent of path order in %s", async (format) => { @@ -375,7 +412,7 @@ it.each(["json", "jsonl", "yaml", "toon", "md"] as const)( .split("\n") .map((line) => JSON.parse(line)), ).toEqual([1, null, null]); - else if (format === "md") expect(output).toBe("[1,null,null]\n"); + else if (format === "md") expect(output).toBe("\\[1,null,null\\]\n"); else expect(output).toBe(`${Formatter.format(selected, format)}\n`); expect(value).toEqual([{ selected: 1 }, {}, { selected: null }]); }, @@ -414,3 +451,120 @@ it.each(["json", "jsonl", "yaml", "toon", "md"] as const)( expect(projectionSource).toEqual(before); }, ); + +it.each(["[0,1].left,right", "right,[0,1].left"])( + "preserves root implicit keys with slices for %s", + async (filter) => { + expect( + JSON.parse( + await runFilteredCli( + [ + { left: 1, right: 2 }, + { left: 3, right: 4 }, + ], + "json", + filter, + ), + ), + ).toEqual([{ left: 1, right: 2 }, { right: 4 }]); + }, +); + +it("keeps root numeric keys as per-item keys when mixed with slices", async () => { + expect( + JSON.parse( + await runFilteredCli( + [{ left: 1, "1": { right: 2 } }, { "1": { right: 4 } }], + "json", + "[0,1].left,1.right", + ), + ), + ).toEqual([{ left: 1, "1": { right: 2 } }, { "1": { right: 4 } }]); +}); + +it.each([ + ["rows[0,1].left,rows.1.right", { rows: [{ left: 1 }, { right: 4 }] }], + ["rows.1.right,rows[0,1].left", { rows: [{ left: 1 }, { right: 4 }] }], + ["rows.1.right", { rows: { "1": { right: 4 } } }], + ["rows[0,1].left,rows.99.right", { rows: [{ left: 1 }] }], + ["rows[0,1].left,rows.0.right", { rows: [{ left: 1, right: 2 }] }], +] as const)( + "preserves sibling and key-only projections for %s", + async (filter, expected) => { + expect( + JSON.parse( + await runFilteredCli( + { + rows: [ + { left: 1, right: 2 }, + { left: 3, right: 4 }, + ], + }, + "json", + filter, + ), + ), + ).toEqual(expected); + }, +); + +it("retains sparse slice positions while ignoring an absent explicit index", async () => { + const rows = new Array(3); + rows[2] = { value: 3 }; + expect( + JSON.parse( + await runFilteredCli( + { rows }, + "json", + "rows[0,1],rows.1.value,rows.2.value", + ), + ), + ).toEqual({ rows: [null, { value: 3 }] }); +}); + +it.each(["json", "jsonl", "yaml", "toon", "md"] as const)( + "refuses incompatible array properties in %s", + async (format) => { + const args = [ + "--format", + format, + "--filter-output", + "rows[0,1],rows.length", + ]; + const chunks: Buffer[] = []; + const output = createCliResultOutput( + parseCliOutputArguments(args), + new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }), + ); + await output?.write( + { rows: [1, 2] }, + { command: "rows", duration: "0ms", format }, + ); + expect(output?.error).toMatchObject({ + code: "invalid_request", + message: expect.stringContaining("non-index property"), + }); + expect(Buffer.concat(chunks).toString()).toContain("invalid_request"); + }, +); + +it.each(["[0,1][0,1].left,right", "right,[0,1][0,1].left"])( + "merges recursively implicit root fields for %s", + async (filter) => { + const value = freezeRecursively([ + [{ left: 1, right: 2 }, { right: 4 }], + [[{ right: 6 }]], + [], + [{ left: 7 }], + ]); + expect(JSON.parse(await runFilteredCli(value, "json", filter))).toEqual([ + [{ left: 1, right: 2 }, { right: 4 }], + [[{ right: 6 }]], + ]); + }, +); diff --git a/tests/boundary/cli/finishAnalysisCommand.test.ts b/tests/boundary/cli/finishAnalysisCommand.test.ts index acd81cd23a..b3d981955d 100644 --- a/tests/boundary/cli/finishAnalysisCommand.test.ts +++ b/tests/boundary/cli/finishAnalysisCommand.test.ts @@ -1,8 +1,12 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { expect, it } from "vitest"; import { finishAnalysisCommand } from "../../../src/cli/finishAnalysisCommand.js"; import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; -import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + projectAnalysisError, + projectAnalysisErrorWithoutCapturedOutput, +} from "../../../src/domain/analysisErrorProjection.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { err, ok } from "../../../src/domain/result.js"; import { scriptCaptureEvidenceFixture } from "../../fixtures/webScriptCapture.js"; @@ -24,7 +28,7 @@ it("preserves producer-owned cleanup failure when service close does not resolve new AbortController().signal, ); - expect(result).toEqual(expected); + expect(result).toEqual(err(expected)); expect(failure.cleanupIncomplete).toBe(true); }); @@ -44,8 +48,11 @@ it("returns completed partial Evidence when cancellation arrives during close", ); expect(result).toMatchObject({ - code: "cancelled", - details: { partial_observation: { evidence_id: evidence.evidence_id } }, + ok: false, + error: { + code: "cancelled", + details: { partial_observation: { evidence_id: evidence.evidence_id } }, + }, }); }); @@ -62,7 +69,7 @@ it("preserves the primary operation error when cancellation arrives during close "fixture-provider", "inspect_web_page", controller.signal, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "unreadable_output", @@ -92,7 +99,7 @@ it("retains late cancellation and completed Evidence when close also fails", asy "fixture-provider", "inspect_web_page", controller.signal, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "cleanup_incomplete", category: "cancelled", @@ -105,3 +112,154 @@ it("retains late cancellation and completed Evidence when close also fails", asy }, }); }); + +it("keeps the initial failure snapshot when close resolves its cleanup but fails another resource during cancellation", async () => { + const controller = new AbortController(); + const primary = new ProviderCleanupError( + "fixture-provider", + ["initial resource"], + { reason: "initial cleanup refused" }, + { + operation: "capture_browser_scenario", + completedObservation: scriptCaptureEvidenceFixture(), + capturedOutput: { + stdout: "startup output", + stderr: "startup diagnostic", + exit_code: 1, + signal: null, + truncated: false, + }, + }, + ); + const initial = projectAnalysisErrorWithoutCapturedOutput(primary); + const result = await finishAnalysisCommand( + async () => err(primary), + { + close: async () => { + primary.resolveCleanup(); + controller.abort("cancelled during cleanup retry"); + throw new ProviderCleanupError( + "fixture-provider", + ["remaining resource"], + { reason: "remaining cleanup denied" }, + ); + }, + }, + "fixture-provider", + "capture_browser_scenario", + controller.signal, + undefined, + { + failureProjection: "initial", + recoverCompletedObservation: () => { + throw new Error("Initial failure snapshots must not recover output"); + }, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "cleanup_incomplete", + details: { + captured_output: { stdout: "startup output" }, + diagnostics: { + primary_error: initial, + cleanup_error: { details: { resources: ["remaining resource"] } }, + }, + }, + }, + }); + expect(result).not.toHaveProperty( + "error.details.diagnostics.primary_error.details.captured_output", + ); +}); + +it.each([false, true])( + "recovers only producer-attested completed output after confirmed cleanup (cancel=%s)", + async (cancel) => { + const evidence = scriptCaptureEvidenceFixture(); + const controller = new AbortController(); + const failure = new ProviderCleanupError( + "fixture-provider", + ["root"], + { reason: "initial cleanup refused" }, + { + operation: "inspect_web_page", + partialObservation: evidence, + completedObservation: evidence, + }, + ); + const result = await finishAnalysisCommand( + async () => err(failure), + { + close: async () => { + failure.resolveCleanup(); + if (cancel) controller.abort("cancelled during successful retry"); + }, + }, + "fixture-provider", + "inspect_web_page", + controller.signal, + undefined, + { recoverCompletedObservation: (observation) => observation }, + ).then(projectCliAnalysisResult); + if (!cancel) expect(result).toEqual(evidence); + else + expect(result).toMatchObject({ + code: "cancelled", + details: { + partial_observation: evidence, + diagnostics: { + cleanup_history: { + details: { diagnostics: { reason: "initial cleanup refused" } }, + }, + }, + }, + }); + }, +); + +it.each(["unresolved", "ordinary-partial", "initial-projection"] as const)( + "does not recover %s cleanup output", + async (kind) => { + const evidence = scriptCaptureEvidenceFixture(); + const failure = new ProviderCleanupError( + "fixture-provider", + ["root"], + { reason: "cleanup refused" }, + { + partialObservation: evidence, + ...(kind === "ordinary-partial" + ? {} + : { completedObservation: evidence }), + }, + ); + const initial = projectAnalysisError(failure); + let attemptedRecovery = false; + const result = await finishAnalysisCommand( + async () => err(failure), + { + close: async () => { + if (kind !== "unresolved") failure.resolveCleanup(); + }, + }, + "fixture-provider", + "inspect_web_page", + new AbortController().signal, + undefined, + { + ...(kind === "initial-projection" + ? { failureProjection: "initial" as const } + : {}), + recoverCompletedObservation: (observation) => { + attemptedRecovery = true; + return observation; + }, + }, + ).then(projectCliAnalysisResult); + expect(attemptedRecovery).toBe(false); + expect(result).toEqual( + kind === "initial-projection" ? initial : projectAnalysisError(failure), + ); + }, +); diff --git a/tests/boundary/cli/javascriptRendererEndpoints.test.ts b/tests/boundary/cli/javascriptRendererEndpoints.test.ts new file mode 100644 index 0000000000..6441944e74 --- /dev/null +++ b/tests/boundary/cli/javascriptRendererEndpoints.test.ts @@ -0,0 +1,144 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest( + "preserves renderer endpoint certainty through CLI graph delivery", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-renderer-endpoints-"); + await writeFixtureFiles(root, { + "main.cjs": ` + const { BrowserWindow } = require("electron"); + const replaced = new BrowserWindow(); + replaced.loadURL = () => {}; + replaced.loadURL("https://replaced.test"); + const local = { loadURL(value) { return value; } }; + local.loadURL("https://local.test"); + const intact = new BrowserWindow(); + intact.loadURL("https://intact.test"); + new BrowserWindow().loadURL(runtimeUrl); + new BrowserWindow().loadFile("page.html"); + `, + "page.html": "

Local renderer

", + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect(endpoints).toHaveLength(4); + for (const value of ["https://replaced.test", "https://local.test"]) { + const endpoint = endpoints.find(({ observations }) => + observations.some(({ properties }) => properties.value === value), + ); + expect(endpoint?.observations[0]).toMatchObject({ + properties: { value, interpretation: "candidate" }, + evidence: { state: "inferred", confidence: "low" }, + }); + expect(endpoint?.observations[0]?.evidence.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("receiver does not establish a network call"), + ]), + ); + expect( + result.graph.edges.find( + ({ target_node_id }) => target_node_id === endpoint?.node_id, + ), + ).toMatchObject({ + relation: "calls", + properties: { interpretation: "candidate" }, + evidence: { state: "inferred", confidence: "low" }, + }); + } + const intact = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => properties.value === "https://intact.test", + ), + ); + expect(intact?.observations[0]).toMatchObject({ + properties: { value: "https://intact.test" }, + evidence: { state: "observed", confidence: "exact" }, + }); + expect(intact?.observations[0]?.properties).not.toHaveProperty( + "interpretation", + ); + const dynamic = endpoints.find(({ observations }) => + observations.some(({ properties }) => properties.value === "runtimeUrl"), + ); + expect(dynamic?.observations[0]).toMatchObject({ + properties: { value: "runtimeUrl", resolution: "partial" }, + evidence: { state: "observed" }, + }); + }, +); + +cliTest( + "qualifies local browser request spellings through CLI Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-network-spellings-"); + await writeFixtureFiles(root, { + "index.js": ` + const local = { fetch(x) { return x; }, sendBeacon(x) { return x; }, EventSource(x) { return x; } }; + local.fetch('/local-fetch'); local.sendBeacon('/local-beacon'); local.EventSource('/local-events'); + const other = { open(a,b) { return b; }, post(x) { return x; }, put(x) { return x; }, patch(x) { return x; }, request(x) { return x; } }; + other.open('GET', '/local-open'); other.post('/local-post'); other.put('/local-put'); other.patch('/local-patch'); other.request('/local-request'); + fetch('/intrinsic-fetch'); navigator.sendBeacon('/intrinsic-beacon'); new EventSource('/intrinsic-events'); + `, + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect(endpoints).toHaveLength(11); + for (const endpoint of endpoints) { + const observation = endpoint.observations[0]!; + const local = String(observation.properties.value).startsWith("/local-"); + expect(observation.evidence).toMatchObject( + local + ? { state: "inferred", confidence: "low" } + : { state: "observed", confidence: "exact" }, + ); + expect(observation.properties.interpretation).toBe( + local ? "candidate" : undefined, + ); + if (local) + expect( + result.graph.edges.find( + ({ target_node_id }) => target_node_id === endpoint.node_id, + )?.evidence, + ).toMatchObject({ state: "inferred", confidence: "low" }); + } + }, +); + +cliTest( + "preserves binding-backed XHR identity and qualifies replaced open methods", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-xhr-identity-"); + await writeFixtureFiles(root, { + "index.js": ` + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/intact-xhr'); + const replaced = new XMLHttpRequest(); + replaced.open = (method, value) => value; + replaced.open('GET', '/replaced-xhr'); + { const XMLHttpRequest = class {}; const local = new XMLHttpRequest(); local.open('GET', '/local-xhr'); } + `, + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect(endpoints).toHaveLength(3); + for (const endpoint of endpoints) { + const observation = endpoint.observations[0]!; + expect(observation.evidence).toMatchObject( + observation.properties.value === "/intact-xhr" + ? { state: "observed", confidence: "exact" } + : { state: "inferred", confidence: "low" }, + ); + } + }, +); diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index c5f18af6a0..68c145ce17 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -280,7 +280,10 @@ describe("CLI result output surface", () => { if (output === undefined) throw new Error("Missing structured output surface"); const cli = Cli.create("rea-output-test", { sync: false }); - registerManagedCommands(cli, silentLogger, async () => failure); + registerManagedCommands(cli, silentLogger, async () => ({ + ok: false, + error: failure, + })); streamCliCommandResults(cli, output); let fallback = ""; const previousExitCode = process.exitCode; @@ -408,12 +411,15 @@ describe("full-output token windows", () => { if (output === undefined) throw new Error("Missing TOON output surface"); const cli = Cli.create("rea-output-test", { sync: false }); registerManagedCommands(cli, silentLogger, async () => ({ - code: "target_unavailable", - category: "unavailable", - message: "The selected artifact is missing.", - retryable: false, - remediation: { action: "Check that the path exists." }, - details: { source: "artifact-open" }, + ok: false, + error: { + code: "target_unavailable", + category: "unavailable", + message: "The selected artifact is missing.", + retryable: false, + remediation: { action: "Check that the path exists." }, + details: { source: "artifact-open" }, + }, })); streamCliCommandResults(cli, output); const previousExitCode = process.exitCode; @@ -667,7 +673,7 @@ describe("CLI result output surface", () => { registerManagedCommands(cli, silentLogger, async (...[path, operation]) => { expect(path).toBe("fixture.dll"); expect(operation).toBe("inspect_managed_members"); - return value; + return { ok: false, error: value }; }); // A terminal destination exercises the indented encoding past the limit. Object.defineProperty(destination, "isTTY", { value: true }); @@ -707,7 +713,10 @@ describe("CLI result output surface", () => { if (tokenOutput === undefined) throw new Error("Missing token-count output surface"); const tokenCli = Cli.create("rea-output-test", { sync: false }); - registerManagedCommands(tokenCli, silentLogger, async () => value); + registerManagedCommands(tokenCli, silentLogger, async () => ({ + ok: false, + error: value, + })); streamCliCommandResults(tokenCli, tokenOutput); await tokenCli.serve(tokenArgv, { stdout: (text) => { diff --git a/tests/boundary/cli/managedMemberComparisonCli.test.ts b/tests/boundary/cli/managedMemberComparisonCli.test.ts index ea90549406..2e2d25ce23 100644 --- a/tests/boundary/cli/managedMemberComparisonCli.test.ts +++ b/tests/boundary/cli/managedMemberComparisonCli.test.ts @@ -216,7 +216,10 @@ const registeredComparisonCommand = () => { return cli; }, } as unknown as Parameters[0]; - registerManagedCommands(cli, silentLogger, async () => null); + registerManagedCommands(cli, silentLogger, async () => ({ + ok: false, + error: null, + })); return commands.get(CLI_COMMANDS.compareManagedMembers)!; }; diff --git a/tests/boundary/cli/markdownLiteralCells.test.ts b/tests/boundary/cli/markdownLiteralCells.test.ts index 44271ad030..528ba53928 100644 --- a/tests/boundary/cli/markdownLiteralCells.test.ts +++ b/tests/boundary/cli/markdownLiteralCells.test.ts @@ -103,3 +103,56 @@ cliTest( ).toEqual(expectedCells); }, ); + +cliTest( + "preserves literal scalar, array, and heading Evidence facts", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-markdown-literal-text-"); + const path = join(root, "bundle.json"); + const literal = "**literal** [source](wrong) ©"; + const heading = "**key**\n# injected"; + const result = { + [heading]: literal, + list: [literal], + bullet: "- literal", + numbered: "1. literal", + rule: "---", + indented: " literal\n- next\n# heading", + nested: { value: 1 }, + }; + const record = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "ordinary-json", parameters: {}, result }, + ); + await writeFile(path, JSON.stringify(createEvidenceBundle([record]))); + const base = [ + "inspect-evidence-bundle", + JSON.stringify({ path, detail: "complete" }), + "--filter-output", + "records.0.normalized_result", + ]; + const markdown = await cli.run({ arguments: [...base, "--format", "md"] }); + const json = await cli.run({ arguments: [...base, "--json"] }); + expect(markdown.exitCode, markdown.stderr).toBe(0); + expect(json.exitCode, json.stderr).toBe(0); + expect(JSON.parse(json.stdout)).toEqual({ + records: { "0": { normalized_result: result } }, + }); + const renderer = await createMarkdownRenderer(root, { linkify: false }); + for (const document of [ + markdown.stdout, + renderCliDocument(result, "md", false), + ]) { + const html = renderer.render(document); + expect(html).toContain( + "**literal** [source](wrong) <T> &copy;", + ); + expect(html).toContain("**key**
# injected"); + expect(html).toContain("literal
- next
# heading"); + expect(html).not.toMatch(/<(?:strong|em|ul|ol|li|T|pre|hr)(?:\s|>)/u); + expect(html).not.toContain('href="wrong"'); + expect(html).not.toContain(" parent); + const run = vi.fn(async () => ({ ok: true as const, value: parent })); const cli = Cli.create("rea-load-image-cli-test", { sync: false }); registerCoreNativeCommands(cli, pino({ enabled: false }), run); let stdout = ""; diff --git a/tests/boundary/cli/wasmLateCancellationCli.test.ts b/tests/boundary/cli/wasmLateCancellationCli.test.ts index c782870de4..4947906295 100644 --- a/tests/boundary/cli/wasmLateCancellationCli.test.ts +++ b/tests/boundary/cli/wasmLateCancellationCli.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { EventEmitter } from "node:events"; import { expect, it } from "vitest"; import { WasmArtifactService } from "../../../src/application/wasm/WasmArtifactService.js"; @@ -31,7 +32,7 @@ it("delivers cancellation with completed WASM Evidence through the CLI boundary" "wabt-artifact", "inspect_wasm_artifact", signal, - ), + ).then(projectCliAnalysisResult), { on: events.on.bind(events), off: events.off.bind(events), diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index 0bb3fb4a4a..4f4bb04408 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -13,15 +13,12 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { createNonApplicationZipInventory, + inventoryApplicationPackage, requireSuccessfulProjection, } from "../../../support/applicationSessionFixture.js"; import { projectAndroidApplicationEvidence } from "../../../../src/application/android/AndroidApplicationService.js"; -import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js"; import { androidApplicationProjectionResultSchema } from "../../../../src/domain/android/androidApplication.js"; -import { parseEvidence } from "../../../../src/domain/evidence.js"; - -const { runProviderAnalysis } = createDirectAnalysis({}); describe("Android application projection", () => { it("projects deterministic APK components and explicit bridge hypotheses", async () => { @@ -50,9 +47,7 @@ describe("Android application projection", () => { await writer.add("META-INF/FIXTURE.RSA", new TextReader("opaque signing")); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const first = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); @@ -144,9 +139,7 @@ describe("Android application projection", () => { await writer.add(entry, new TextReader("unrecognized bytes")); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); @@ -194,9 +187,7 @@ it("projects APK v1 metadata candidates at any META-INF depth", async () => { await writer.add(entry, new TextReader(`content:${entry}`)); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const projection = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), @@ -239,9 +230,7 @@ describe("APK family without a root manifest", () => { for (const [entry, text] of Object.entries(entries)) await writer.add(entry, new TextReader(text)); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); return androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), @@ -269,9 +258,7 @@ it("retains encrypted native library paths as unknown component evidence", async { password: "fixture-only" }, ); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const projection = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), @@ -314,9 +301,7 @@ describe("case-distinct APK resource names", () => { await writer.add("res/2f.xml", new TextReader("lower")); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); expect(inventory.normalized_result).toMatchObject({ coverage: { scope: "container-entries", status: "complete" }, }); @@ -358,9 +343,7 @@ describe("Android bridge candidate coverage", () => { await writer.add("lib/arm64-v8a/libnative.so", new TextReader("native")); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); @@ -392,9 +375,7 @@ describe("Android bridge candidate coverage", () => { ); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); @@ -439,9 +420,7 @@ it("does not infer a runtime from library-named directories or their text descen await writer.add(`assets/${name}/NOTICE.txt`, new TextReader("inert text")); } await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const graph = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), @@ -460,9 +439,7 @@ it("retains an encrypted root manifest occurrence without claiming absence", asy password: "fixture-only", }); await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = await inventoryApplicationPackage(path); const graph = androidApplicationProjectionResultSchema.parse( requireSuccessfulProjection( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), diff --git a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts index 7f89c736c8..c7f8863133 100644 --- a/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts +++ b/tests/boundary/filesystem/apkSignatureViewIdentity.test.ts @@ -1,3 +1,6 @@ +import { rebindEvidenceToTargetPath } from "../../../src/domain/evidence.js"; +import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -39,9 +42,29 @@ it("checks parent subject identity again when signing facts are already cached", { kind: "summary" }, ).ok, ).toBe(true); + const rebound = rebindEvidenceToTargetPath(evidence, { + path: join(root, "alias.apk"), + sha256: evidence.subject.digest.sha256, + format: evidence.subject.format, + }); + const exporting = new EvidenceLedger(); + expect(exporting.record(rebound).ok).toBe(true); + const imported = new EvidenceLedger(); + expect(imported.import(exporting.export()).ok).toBe(true); + const replay = await inspectAnalysisView( + { + source: { kind: "retained-evidence", evidence_id: rebound.evidence_id }, + view: { kind: "summary" }, + }, + (id) => imported.forAnalysis(id), + ); + if (!replay.ok) throw replay.error; + expect(replay.value.subject?.local_path).toBe(join(root, "alias.apk")); + expect(replay.value.normalized_result).toMatchObject({ + artifact: { path }, + }); for (const subject of [ { ...evidence.subject, format: "directory" as const }, - { ...evidence.subject, local_path: join(root, "other.apk") }, { ...evidence.subject, digest: { ...evidence.subject.digest, sha256: "00".repeat(32) }, diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index 418fd1aa3f..a63f1b8b6f 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -58,7 +59,9 @@ async function createCompleteAppleProjection() { await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const fragments = fragmentInventoryEvidence(inventory, 3); const result = projectAppleApplicationEvidence({ @@ -94,7 +97,9 @@ describe("Apple application projection", () => { await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect(inventory.normalized_result).toMatchObject({ coverage: { scope: "container-entries", status: "complete" }, @@ -147,7 +152,9 @@ describe("Apple application projection", () => { await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const first = projectAppleApplicationEvidence({ inventory_evidence: [inventory], @@ -216,7 +223,9 @@ describe("Apple application projection", () => { await writer.add("AndroidManifest.xml", new TextReader("")); await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect( projectAppleApplicationEvidence({ inventory_evidence: [inventory] }), @@ -250,7 +259,9 @@ async function projectArchive( ); await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); return appleApplicationProjectionResultSchema.parse( requireSuccessfulProjection( @@ -352,7 +363,9 @@ describe("macOS application projection", () => { await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const projection = appleApplicationProjectionResultSchema.parse( requireSuccessfulProjection( @@ -403,7 +416,9 @@ describe("macOS application projection", () => { ); const inventory = parseEvidence( - await runProviderAnalysis(app, "inventory_artifact", {}), + await runProviderAnalysis(app, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const result = projectAppleApplicationEvidence({ inventory_evidence: [inventory], @@ -476,7 +491,9 @@ describe("Apple application projection completeness", () => { await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const result = projectAppleApplicationEvidence({ inventory_evidence: [inventory], diff --git a/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts b/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts index 121efd7448..f1160d2447 100644 --- a/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts +++ b/tests/boundary/filesystem/artifactAdmittedIdentity.test.ts @@ -12,6 +12,7 @@ import { writeFile, unlink, symlink, + rmdir, } from "node:fs/promises"; import { join } from "node:path"; import { createPackage } from "@electron/asar"; @@ -24,6 +25,100 @@ import { } from "../../../src/domain/artifactGraph.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createSingleEntryZip } from "../../fixtures/zipNames.js"; + +it.skipIf(process.platform === "win32").each(["zip", "asar"])( + "rejects a %s selection whose traversed directory became a file", + async (format) => { + const root = await createTestTempDirectory("rea-admitted-traversal-"); + const child = join(root, "child"); + await mkdir(child); + const stored = join(root, `stored.${format}`); + if (format === "asar") { + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "payload.txt"), "member bytes"); + await createPackage(source, stored); + } else { + await writeFile(stored, await createSingleEntryZip("payload.txt")); + } + const selected = `${child}/../stored.${format}`; + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(selected); + if (!opened.ok) throw opened.error; + await rmdir(child); + await writeFile(child, "now a regular file"); + await expect(readFile(selected)).rejects.toMatchObject({ + code: "ENOTDIR", + }); + for (const operation of [ + "inventory_artifact", + "extract_artifact", + ] as const) { + const output = join(root, operation); + const result = await session.execute( + operation, + operation === "extract_artifact" ? { output_root: output } : {}, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "integrity", + detail: expect.stringContaining( + "no longer resolves to its admitted source", + ), + }, + }); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + } + } finally { + await session.close(); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps native symlink parent traversal when revalidating archive selections", + async () => { + const root = await createTestTempDirectory( + "rea-admitted-native-traversal-", + ); + const physical = join(root, "physical"); + await mkdir(join(physical, "child"), { recursive: true }); + const link = join(root, "link"); + await symlink(join(physical, "child"), link); + await writeFile( + join(physical, "stored.zip"), + await createSingleEntryZip("payload.txt", "selected bytes"), + ); + await writeFile( + join(root, "stored.zip"), + "lexical sibling is not an archive", + ); + const session = createTestBinarySession(new ArtifactProvider({})); + try { + const opened = await session.open(`${link}/../stored.zip`); + if (!opened.ok) throw opened.error; + const inventory = await session.execute("inventory_artifact", {}); + if (!inventory.ok) throw inventory.error; + expect( + artifactInventoryResultSchema.parse(inventory.value.result).manifest + .root_format, + ).toBe("zip"); + const output = join(root, "output"); + const extraction = await session.execute("extract_artifact", { + output_root: output, + }); + if (!extraction.ok) throw extraction.error; + expect(await readFile(join(output, "payload.txt"), "utf8")).toBe( + "selected bytes", + ); + } finally { + await session.close(); + } + }, +); it.each([ { format: "zip", replacement: "valid" }, diff --git a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts index 10abc154d5..955832fd42 100644 --- a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts +++ b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts @@ -6,6 +6,7 @@ import { rename, rm, writeFile, + mkdir, } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -26,6 +27,24 @@ import { fatImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; +it.skipIf(process.platform === "win32").each(["file", "directory"])( + "rejects invalid native traversal before canonicalizing a %s inventory root", + async (kind) => { + const root = await createTestTempDirectory( + "rea-inventory-native-selection-", + ); + await writeFile(join(root, "blocker"), "regular file"); + const path = join(root, "target"); + if (kind === "directory") await mkdir(path); + else await writeFile(path, "target bytes"); + await expect( + inventoryArtifact(`${root}/blocker/../target`), + ).rejects.toMatchObject({ code: "ENOTDIR" }); + const valid = await inventoryArtifact(path); + expect(valid.manifest.root_format).toBe(kind); + }, +); + it.skipIf(process.platform === "win32")( "rejects a FIFO artifact root without waiting for a writer", async () => { diff --git a/tests/boundary/filesystem/artifactSelectedName.test.ts b/tests/boundary/filesystem/artifactSelectedName.test.ts index 4b8b4dc6fb..bcf997b118 100644 --- a/tests/boundary/filesystem/artifactSelectedName.test.ts +++ b/tests/boundary/filesystem/artifactSelectedName.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; @@ -48,7 +49,9 @@ it.skipIf(process.platform === "win32").each([ expect(admitted.value.format).toBe(format); const { runProviderAnalysis } = createDirectAnalysis({}); const observed = parseEvidence( - await runProviderAnalysis(selected, "inventory_artifact", {}), + await runProviderAnalysis(selected, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect(observed.subject?.local_path).toBe(selected); expect( diff --git a/tests/boundary/filesystem/asarHeaderAdmission.test.ts b/tests/boundary/filesystem/asarHeaderAdmission.test.ts index 29d6839dcf..79b62db3e9 100644 --- a/tests/boundary/filesystem/asarHeaderAdmission.test.ts +++ b/tests/boundary/filesystem/asarHeaderAdmission.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { mkdir, open, readFile, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createPackage } from "@electron/asar"; @@ -293,7 +294,9 @@ it("applies the caller-selected environment, not the ambient one, to inventory a vi.stubEnv("REA_ASAR_MAX_HEADER_BYTES", "1"); for (const operation of ["inventory_artifact", "inspect_artifact"] as const) { await expect( - createDirectAnalysis(lowered).runProviderAnalysis(archive, operation, {}), + createDirectAnalysis(lowered) + .runProviderAnalysis(archive, operation, {}) + .then(projectCliAnalysisResult), ).resolves.toMatchObject({ code: "truncated", message: recovery, @@ -304,7 +307,9 @@ it("applies the caller-selected environment, not the ambient one, to inventory a }, }); await expect( - createDirectAnalysis({}).runProviderAnalysis(archive, operation, {}), + createDirectAnalysis({}) + .runProviderAnalysis(archive, operation, {}) + .then(projectCliAnalysisResult), ).resolves.toMatchObject({ operation }); } const refused = await analyzeJavaScriptApplication( @@ -365,11 +370,9 @@ it("refuses invalid settings at startup validation and at each owner", async () const { archive } = await createFixture(); const invalid = { REA_ASAR_MAX_HEADER_BYTES: "0" }; await expect( - createDirectAnalysis(invalid).runProviderAnalysis( - archive, - "inventory_artifact", - {}, - ), + createDirectAnalysis(invalid) + .runProviderAnalysis(archive, "inventory_artifact", {}) + .then(projectCliAnalysisResult), ).resolves.toMatchObject({ code: "configuration_invalid" }); const analyzed = await analyzeJavaScriptApplication( { input_path: archive }, diff --git a/tests/boundary/filesystem/asarInventoryContainment.test.ts b/tests/boundary/filesystem/asarInventoryContainment.test.ts index 5d6d323eb4..c720c03946 100644 --- a/tests/boundary/filesystem/asarInventoryContainment.test.ts +++ b/tests/boundary/filesystem/asarInventoryContainment.test.ts @@ -192,7 +192,7 @@ describe("expanded ASAR inventory containment", () => { reason: "integrity", }); const observed = await inventoryArtifact(application, { - integrity: { mode: "record-and-continue" }, + integrity: "record-and-continue", }); const container = artifactOccurrenceAt(observed, "resources/app.asar"); const child = artifactOccurrenceAt(observed, "resources/app.asar/main.js"); diff --git a/tests/boundary/filesystem/asarSelectedCompanion.test.ts b/tests/boundary/filesystem/asarSelectedCompanion.test.ts index cde62c9dd3..9fbcc0f3f2 100644 --- a/tests/boundary/filesystem/asarSelectedCompanion.test.ts +++ b/tests/boundary/filesystem/asarSelectedCompanion.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; @@ -98,7 +99,7 @@ it.skipIf(process.platform === "win32")( expect(extractFile(selected, "native.node").equals(bytes)).toBe(true); const selectedInventory = await inventoryArtifact(selected); const canonicalInventory = await inventoryArtifact(canonical, { - integrity: { mode: "record-and-continue" }, + integrity: "record-and-continue", }); expect(selectedInventory.manifest.root_sha256).toBe( canonicalInventory.manifest.root_sha256, @@ -179,7 +180,9 @@ it.skipIf(process.platform === "win32")( await symlink(stored, selected); const { runProviderAnalysis } = createDirectAnalysis({}); const observed = parseEvidence( - await runProviderAnalysis(selected, "inventory_artifact", {}), + await runProviderAnalysis(selected, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect(observed.subject?.local_path).toBe(selected); const inventory = artifactInventoryResultSchema.parse( diff --git a/tests/boundary/filesystem/ciFailureReport.test.ts b/tests/boundary/filesystem/ciFailureReport.test.ts index 8dd3d1fa47..f2487e1e9f 100644 --- a/tests/boundary/filesystem/ciFailureReport.test.ts +++ b/tests/boundary/filesystem/ciFailureReport.test.ts @@ -40,7 +40,7 @@ const context = ( turboVersion: "2.11.7", platform: "linux", architecture: "x64", - taskHashes: { "lint:built": taskHash }, + taskHashes: { lint: taskHash }, }); const run = (overrides: Partial = {}) => normalizeWorkflowRun({ @@ -120,6 +120,26 @@ it("attributes only an identical structured lint failure with matching task, con }); }); +it.each(["context", "mainContext"] as const)( + "keeps mixed legacy and canonical lint receipts unknown when %s uses the old task", + (side) => { + const input = comparison(); + const receipt = input[side]!; + expect( + classifyFailure({ + ...input, + [side]: { + ...receipt, + taskHashes: { "lint:built": "a".repeat(16) }, + }, + }), + ).toEqual({ + classification: "unknown", + reason: "owning_task_hash_differs", + }); + }, +); + it.each([ ...[ "nodeVersion", @@ -131,7 +151,7 @@ it.each([ ["optional head hash", { prHeadSha: "bad" }], [ "unrelated task hash", - { taskHashes: { "lint:built": "a".repeat(16), typecheck: "bad" } }, + { taskHashes: { lint: "a".repeat(16), typecheck: "bad" } }, ], ] as const)( "keeps malformed %s provenance unknown on either side", diff --git a/tests/boundary/filesystem/ciFailureReportBudget.test.ts b/tests/boundary/filesystem/ciFailureReportBudget.test.ts index b9d051d66b..0393c51e0c 100644 --- a/tests/boundary/filesystem/ciFailureReportBudget.test.ts +++ b/tests/boundary/filesystem/ciFailureReportBudget.test.ts @@ -34,7 +34,7 @@ const evidence = ( turboVersion: "2.11.7", platform: "linux", architecture: "x64", - taskHashes: { "lint:built": "a".repeat(16) }, + taskHashes: { lint: "a".repeat(16) }, }, diagnostics, }); diff --git a/tests/boundary/filesystem/ciFeedback.test.ts b/tests/boundary/filesystem/ciFeedback.test.ts index c82dbe3360..d05c7c93d4 100644 --- a/tests/boundary/filesystem/ciFeedback.test.ts +++ b/tests/boundary/filesystem/ciFeedback.test.ts @@ -300,7 +300,7 @@ it("records the actual static task graph with explicit source identity and no am const context = await recordStaticTaskContext({ outputDirectory: directory }); const head = await execute("git", ["rev-parse", "HEAD"]); expect(context.sourceSha).toBe(head.stdout.trim()); - expect(context.taskHashes["lint:built"]).toMatch(/^[a-f0-9]{16}$/u); + expect(context.taskHashes.lint).toMatch(/^[a-f0-9]{16}$/u); expect(context.taskHashes.typecheck).toMatch(/^[a-f0-9]{16}$/u); expect(context.taskHashes["format:check"]).toMatch(/^[a-f0-9]{16}$/u); expect(context.taskHashes).not.toHaveProperty("jscpd"); @@ -311,7 +311,7 @@ it("records the actual static task graph with explicit source identity and no am }); expect(deep.taskHashes.jscpd).toMatch(/^[a-f0-9]{16}$/u); expect(deep.taskHashes.knip).toMatch(/^[a-f0-9]{16}$/u); - expect(deep.taskHashes).not.toHaveProperty("lint:built"); + expect(deep.taskHashes).not.toHaveProperty("lint"); await expect( recordStaticTaskContext({ outputDirectory: directory, diff --git a/tests/boundary/filesystem/ciStaticArtifacts.test.ts b/tests/boundary/filesystem/ciStaticArtifacts.test.ts index af44d8197c..2c090e4153 100644 --- a/tests/boundary/filesystem/ciStaticArtifacts.test.ts +++ b/tests/boundary/filesystem/ciStaticArtifacts.test.ts @@ -49,7 +49,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( turboVersion: "2.11.7", platform: process.platform, architecture: process.arch, - taskHashes: { "lint:built": "a".repeat(16) }, + taskHashes: { lint: "a".repeat(16) }, }), ); const archive = join(root, "reports.zip"); @@ -107,7 +107,7 @@ it("uses the same repository path admission for archived and compared diagnostic turboVersion: "2.11.7", platform: process.platform, architecture: process.arch, - taskHashes: { "lint:built": "a".repeat(16) }, + taskHashes: { lint: "a".repeat(16) }, }), ); const archive = join(root, "reports.zip"); @@ -201,7 +201,7 @@ it.each([3.5, 100, 0])( turboVersion: "2.11.7", platform: process.platform, architecture: process.arch, - taskHashes: { "lint:built": "a".repeat(16), jscpd: "b".repeat(16) }, + taskHashes: { lint: "a".repeat(16), jscpd: "b".repeat(16) }, }), ); const archive = join(root, "reports.zip"); diff --git a/tests/boundary/filesystem/ciTestReports.test.ts b/tests/boundary/filesystem/ciTestReports.test.ts index 815f736a50..8d20c80142 100644 --- a/tests/boundary/filesystem/ciTestReports.test.ts +++ b/tests/boundary/filesystem/ciTestReports.test.ts @@ -364,7 +364,7 @@ it("runs the existing onboarding and Python producer guards from their selected "node scripts/run-exclusive.mjs artifacts npm run check:ci:fast:tasks", ); expect(scripts["check:ci:fast:tasks"]).toBe( - "node scripts/run-turbo.mjs run typecheck lint:built format:check", + "node scripts/run-turbo.mjs run typecheck lint format:check", ); expect(scripts["test:python"]).toContain( "bridge/native/test_rea_lldb_tracer.py", diff --git a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts index 5ebeec3d24..120b6c0b88 100644 --- a/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts +++ b/tests/boundary/filesystem/composedWorkflowEvidenceLinks.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { executeComposedWorkflow } from "../../../src/application/WorkflowEvidence.js"; import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js"; import { ok } from "../../../src/domain/result.js"; @@ -24,7 +25,7 @@ it.each([0, 1, 12])( "binary_overview", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ); expect(current.evidence_links).toHaveLength(1); const snapshot = parseAnalysisSnapshot( @@ -42,7 +43,7 @@ it.each([0, 1, 12])( "binary_overview", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ); expect(unique.evidence_id).toBe(current.evidence_id); expect(unique.evidence_links).toEqual(current.evidence_links); @@ -77,7 +78,7 @@ it.each([0, 1, 12])( "binary_overview", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ); expect(delivered.normalized_result).toEqual(current.normalized_result); expect([current.evidence_id, historical.evidence_id]).not.toContain( @@ -98,7 +99,7 @@ it.each([0, 1, 12])( "binary_overview", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ); expect(refreshed.evidence_id).toBe(delivered.evidence_id); expect(refreshed.evidence_links).toEqual(delivered.evidence_links); @@ -134,7 +135,9 @@ it.each([false, true])( }; try { const evidence = parseEvidence( - await runDirectAnalysis(dependencies, path, "binary_overview", {}), + await runDirectAnalysis(dependencies, path, "binary_overview", {}).then( + projectCliAnalysisResult, + ), ); expect(evidence.normalized_result).toEqual(summary); expect(evidence.confidence).toBe("derived"); diff --git a/tests/boundary/filesystem/directAnalysisPermissions.test.ts b/tests/boundary/filesystem/directAnalysisPermissions.test.ts index e603d64f51..34effe4d11 100644 --- a/tests/boundary/filesystem/directAnalysisPermissions.test.ts +++ b/tests/boundary/filesystem/directAnalysisPermissions.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { chmod, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -39,14 +40,16 @@ describe("direct analysis snapshot files", () => { vi.stubEnv("REA_ANALYSIS_PROVIDER", "environment-provider"); await expect( - createDirectAnalysis(process.env).runDirectAnalysis( - targetPath, - "binary_overview", - {}, - { - providerId: "request-provider", - }, - ), + createDirectAnalysis(process.env) + .runDirectAnalysis( + targetPath, + "binary_overview", + {}, + { + providerId: "request-provider", + }, + ) + .then(projectCliAnalysisResult), ).resolves.toMatchObject({ error: "Analysis failed", code: "capability_unavailable", @@ -96,14 +99,16 @@ describe("direct analysis snapshot files", () => { vi.stubEnv("HOPPER_LAUNCHER_PATH", launcherPath); await expect( - createDirectAnalysis(process.env).runDirectAnalysis( - process.execPath, - "binary_overview", - {}, - { - snapshotPath, - }, - ), + createDirectAnalysis(process.env) + .runDirectAnalysis( + process.execPath, + "binary_overview", + {}, + { + snapshotPath, + }, + ) + .then(projectCliAnalysisResult), ).resolves.not.toEqual(evidence); }); }); diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 2d5221fee6..df6992a22b 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -1,3 +1,7 @@ +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { readFile, symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; @@ -37,6 +41,7 @@ import { createTestBinarySession, createTestProviderRouter, } from "../../fixtures/binarySession.js"; +import { routeProviderIdentity } from "../../../src/application/binary/SessionProviderPolicy.js"; import { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { isProfilelessNativeDispatchSnapshot } from "../../../src/application/binary/ProfilelessNativeWorkflowSnapshot.js"; import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js"; @@ -383,6 +388,88 @@ const withEarlierHistoricalEvidence = ( }; describe("snapshot output cleanup lifecycle", () => { + it.each(["cleanup", "cancelled"] as const)( + "preserves the committed snapshot receipt after %s finalization and retry", + async (failureKind) => { + const fixture = await createSnapshotReplayFixture( + "rea-snapshot-close-failure-", + ); + const provider = fixture.createProvider(); + const failure = + failureKind === "cleanup" + ? new ProviderCleanupError( + IDENTITY.id, + ["fixture provider"], + {}, + { + partialObservation: createEvidence(undefined, IDENTITY, { + operation: "health", + parameters: {}, + result: "cleanup facts", + }), + }, + ) + : new AnalysisCancelledError("close_binary"); + const cleanupObservation = failure.partialObservation; + const close = vi + .fn() + .mockResolvedValueOnce(err(failure)) + .mockResolvedValue(ok(null)); + const session = createTestBinarySession({ + ...provider, + createClient: (target, options) => ({ + ...provider.createClient(target, options), + close, + }), + }); + try { + const opened = await session.open(fixture.path); + if (!opened.ok) throw opened.error; + const analyzed = await session.execute("list_segments", {}); + if (!analyzed.ok) throw analyzed.error; + const closed = await session.closeWithSnapshot( + fixture.snapshotPath, + false, + ); + expect(closed.ok).toBe(false); + if (closed.ok) throw new Error("Expected close failure"); + const committed = await readFile(fixture.snapshotPath); + const snapshot = await readAnalysisSnapshot(fixture.snapshotPath); + if (!snapshot.ok) throw snapshot.error; + const receipt = { + path: fixture.snapshotPath, + bytes: committed.byteLength, + primitive_entries: snapshot.value.entries.length, + workflow_entries: snapshot.value.workflow_entries.length, + evidence_records: snapshot.value.evidence_bundle.records.length, + }; + expect( + projectAnalysisError(closed.error).details?.partial_observation, + ).toEqual({ + saved_snapshot: receipt, + ...(cleanupObservation === undefined + ? {} + : { cleanup_observation: cleanupObservation }), + }); + expect((await session.close()).ok).toBe(true); + expect(close).toHaveBeenCalledTimes(2); + expect((await readFile(fixture.snapshotPath)).equals(committed)).toBe( + true, + ); + expect( + projectAnalysisError(closed.error).details?.partial_observation, + ).toEqual({ + saved_snapshot: receipt, + ...(cleanupObservation === undefined + ? {} + : { cleanup_observation: cleanupObservation }), + }); + } finally { + await session.close(); + } + }, + ); + it("returns completed analysis after retrying cleanup of a published snapshot", async () => { const fixture = await createSnapshotReplayFixture( "rea-snapshot-cleanup-retry-", @@ -397,7 +484,7 @@ describe("snapshot output cleanup lifecycle", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(parseEvidence(output).operation).toBe("binary_overview"); const saved = await readAnalysisSnapshot(fixture.snapshotPath); if (!saved.ok) throw saved.error; @@ -483,7 +570,7 @@ const verifyWindowsSnapshotAdmission = async ( "search_strings", arguments_, options, - ); + ).then(projectCliAnalysisResult); expect(first).toMatchObject({ operation: "search_strings" }); expect(fixture.starts).toHaveLength(1); if (selectedPath === "junction") @@ -495,7 +582,7 @@ const verifyWindowsSnapshotAdmission = async ( "search_strings", arguments_, options, - ); + ).then(projectCliAnalysisResult); expect(second).toMatchObject({ error: "Analysis failed" }); expect(fixture.starts).toHaveLength(2); expect( @@ -528,7 +615,7 @@ it.each([ scenario.tool, scenario.arguments, options, - ), + ).then(projectCliAnalysisResult), ); const saved = await readAnalysisSnapshot(fixture.snapshotPath); if (!saved.ok) throw saved.error; @@ -555,7 +642,7 @@ it.each([ scenario.tool, scenario.arguments, options, - ), + ).then(projectCliAnalysisResult), ); expect(refreshed.limitations).toEqual( expect.arrayContaining(first.limitations), @@ -570,7 +657,7 @@ it.each([ scenario.tool, scenario.arguments, options, - ); + ).then(projectCliAnalysisResult); expect(replayed).toEqual(refreshed); expect(fixture.starts).toEqual(starts); expect(fixture.calls).toEqual(calls); @@ -612,7 +699,7 @@ describe("direct analysis composed snapshot replay", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(fixture.resolutions).toHaveLength(1); const first = await runDirectAnalysis( @@ -621,7 +708,7 @@ describe("direct analysis composed snapshot replay", () => { scenario.tool, scenario.arguments, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(first).toMatchObject({ operation: scenario.tool }); expect(fixture.resolutions).toHaveLength(2); expect(fixture.starts).toHaveLength(2); @@ -633,7 +720,7 @@ describe("direct analysis composed snapshot replay", () => { scenario.tool, scenario.arguments, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(replay).toEqual(first); expect(fixture.resolutions).toHaveLength(3); expect(fixture.starts).toHaveLength(2); @@ -674,7 +761,7 @@ describe("direct analysis composed snapshot replay", () => { scenario.tool, scenario.arguments, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(first).toMatchObject({ operation: scenario.tool }); const callsAfterFirst = [...fixture.calls]; const startsAfterFirst = [...fixture.starts]; @@ -692,7 +779,7 @@ describe("direct analysis composed snapshot replay", () => { scenario.tool, scenario.arguments, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(second).toEqual(first); expect(fixture.calls).toEqual(callsAfterFirst); @@ -728,7 +815,7 @@ describe("Ghidra composed workflow snapshot replay", () => { tool, parameters, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(first).toMatchObject({ operation: tool }); const snapshot = await readAnalysisSnapshot(fixture.snapshotPath); if (!snapshot.ok) throw snapshot.error; @@ -743,7 +830,7 @@ describe("Ghidra composed workflow snapshot replay", () => { tool, parameters, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(replay).toEqual(first); if (process.platform === "win32") { // The first provider call in a new CLI session admits its source. @@ -769,7 +856,7 @@ describe("binary overview snapshot replay", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(parseEvidence(first).caller_input_path).toBe(fixture.path); expect(fixture.calls.toSorted()).toEqual(operations.toSorted()); expect(fixture.starts).toHaveLength(1); @@ -798,7 +885,7 @@ describe("binary overview snapshot replay", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(second).toEqual(first); expect(fixture.calls.toSorted()).toEqual(operations.toSorted()); expect(fixture.starts).toHaveLength(1); @@ -811,7 +898,7 @@ describe("binary overview snapshot replay", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); const firstEvidence = parseEvidence(first); const relocatedEvidence = parseEvidence(relocated); expect(relocatedEvidence).toMatchObject({ @@ -867,7 +954,7 @@ it.skipIf(process.platform === "win32")( "search_strings", parameters, options, - ), + ).then(projectCliAnalysisResult), ); expect(first).toMatchObject({ caller_input_path: alias, @@ -910,7 +997,7 @@ it.skipIf(process.platform === "win32")( "search_strings", parameters, options, - ), + ).then(projectCliAnalysisResult), ); expect(replay).toMatchObject({ evidence_id: first.evidence_id, @@ -952,7 +1039,7 @@ it.skipIf(process.platform === "win32")( "search_strings", parameters, options, - ), + ).then(projectCliAnalysisResult), ); expect(historicalReplay).toMatchObject({ evidence_id: first.evidence_id, @@ -968,6 +1055,86 @@ it.skipIf(process.platform === "win32")( }, ); +describe("preview and active route policies", () => { + it.each([ + { + tool: "search_strings", + arguments: { pattern: "fixture", document: "fixture" }, + replayed: true, + }, + { tool: "binary_overview", arguments: {}, replayed: false }, + ] as const)( + "keeps $tool replay policy consistent across a composite route", + async ({ tool, arguments: arguments_, replayed }) => { + const fixture = await createSnapshotReplayFixture( + "rea-composite-replay-policy-", + ); + const provider = fixture.createProvider({ + supportedOperations: [...operations, "search_strings"], + }); + const auxiliary = fixture.createProvider({ + identity: { id: "auxiliary", name: "Auxiliary", version: "1" }, + supportedOperations: ["read_bytes"], + }); + const liveAuxiliary: AnalysisProvider = { + ...auxiliary, + capabilities: () => + auxiliary + .capabilities() + .map((descriptor) => ({ ...descriptor, cachePolicy: "live" })), + }; + const createSession = () => + createTestBinarySession(provider, { + auxiliaryProviders: [liveAuxiliary], + }); + const session = createSession(); + try { + const target = await session.resolveTarget(fixture.path); + if (!target.ok) throw target.error; + const preview = await session.previewTarget(target.value); + if (!preview.ok) throw preview.error; + const opened = await session.openResolvedTarget(preview.value); + if (!opened.ok) throw opened.error; + // An enhanced operation has no exact descriptor here. Both stages must + // attribute it to the selected deep provider rather than the composite. + expect( + routeProviderIdentity(preview.value.route, "analyze_function"), + ).toEqual(IDENTITY); + expect(session.providerIdentity("analyze_function")).toEqual(IDENTITY); + expect(session.allowsSnapshotReplay(tool)).toBe(replayed); + expect(session.allowsSnapshotReplay("decompile_all")).toBe(false); + } finally { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } + const dependencies = { + ...fixture.dependencies(provider), + createBinarySession: createSession, + }; + const first = parseEvidence( + await runDirectAnalysis(dependencies, fixture.path, tool, arguments_, { + snapshotPath: fixture.snapshotPath, + }).then(projectCliAnalysisResult), + ); + const starts = fixture.starts.length; + const queries = fixture.queries.length; + const second = parseEvidence( + await runDirectAnalysis(dependencies, fixture.path, tool, arguments_, { + snapshotPath: fixture.snapshotPath, + }).then(projectCliAnalysisResult), + ); + if (replayed) expect(second).toEqual(first); + expect(second).toMatchObject({ + operation: tool, + subject: first.subject, + analysis_profile: first.analysis_profile, + }); + expect(fixture.starts.length).toBe(starts + (replayed ? 0 : 1)); + expect(fixture.queries.length > queries).toBe(!replayed); + }, + ); +}); + describe("snapshot replay provider identity", () => { it("uses the resolved provider version when capability metadata omits it", async () => { const fixture = await createSnapshotReplayFixture("rea-profile-version-"); @@ -988,7 +1155,7 @@ describe("snapshot replay provider identity", () => { "search_strings", { pattern: "fixture", document: "fixture" }, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); const callsAfterFirst = [...fixture.calls]; const startsAfterFirst = [...fixture.starts]; const firstSnapshot = await readAnalysisSnapshot(fixture.snapshotPath); @@ -1003,7 +1170,7 @@ describe("snapshot replay provider identity", () => { "search_strings", { pattern: "fixture", document: "fixture" }, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(fixture.calls).toEqual(callsAfterFirst); expect(fixture.starts).toEqual(startsAfterFirst); expect(second).toEqual(first); @@ -1022,7 +1189,7 @@ describe("snapshot replay provider identity", () => { "search_strings", { pattern: "fixture", document: "fixture" }, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(changedProfileResult).not.toEqual(first); expect(changedProfileResult).toMatchObject({ code: "evidence_integrity_mismatch", @@ -1049,7 +1216,7 @@ describe("snapshot replay provider identity", () => { "search_strings", { pattern: "fixture", document: "fixture" }, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(changedProviderResult).not.toEqual(first); expect(changedProviderResult).toMatchObject({ code: "evidence_integrity_mismatch", @@ -1073,7 +1240,7 @@ const assertEvidenceSurvivesSnapshotSaveFailure = async (): Promise => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "invalid_request", @@ -1108,7 +1275,7 @@ describe("workflow snapshot profile and cancellation binding", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); const loaded = await readAnalysisSnapshot(fixture.snapshotPath); if (!loaded.ok) throw loaded.error; const current = loaded.value.evidence_bundle.records.find( @@ -1132,7 +1299,7 @@ describe("workflow snapshot profile and cancellation binding", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(refreshed).toMatchObject({ normalized_result: { document: "fixture" }, }); @@ -1152,7 +1319,7 @@ describe("workflow snapshot profile and cancellation binding", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); const controller = new AbortController(); const router = createTestProviderRouter(provider); @@ -1177,7 +1344,7 @@ describe("workflow snapshot profile and cancellation binding", () => { "binary_overview", {}, { snapshotPath: fixture.snapshotPath, signal: controller.signal }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ error: "Analysis failed", @@ -1412,7 +1579,7 @@ it("retains a native dispatch workflow snapshot without a deep provider profile" "inspect_native_dispatch_metadata", { max_records: 100 }, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ operation: "inspect_native_dispatch_metadata", @@ -1433,7 +1600,7 @@ it("retains a native dispatch workflow snapshot without a deep provider profile" "inspect_native_dispatch_metadata", { max_records: 100 }, { snapshotPath: fixture.snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(replay).toEqual(result); expect(dispatchCalls).toBe(1); diff --git a/tests/boundary/filesystem/droidClientConfiguration.test.ts b/tests/boundary/filesystem/droidClientConfiguration.test.ts index 6f9b264233..992113d8d0 100644 --- a/tests/boundary/filesystem/droidClientConfiguration.test.ts +++ b/tests/boundary/filesystem/droidClientConfiguration.test.ts @@ -51,6 +51,24 @@ describe("Factory Droid configuration paths", () => { join("/home/a", ".factory", "skills", "reverse-engineer-anything"), ]); }); + + it("uses Droid's selected home for configuration, discovery, and personal skills", () => { + const environment = { FACTORY_HOME_OVERRIDE: "/selected/home" }; + expect(droidPath("/home/a", "darwin", environment)).toMatchObject({ + configPath: join("/selected/home", ".factory", "mcp.json"), + markerPath: join("/selected/home", ".factory"), + }); + expect( + skillDestinations("/home/a", ["droid"], environment).map( + ({ path }) => path, + ), + ).toEqual([ + join("/selected/home", ".factory", "skills", "reverse-engineer-anything"), + ]); + expect( + droidPath("/home/a", "darwin", { FACTORY_HOME_OVERRIDE: "" }), + ).toEqual(droidPath("/home/a", "darwin")); + }); }); describe("Factory Droid JSON registration", () => { diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index c5de3f9cd0..d3319f822d 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -61,6 +61,27 @@ const bundle = (result = true) => ), ]); +it("preserves tagged refusal JSON without interpreting Evidence-shaped fields", async () => { + const root = await createTestTempDirectory("rea-refused-delivery-"); + const refusal = { + code: "unreadable_output", + evidence_id: "caller-selected diagnostic", + normalized_result: { partial: true }, + }; + const delivered = await publishCliAnalysisEvidence( + { ok: false, error: refusal }, + { + providerId: "fixture", + operation: "inspect_artifact", + signal: new AbortController().signal, + detail: "summary", + evidenceOutput: join(root, "refused.json"), + }, + ); + expect(delivered).toBe(refusal); + expect(await readdir(root)).toEqual([]); +}); + it("preserves direct duplicate rejection and CLI identity merging for repeated inspection sources", async () => { const root = await createTestTempDirectory("rea-repeated-delivery-"); const input = join(root, "input.txt"); @@ -91,14 +112,17 @@ it("preserves direct duplicate rejection and CLI identity merging for repeated i deliverAnalysisEvidence(complete, join(root, "direct.json")), ).rejects.toBeInstanceOf(DuplicateEvidenceRecordError); const path = join(root, "cli.json"); - const delivered = await publishCliAnalysisEvidence(complete, { - providerId: "fixture", - operation: "inspect_artifact", - signal: new AbortController().signal, - detail: "complete", - evidenceOutput: path, - sourceEvidence: [withEvidenceCallerInputPath(complete, "./old"), source], - }); + const delivered = await publishCliAnalysisEvidence( + { ok: true, value: complete }, + { + providerId: "fixture", + operation: "inspect_artifact", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: path, + sourceEvidence: [withEvidenceCallerInputPath(complete, "./old"), source], + }, + ); expect(delivered).toEqual(complete); const saved = await readEvidenceBundle(path); if (!saved.ok) throw saved.error; @@ -126,13 +150,16 @@ it("retains a malformed artifact parent and refuses publication through both del ); if (direct.ok) throw new Error("Expected invalid artifact closure"); const projected = projectAnalysisError(direct.error); - const cli = await publishCliAnalysisEvidence(evidence, { - providerId: "fixture", - operation: "inspect_artifact", - signal: new AbortController().signal, - detail: "complete", - evidenceOutput: join(root, "cli.json"), - }); + const cli = await publishCliAnalysisEvidence( + { ok: true, value: evidence }, + { + providerId: "fixture", + operation: "inspect_artifact", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: join(root, "cli.json"), + }, + ); expect(cli).toEqual(projected); expect(JSON.stringify(projected)).toContain(evidence.evidence_id); expect(await readdir(root)).toEqual([]); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 421b27717f..5cefb09d1d 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -1,5 +1,14 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { createReadStream } from "node:fs"; -import { open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { + open, + readFile, + rm, + rmdir, + stat, + utimes, + writeFile, +} from "node:fs/promises"; import { createServer } from "node:net"; import { join } from "node:path"; import { Readable } from "node:stream"; @@ -773,7 +782,7 @@ it("retains failed snapshot cleanup and retries it on close", async () => { "flutter", "identify_flutter_build", new AbortController().signal, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "cleanup_incomplete", details: { @@ -1193,7 +1202,10 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( close.mockRestore(); }); const result = await provider.execute({ operation, input: { path } }); - if (!result.ok) throw result.error; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected retained reader cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.partialObservation).toMatchObject({ operation }); expect(removeRoot).not.toHaveBeenCalled(); let cleanupFailure: unknown; try { @@ -1587,3 +1599,325 @@ it("stops a cancelled descriptor copy before writing its next chunk and releases expect(projectAnalysisError(result.error).code).toBe("cancelled"); expect(removeRoot).toHaveBeenCalledTimes(1); }); + +it.each([false, true])( + "reports actual nonempty-root removal failure and resolves its exact error after retry (cancel=%s)", + async (cancel) => { + const path = await buildApk("retained-root.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + let refuse = true; + let retainedRoot = ""; + const controller = new AbortController(); + const reason = { caller: "cancel during refused cleanup" }; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + retainedRoot = root; + if (refuse) { + if (cancel) controller.abort(reason); + await rmdir(root); // Actual OS refusal: the owned snapshot still occupies the directory. + } else await rm(root, { recursive: true, force: true }); + }, + }); + onTestFinished(async () => { + refuse = false; + await provider.close(); + }); + const controlProvider = new FlutterBuildProvider(); + const control = await new FlutterBuildAnalysisService( + controlProvider, + ).execute("identify_flutter_build", { path }); + await controlProvider.close(); + if (!control.ok) throw control.error; + const result = await provider.execute( + { operation: "identify_flutter_build", input: { path } }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected owned cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([retainedRoot]); + expect(result.error.partialObservation).toEqual(control.value); + if (cancel) expect(result.error.cleanupPrimary?.cause).toBe(reason); + await expect(stat(join(retainedRoot, "input.apk"))).resolves.toBeDefined(); + let retryFailure: unknown; + try { + await provider.close(); + } catch (cause) { + retryFailure = cause; + } + expect(retryFailure).toBeInstanceOf(ProviderCleanupError); + refuse = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + expect(retryFailure).toMatchObject({ + cleanupIncomplete: false, + cleanupResources: [], + }); + await expect(stat(retainedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it("drains admitted snapshot work before close and fences later execution", async () => { + const path = await buildApk("drain-root.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + let reached!: () => void; + let proceed!: () => void; + const boundary = new Promise((resolve) => { + reached = resolve; + }); + const barrier = new Promise((resolve) => { + proceed = resolve; + }); + const original = ZipArtifactReader.prototype.open; + const list = vi + .spyOn(ZipArtifactReader.prototype, "open") + .mockImplementation(async function ( + this: ZipArtifactReader, + ...args: Parameters + ) { + reached(); + await barrier; + return original.apply(this, args); + }); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(async () => { + proceed(); + list.mockRestore(); + await provider.close(); + }); + const running = provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + await boundary; + let closed = false; + const closing = provider.close().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + expect(removeRoot).not.toHaveBeenCalled(); + proceed(); + expect((await running).ok).toBe(true); + await closing; + expect(removeRoot).toHaveBeenCalledTimes(1); + expect( + ( + await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }) + ).ok, + ).toBe(false); +}); + +it("preserves source admission failure when cancellation arrives during its root cleanup", async () => { + const root = await createTestTempDirectory("flutter-primary-"); + const controller = new AbortController(); + const provider = new FlutterBuildProvider({ + removeRoot: async (path) => { + await rm(path, { recursive: true, force: true }); + controller.abort({ caller: "late cleanup cancellation" }); + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { + operation: "identify_flutter_build", + input: { path: join(root, "absent.apk") }, + }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected missing selected source"); + expect(result.error).not.toBeInstanceOf(AnalysisCancelledError); + expect(result.error).toMatchObject({ _tag: "AnalysisInputError" }); + expect(result.error.cleanupIncomplete).toBe(false); +}); + +it("retains mixed-root cleanup diagnostics and releases aggregate errors only after every root recovers", async () => { + const path = await buildApk("mixed-roots.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const roots: string[] = []; + const allowed = new Set(); + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + if (!roots.includes(root)) roots.push(root); + if (!allowed.has(root)) await rmdir(root); + else await rm(root, { recursive: true, force: true }); + }, + }); + onTestFinished(async () => { + roots.forEach((root) => allowed.add(root)); + await provider.close(); + }); + const [first, second] = await Promise.all([ + provider.execute({ operation: "identify_flutter_build", input: { path } }), + provider.execute({ operation: "identify_flutter_build", input: { path } }), + ]); + expect(first.ok || second.ok).toBe(false); + if (first.ok || second.ok) throw new Error("Expected both cleanup owners"); + let aggregate: unknown; + try { + await provider.close(); + } catch (cause) { + aggregate = cause; + } + expect(aggregate).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: expect.arrayContaining(roots), + diagnostics: { + retained_roots: expect.arrayContaining( + roots.map((root) => ({ + path: root, + failures: [expect.objectContaining({ code: "ENOTEMPTY" })], + })), + ), + }, + }); + allowed.add(first.error.cleanupResources[0]!); + await expect(provider.close()).rejects.toBeInstanceOf(ProviderCleanupError); + expect(first.error.cleanupIncomplete).toBe(false); + expect(second.error.cleanupIncomplete).toBe(true); + expect(aggregate).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: second.error.cleanupResources, + }); + allowed.add(second.error.cleanupResources[0]!); + await provider.close(); + expect(second.error.cleanupIncomplete).toBe(false); + expect(aggregate).toMatchObject({ + cleanupIncomplete: false, + cleanupResources: [], + }); +}); + +it("retries retained cleanup before admitting replacement snapshots", async () => { + const path = await buildApk("blocked-roots.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const roots = new Set(); + let refuse = true; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + roots.add(root); + if (refuse) await rmdir(root); + else await rm(root, { recursive: true, force: true }); + }, + }); + onTestFinished(async () => { + refuse = false; + await provider.close(); + }); + const first = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + const blocked = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + expect(roots.size).toBe(1); + expect(blocked.ok).toBe(false); + if (first.ok || blocked.ok) throw new Error("Expected retained cleanup"); + expect(blocked.error.cleanupResources).toEqual(first.error.cleanupResources); + refuse = false; + expect( + ( + await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }) + ).ok, + ).toBe(true); + expect(first.error.cleanupIncomplete).toBe(false); + expect(blocked.error.cleanupIncomplete).toBe(false); + expect(roots.size).toBe(2); +}); + +it("does not create a replacement snapshot when cancelled during cleanup retry", async () => { + const path = await buildApk("cancel-cleanup-retry.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const roots = new Set(); + const controller = new AbortController(); + const reason = { caller: "cancel during cleanup retry" }; + let attempts = 0; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + roots.add(root); + attempts++; + if (attempts === 1) await rmdir(root); + else { + controller.abort(reason); + await rm(root, { recursive: true, force: true }); + } + }, + }); + onTestFinished(() => provider.close()); + const request = { + operation: "identify_flutter_build" as const, + input: { path }, + }; + const first = await provider.execute(request); + if (first.ok) throw new Error("Expected retained cleanup"); + const cancelled = await provider.execute(request, { + signal: controller.signal, + }); + expect(cancelled.ok).toBe(false); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(projectAnalysisError(cancelled.error).code).toBe("cancelled"); + expect(cancelled.error.cause).toBe(reason); + expect(first.error.cleanupIncomplete).toBe(false); + expect(roots.size).toBe(1); +}); + +it("preserves a completed analysis failure through refused cleanup and late cancellation", async () => { + const path = await buildApk("unsupported-cleanup.apk", []); + const controlProvider = new FlutterBuildProvider(); + const control = await controlProvider.execute({ + operation: "inspect_dart_aot", + input: { path }, + }); + await controlProvider.close(); + if (control.ok) throw new Error("Expected unsupported package control"); + const controller = new AbortController(); + let refuse = true; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + if (refuse) { + controller.abort({ caller: "late failed cleanup" }); + await rmdir(root); + } else await rm(root, { recursive: true, force: true }); + }, + }); + onTestFinished(async () => { + refuse = false; + await provider.close(); + }); + const result = await provider.execute( + { operation: "inspect_dart_aot", input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected primary and cleanup failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(result.error.cleanupPrimary!)).toEqual( + projectAnalysisError(control.error), + ); + expect(result.error.cleanupPrimary).not.toBeInstanceOf( + AnalysisCancelledError, + ); + refuse = false; + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error)).toMatchObject( + projectAnalysisError(control.error), + ); +}); diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index ddfa250039..662dea627a 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../../src/cli/projectCliAnalysisResult.js"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; @@ -299,7 +300,11 @@ describe("HarmonyOS packaging boundaries", () => { path, JSON.stringify({ log: { version: "1.2", creator: {}, entries: [] } }), ); - const result = await runProviderAnalysis(path, "inventory_artifact", {}); + const result = await runProviderAnalysis( + path, + "inventory_artifact", + {}, + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ error: "Analysis failed", code: "target_unavailable", diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts index 28fdb5e386..7061eade2c 100644 --- a/tests/boundary/filesystem/hermesClientConfiguration.test.ts +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { userInfo } from "node:os"; @@ -11,7 +11,10 @@ import { configureClientConfiguration, inspectClientConfiguration, } from "../../../src/application/SetupClientConfiguration.js"; -import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { + clientSkillDirectories, + supportedClients, +} from "../../../src/application/SupportedClients.js"; import { runUninstall, systemUninstallHost, @@ -37,6 +40,45 @@ const hermesPath = ( ); describe("Hermes configuration paths", () => { + it.skipIf(process.platform === "win32")( + "configures the filesystem-selected Hermes home through symlink parent traversal", + async () => { + const home = await createTestTempDirectory("rea-hermes-path-selection-"); + await mkdir(join(home, "physical", "child"), { recursive: true }); + await symlink(join(home, "physical", "child"), join(home, "alias")); + const selected = `${home}/alias/..`; + const original = "model: selected-home\n"; + const decoy = "model: lexical-parent\n"; + await writeFile(join(home, "physical", "config.yaml"), original); + await writeFile(join(home, "config.yaml"), decoy); + const environment = { HERMES_HOME: selected }; + const client = supportedClients(home, process.platform, environment).find( + ({ name }) => name === "hermes", + ); + if (client === undefined) throw new Error("missing Hermes client"); + expect(client.markerPath).toBe(selected); + expect(client.configPath).toBe(`${selected}/config.yaml`); + expect( + clientSkillDirectories(home, ["hermes"], environment).directories, + ).toEqual([{ client: "hermes", directory: `${selected}/skills` }]); + expect( + await configureClientConfiguration(client, {}, command), + ).toMatchObject({ + status: "configured", + }); + expect( + await readFile(join(home, "physical", "config.yaml"), "utf8"), + ).toContain("mcp_servers:"); + expect( + await readFile( + join(home, "physical", "config.yaml.rea.backup"), + "utf8", + ), + ).toBe(original); + expect(await readFile(join(home, "config.yaml"), "utf8")).toBe(decoy); + }, + ); + it("resolves config.yaml under the platform Hermes home", () => { expect(hermesPath({})).toEqual({ name: "hermes", @@ -130,6 +172,76 @@ describe("Hermes configuration paths", () => { }); }); +describe("Hermes unresolved home identity", () => { + it.skipIf(process.platform === "win32")( + "keeps a new custom home reached through a native-home symlink outside native sticky selection", + async () => { + const home = await createTestTempDirectory("rea-hermes-new-alias-"); + const native = join(home, ".hermes"); + const profile = join(native, "profiles", "coder"); + const external = join(home, "external"); + await mkdir(profile, { recursive: true }); + await mkdir(external); + await writeFile(join(profile, "config.yaml"), "model: native-profile\n"); + await writeFile(join(native, "active_profile"), "coder\n"); + await symlink(external, join(native, "alias")); + const selected = join(native, "alias", "new-home"); + const environment = { HERMES_HOME: selected }; + const client = supportedClients(home, process.platform, environment).find( + ({ name }) => name === "hermes", + ); + if (client === undefined) throw new Error("missing Hermes client"); + expect(client.markerPath).toBe(selected); + expect( + clientSkillDirectories(home, ["hermes"], environment).directories, + ).toEqual([{ client: "hermes", directory: join(selected, "skills") }]); + expect( + await configureClientConfiguration(client, {}, command), + ).toMatchObject({ + status: "configured", + }); + expect( + await readFile(join(external, "new-home", "config.yaml"), "utf8"), + ).toContain("mcp_servers:"); + expect(await readFile(join(profile, "config.yaml"), "utf8")).toBe( + "model: native-profile\n", + ); + }, + ); + + it.skipIf(process.platform === "win32")( + "keeps a dangling home alias unresolved instead of selecting a native profile", + async () => { + const home = await createTestTempDirectory("rea-hermes-dangling-home-"); + const native = join(home, ".hermes"); + const profile = join(native, "profiles", "coder"); + await mkdir(profile, { recursive: true }); + await writeFile(join(profile, "config.yaml"), "model: native-profile\n"); + await writeFile(join(native, "active_profile"), "coder\n"); + await symlink(join(home, "missing-external"), join(native, "alias")); + const selected = join(native, "alias", "new-home"); + const environment = { HERMES_HOME: selected }; + const client = supportedClients(home, process.platform, environment).find( + ({ name }) => name === "hermes", + ); + expect(client).toMatchObject({ + configPath: selected, + configPathError: expect.stringContaining("Cannot resolve"), + }); + expect(client).not.toHaveProperty("markerPath"); + expect( + clientSkillDirectories(home, ["hermes"], environment), + ).toMatchObject({ + directories: [], + unresolved: [{ client: "hermes", path: selected }], + }); + expect(await readFile(join(profile, "config.yaml"), "utf8")).toBe( + "model: native-profile\n", + ); + }, + ); +}); + const hermesClient = async () => { const home = await createTestTempDirectory("rea-hermes-"); const client = supportedClients(home, process.platform, {}).find( diff --git a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts index 65dd0857ec..2d8725f2d0 100644 --- a/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronApplicationAnalysis.test.ts @@ -430,9 +430,10 @@ const expectElectronSummary = (result: Reconstruction): void => { ipc: { literal_channels: 5, dynamic_channel_operations: 2, - paired_renderer_transmissions: 4, - ambiguous_renderer_transmissions: 1, - unpaired_literal_renderer_transmissions: 1, + // Retained module gaps leave preload/renderer application ownership unknown. + paired_renderer_transmissions: 0, + ambiguous_renderer_transmissions: 7, + unpaired_literal_renderer_transmissions: 0, }, sender_validation_observations: 2, utility_processes: 1, @@ -591,12 +592,7 @@ const expectElectronBoundaries = (graph: ApplicationGraph): void => { ({ properties }) => properties.pairing_basis === "unique-exact-literal-channel", ); - expect(pairingEdges).toHaveLength(4); - expect( - pairingEdges.some( - ({ properties }) => properties.channel === "rea:ambiguous", - ), - ).toBe(false); + expect(pairingEdges).toHaveLength(0); expect(graph.limitations.join(" ")).toMatch(/dynamic IPC|ambiguous/iu); }; diff --git a/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts index 467de327ca..07bb01a5e1 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptPackageRendererProvenance.test.ts @@ -42,11 +42,13 @@ it.each(["renderer", "browser", "module"] as const)( expect(metadata?.observations).toContainEqual( expect.objectContaining({ properties: expect.objectContaining({ - renderer: "entry.js", renderer_declarations: { [field]: "entry.js" }, }), }), ); + expect(metadata?.observations[0]?.properties).not.toHaveProperty( + "renderer", + ); expect(graph.edges).toContainEqual( expect.objectContaining({ source_node_id: metadata?.node_id, @@ -71,7 +73,6 @@ it("keeps present browser mappings distinct from absent renderer metadata when m }); const metadata = graph.nodes.find(({ kind }) => kind === "package"); expect(metadata?.observations[0]?.properties).toMatchObject({ - renderer: "entry.js", renderer_declarations: { browser: { status: "non-string" }, module: "entry.js", @@ -91,7 +92,6 @@ it("retains simultaneous string declarations while keeping renderer priority", a }); const metadata = graph.nodes.find(({ kind }) => kind === "package"); expect(metadata?.observations[0]?.properties).toMatchObject({ - renderer: "entry.js", renderer_declarations: { renderer: "entry.js", browser: "browser.js", diff --git a/tests/boundary/filesystem/referenceSourceGitPaths.test.ts b/tests/boundary/filesystem/referenceSourceGitPaths.test.ts new file mode 100644 index 0000000000..0a72396024 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceGitPaths.test.ts @@ -0,0 +1,54 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { locateReferenceSourceGitDirectory } from "../../../src/application/ReferenceSourceGitDirectory.js"; +import { readReferenceSourceVcs } from "../../../src/application/ReferenceSourceVcsAdapter.js"; +import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +it.skipIf(process.platform === "win32").each(["gitdir", "commondir"] as const)( + "rejects non-directory traversal in a %s pointer without publishing decoy Git facts", + async (pointer) => { + const root = await createTestTempDirectory("rea-reference-git-path-"); + const source = join(root, "source"); + const metadata = join(root, "metadata"); + await mkdir(source); + await mkdir(metadata); + await writeFile(join(root, "blocker"), "regular file\n"); + await writeFile(join(metadata, "HEAD"), `${"1".repeat(40)}\n`); + await writeFile(join(metadata, "config"), "[core]\nignorecase = true\n"); + const selected = `${join(root, "blocker")}/../metadata`; + if (pointer === "gitdir") { + await writeFile(join(source, ".git"), `gitdir: ${selected}\n`); + } else { + await mkdir(join(source, ".git")); + await writeFile(join(source, ".git", "HEAD"), `${"2".repeat(40)}\n`); + await writeFile(join(source, ".git", "commondir"), `${selected}\n`); + } + await expect(stat(selected)).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect( + locateReferenceSourceGitDirectory(source, (path) => + readFile(path, "utf8"), + ), + ).rejects.toMatchObject({ code: "ENOTDIR", path: selected }); + await withArtifactResourceScope(async (resources) => { + expect(await readReferenceSourceVcs(source, resources)).toMatchObject({ + ok: true, + value: { kind: "unknown", head: null, dirty: null }, + }); + }); + const imported = await importReferenceSource({ + root: source, + caller: "git-path-test", + policy: { secretPatterns: [] }, + }); + expect(imported).toMatchObject({ + ok: false, + error: { + code: "io", + cause: { code: "ENOTDIR", path: selected }, + }, + }); + }, +); diff --git a/tests/boundary/filesystem/stableFileHashOwnership.test.ts b/tests/boundary/filesystem/stableFileHashOwnership.test.ts index b4bb528fe2..56d2b60948 100644 --- a/tests/boundary/filesystem/stableFileHashOwnership.test.ts +++ b/tests/boundary/filesystem/stableFileHashOwnership.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { open as openFile, writeFile } from "node:fs/promises"; +import { open as openFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; @@ -52,6 +52,46 @@ it("rejects a real extent change after admission instead of replacing its baseli } }); +it("classifies a selected name removed at final validation as a changed file and closes its descriptor", async () => { + const root = await createTestTempDirectory("rea-stable-name-removed-"); + const path = join(root, "input.bin"); + await writeFile(path, "admitted bytes"); + const nativeOpen = regularFile.openRegularFile; + let descriptor: regularFile.StableRegularFileDescriptor | undefined; + const open = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementationOnce(async (...args) => { + descriptor = await nativeOpen(...args); + const nativeStat = descriptor.handle.stat.bind(descriptor.handle); + vi.spyOn(descriptor.handle, "stat").mockImplementationOnce(async () => { + const metadata = await nativeStat(); + await unlink(path); + return metadata; + }); + return descriptor; + }); + try { + const retainFailedClose = vi.fn(); + const failure: unknown = await hashStableFile(path, { + retainFailedClose, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "StableFileHashFailure", + cause: { + name: "RegularFileChangedError", + code: "ESTALE", + cause: { code: "ENOENT" }, + }, + }); + expect(descriptor?.handle.fd).toBe(-1); + expect(retainFailedClose).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + if (descriptor !== undefined && descriptor.handle.fd >= 0) + await descriptor.handle.close(); + } +}); + it("captures a two-byte prefix across short reads and buffer reuse", async () => { const root = await createTestTempDirectory("rea-stable-observation-prefix-"); const path = join(root, "script"); diff --git a/tests/boundary/filesystem/staticCheckReports.test.ts b/tests/boundary/filesystem/staticCheckReports.test.ts index dd3a146f6b..a04f86142f 100644 --- a/tests/boundary/filesystem/staticCheckReports.test.ts +++ b/tests/boundary/filesystem/staticCheckReports.test.ts @@ -69,7 +69,6 @@ it("covers the lint inventory and root tool configs in resolved cache inputs", ( join(repository, "node_modules/turbo/bin/turbo"), "run", "lint", - "lint:built", "knip", "--dry=json", ], @@ -92,10 +91,8 @@ it("covers the lint inventory and root tool configs in resolved cache inputs", ( ), }) .parse(JSON.parse(dry.stdout)); - const lintTasks = tasks.filter(({ task }) => - ["lint", "lint:built"].includes(task), - ); - expect(lintTasks).toHaveLength(2); + const lintTasks = tasks.filter(({ task }) => task === "lint"); + expect(lintTasks).toHaveLength(1); for (const task of lintTasks) expect( files.filter((file) => !Object.hasOwn(task.inputs, file)), diff --git a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts index 397194ac2c..b7cdbd20a4 100644 --- a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts +++ b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -177,7 +178,7 @@ it("retains cancelled source read ownership until service close retries it", asy "web-module-artifacts", "trace_web_module_imports", new AbortController().signal, - ); + ).then(projectCliAnalysisResult); expect(finalized).toMatchObject({ code: "cleanup_incomplete", details: { resources: [sourcePath] }, diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index b287db7dd4..c158bc44a0 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -1,3 +1,4 @@ +import { javaClassFile } from "../../fixtures/javaClassFile.js"; import { Il2cppBinaryFormatFailure } from "../../../src/il2cpp/Il2cppBinaryFormat.js"; import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js"; import { @@ -191,10 +192,33 @@ it("rejects reused MethodDef tokens within an image but preserves cross-image re ); }); +it.each([0, 4000, 9000])( + "reports a valid JVM class with %i unused constant bytes as unsupported", + (constantBytes) => { + expect(failure(javaClassFile(constantBytes, true))).toEqual({ + reason: "unsupported-container", + kind: "unsupported", + }); + }, +); + +it("does not infer universal Mach-O identity from a FAT-shaped non-Mach-O table", () => { + // CAFEBABE also identifies JVM classes. A plausible FAT table and known CPU + // do not prove Mach-O unless the declared slice contains a Mach-O header. + const bytes = createUniversalFixture([ + { cpu: 0x0100000c, bytes: Buffer.alloc(0x100) }, + ]); + expect(failure(bytes)).toEqual({ + reason: "unsupported-container", + kind: "unsupported", + }); + expect(failure(bytes, "arm64").reason).toBe("slice-not-found"); +}); + it("requires an explicit slice of a universal Mach-O and reads only that slice", () => { const thin = createIl2cppBinaryFixture(); const universal = createUniversalFixture([ - { cpu: 0x01000007, bytes: Buffer.alloc(0x100) }, + { cpu: 0x01000007, bytes: createIl2cppBinaryFixture() }, { cpu: 0x0100000c, bytes: thin }, ]); expect(failure(universal)).toEqual({ @@ -1202,6 +1226,17 @@ it("checks the observed slice header against the caller-selected FAT architectur ]); expect(failure(bytes, "amd64").reason).toBe("malformed-container"); }); +it.each([1, 0x80000000])( + "checks the complete FAT CPU subtype including capability bits (%i)", + (subtype) => { + const thin = createIl2cppBinaryFixture(); + thin.writeUInt32LE(subtype, 8); + const bytes = createUniversalFixture([{ cpu: 0x0100000c, bytes: thin }]); + expect(failure(bytes, "arm64").reason).toBe("malformed-container"); + bytes.writeUInt32BE(subtype, 12); + expect(link(bytes, "arm64").method_addresses).toEqual(expectedAddresses); + }, +); it("still validates version 27 type indices when rendered names are unavailable", () => { const decoded = readIl2cppMetadataForLinking( createIl2cppMetadataFixture({ version: 27 }).bytes, diff --git a/tests/boundary/javascript/javascriptWorkerFinalizationCancellation.test.ts b/tests/boundary/javascript/javascriptWorkerFinalizationCancellation.test.ts index f4b0ae5f5f..7f3f67de7f 100644 --- a/tests/boundary/javascript/javascriptWorkerFinalizationCancellation.test.ts +++ b/tests/boundary/javascript/javascriptWorkerFinalizationCancellation.test.ts @@ -3,22 +3,24 @@ import { promisify } from "node:util"; import { expect, it } from "vitest"; -it("retains completed worker facts when cancellation arrives during final job cleanup", async () => { - const loaderUrl = new URL( - "../../../scripts/lib/verifier-source-loader.mjs", - import.meta.url, - ).href; - const workerUrl = new URL( - "../../../src/javascript/analysis/JavaScriptAnalysisWorker.ts", - import.meta.url, - ).href; - const processUrl = new URL( - "../../../src/process/ProviderProcess.ts", - import.meta.url, - ).href; - // Isolate the filesystem seam in a Node process. Both analyses use the real - // analyzer and remove their actual files before the cancellation is delivered. - const script = ` +it.each([false, true])( + "retains completed worker facts when cancellation arrives during final job cleanup (failure: %s)", + async (failCleanup) => { + const loaderUrl = new URL( + "../../../scripts/lib/verifier-source-loader.mjs", + import.meta.url, + ).href; + const workerUrl = new URL( + "../../../src/javascript/analysis/JavaScriptAnalysisWorker.ts", + import.meta.url, + ).href; + const processUrl = new URL( + "../../../src/process/ProviderProcess.ts", + import.meta.url, + ).href; + // Isolate the filesystem seam in a Node process. Both analyses use the real + // analyzer. Failed final removal retains the real file for owning close retry. + const script = ` import fs from 'node:fs'; import { createHash } from 'node:crypto'; import { syncBuiltinESMExports } from 'node:module'; @@ -29,8 +31,13 @@ it("retains completed worker facts when cancellation arrives during final job cl let cancelOnCleanup = false; let completedRemovals = 0; fs.promises.rm = async (path, options) => { + const finalJobRemoval = String(path).includes('rea-javascript-analysis-') && String(path).endsWith('.records'); + if (finalJobRemoval && cancelOnCleanup && ${JSON.stringify(failCleanup)}) { + controller.abort(reason); + throw new Error('fixture final removal denied'); + } await nativeRm(path, options); - if (String(path).includes('rea-javascript-analysis-') && String(path).endsWith('.records')) { + if (finalJobRemoval) { completedRemovals += 1; if (cancelOnCleanup) controller.abort(reason); } @@ -60,8 +67,9 @@ it("retains completed worker facts when cancellation arrives during final job cl const cancelled = await worker.analyze(source('cancelled.mjs'), { nodes: 100, unknowns: 100 }, controller.signal); const facts = cancelled.ok ? cancelled.value : cancelled.error; observed = { controlOk: control.ok, controlReusable, completedRemovals, launches, - cancelled: !cancelled.ok && cancelled.error.error._tag === 'AnalysisCancelledError', - exactReason: !cancelled.ok && cancelled.error.error.cause === reason, + cancelled: !cancelled.ok && (cancelled.error.error.cleanupPrimary ?? cancelled.error.error)._tag === 'AnalysisCancelledError', + exactReason: !cancelled.ok && (cancelled.error.error.cleanupPrimary ?? cancelled.error.error).cause === reason, + cleanupIncomplete: !cancelled.ok && cancelled.error.error.cleanupIncomplete, parseStatus: facts.javascript?.parse_status, hasModule: facts.module !== null, hasProjection: facts.projection !== null, reusable: worker.canReuse() }; } finally { @@ -76,24 +84,26 @@ it("retains completed worker facts when cancellation arrives during final job cl process.stdout.write(JSON.stringify({ ...observed, rootRemoved, childExited: child.exitCode !== null || child.signalCode !== null })); `; - const { stdout } = await promisify(execFile)( - process.execPath, - ["--input-type=module", "--eval", script], - { timeout: 20_000 }, - ); - const observed: unknown = JSON.parse(stdout); - expect(observed).toEqual({ - controlOk: true, - controlReusable: true, - completedRemovals: 2, - launches: 1, - cancelled: true, - exactReason: true, - parseStatus: "complete", - hasModule: true, - hasProjection: true, - reusable: false, - rootRemoved: true, - childExited: true, - }); -}); + const { stdout } = await promisify(execFile)( + process.execPath, + ["--input-type=module", "--eval", script], + { timeout: 20_000 }, + ); + const observed: unknown = JSON.parse(stdout); + expect(observed).toEqual({ + controlOk: true, + controlReusable: true, + completedRemovals: failCleanup ? 1 : 2, + launches: 1, + cancelled: true, + exactReason: true, + cleanupIncomplete: failCleanup, + parseStatus: "complete", + hasModule: true, + hasProjection: true, + reusable: false, + rootRemoved: true, + childExited: true, + }); + }, +); diff --git a/tests/boundary/javascript/workerFailureComposition.test.ts b/tests/boundary/javascript/workerFailureComposition.test.ts index 94c855b2f7..2013ea765f 100644 --- a/tests/boundary/javascript/workerFailureComposition.test.ts +++ b/tests/boundary/javascript/workerFailureComposition.test.ts @@ -31,13 +31,17 @@ vi.mock("node:fs/promises", async (original) => { return { ...actual, rm: async (...args: Parameters) => { + if ( + String(args[0]).includes("rea-javascript-analysis-") && + String(args[0]).endsWith(".source") + ) + faults.onSourceRemoval?.(); if ( faults.sourceRemoval && String(args[0]).includes("rea-javascript-analysis-") && String(args[0]).endsWith(".source") ) { faults.removals += 1; - faults.onSourceRemoval?.(); throw Object.assign( new Error("controlled source-file removal failure"), { code: "EACCES" }, @@ -77,9 +81,20 @@ afterEach(() => { faults.onSourceRemoval = undefined; }); -it.each(["io", "output"] as const)( - "keeps worker %s failure classification distinct at the protocol boundary", - async (reason) => { +it.each([ + ["io", false, false], + ["output", false, false], + ["output", true, true], + ["output", false, true], +] as const)( + "keeps worker %s failure classification distinct at the protocol boundary (cleanup: %s; cancellation: %s)", + async (reason, deniedCleanup, cancelCleanup) => { + const controller = new AbortController(); + const cancellation = new Error("cancel during failed job cleanup"); + faults.sourceRemoval = deniedCleanup; + faults.onSourceRemoval = cancelCleanup + ? () => controller.abort(cancellation) + : undefined; const worker = new JavaScriptAnalysisWorker( { heapMb: 128, timeoutMs: 30_000 }, async (request) => @@ -92,13 +107,26 @@ it.each(["io", "output"] as const)( }), ); try { - const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); + const result = await worker.analyze( + file, + { nodes: 100, unknowns: 100 }, + controller.signal, + ); expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected worker failure"); - expect(result.error.error._tag).toBe( + const failure = result.error.error.cleanupPrimary ?? result.error.error; + expect(result.error.error.cleanupIncomplete).toBe(deniedCleanup); + if (cancelCleanup) { + expect(failure.secondaryFailures).toHaveLength(1); + expect(failure.secondaryFailures[0]).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: cancellation, + }); + } + expect(failure._tag).toBe( reason === "io" ? "ProviderAdapterError" : "AnalysisOutputError", ); - const projected = projectAnalysisError(result.error.error); + const projected = projectAnalysisError(failure); expect(projected.code).toBe( reason === "io" ? "execution_failure" : "unreadable_output", ); @@ -112,6 +140,7 @@ it.each(["io", "output"] as const)( expect(projected.message).toContain("storage failed"); } } finally { + faults.sourceRemoval = false; await worker.close(); } }, @@ -124,7 +153,9 @@ it.each([false, true])( faults.sourceRemoval = removalFailure; const controller = new AbortController(); const reason = new Error("cancel during denied source-file cleanup"); - faults.onSourceRemoval = () => controller.abort(reason); + faults.onSourceRemoval = removalFailure + ? () => controller.abort(reason) + : undefined; const sourceMapUrls = [ { declared_url: "app.map", diff --git a/tests/boundary/mcp/androidPackageSummaryMcp.test.ts b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts index 9996a3743e..d3561f28a2 100644 --- a/tests/boundary/mcp/androidPackageSummaryMcp.test.ts +++ b/tests/boundary/mcp/androidPackageSummaryMcp.test.ts @@ -8,9 +8,17 @@ import { inspectAnalysisView } from "../../../src/application/analysisView/Analy import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js"; import { androidPackageSummaryViewResultSchema, + androidManifestViewResultSchema, + androidComponentsViewResultSchema, + androidComponentSummaryViewResultSchema, analysisViewResultSchema, } from "../../../src/domain/analysisView/analysisView.js"; -import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { ok } from "../../../src/domain/result.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { createServer } from "../../../src/server/createServer.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; @@ -21,13 +29,55 @@ import { verifyJadxFixtureCleanup, } from "../../fixtures/android/jadx.js"; +async function assertAndroidPackageSelectedFacetContract(options: { + client: Pick; + ajv: Ajv2020; + outputSchema: Record; + apk: string; + parent: Evidence; + retained: (id: string) => Evidence | undefined; + sourceStatus: string; +}): Promise { + const { client, ajv, outputSchema, apk, parent, retained, sourceStatus } = + options; + for (const facet of ["components", "component-summary"] as const) { + const selected = await client.callTool({ + name: "inspect_android_package", + arguments: { + path: apk, + detail: "summary", + view: { kind: "android", facet }, + }, + }); + expect(selected.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, selected.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const result = parseEvidence(selected.structuredContent).normalized_result; + const projected = + facet === "components" + ? androidComponentsViewResultSchema.parse(result) + : androidComponentSummaryViewResultSchema.parse(result); + // A fresh acquisition gets its own Evidence ID even when the engine is reused. + const selectedParent = retained(projected.parent_evidence_id); + expect(selectedParent?.operation).toBe("inspect_android_package"); + expect(selectedParent?.subject).toEqual(parent.subject); + expect(selectedParent?.normalized_result).toEqual(parent.normalized_result); + expect(projected.source_status).toBe(sourceStatus); + } +} + it.skipIf(process.platform === "win32")( "retains complete APK Evidence and reads exact manifest without its source or another engine launch", async () => { const { provider, apk, launches } = await createJadxProtocolFixture("large-manifest"); const harness = await createApplicationMcpHarness({ - serverOptions: { androidAnalysis: provider }, + serverOptions: { + androidAnalysis: provider, + environment: { REA_MCP_TOOL_FAMILIES: "android,application,session" }, + }, }); onTestFinished(harness.close); const { client, session } = harness; @@ -107,6 +157,15 @@ it.skipIf(process.platform === "win32")( expect(full.parameters).toEqual(parent.parameters); expect(full.raw_result).not.toBeNull(); } + await assertAndroidPackageSelectedFacetContract({ + client, + ajv, + outputSchema, + apk, + parent, + retained: (id) => session.evidenceById(id), + sourceStatus: report.components.status, + }); const before = JSON.stringify(parent); await unlink(apk); let text = ""; @@ -123,10 +182,9 @@ it.skipIf(process.platform === "win32")( }, }); expect(paged.isError).not.toBe(true); - const page = analysisViewResultSchema.parse( + const page = androidManifestViewResultSchema.parse( parseEvidence(paged.structuredContent).normalized_result, ); - if (page.kind !== "android") throw new Error("Unexpected manifest page"); text += page.item.text; expect(page.item.source_status).toBe(manifest.status); expect(page.item.reported_total_bytes).toBe( @@ -274,6 +332,19 @@ it("rejects unavailable retention, invalid selectors and cancellation before pro if (cancelled.ok) throw new Error("Expected Android admission cancellation"); expect(cancelled.error._tag).toBe("AnalysisCancelledError"); expect(cancelled.error.cause).toBe(reason); + for (const detail of [undefined, "complete"]) { + expect( + await inspectAndroidPackage( + service, + { + path: "/missing.apk", + ...(detail === undefined ? {} : { detail }), + view: { kind: "android", facet: "components" }, + }, + retention, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + } expect(execute).not.toHaveBeenCalled(); const session = createTestBinarySession(() => { throw new Error("Unexpected deep provider"); @@ -298,3 +369,112 @@ it("rejects unavailable retention, invalid selectors and cancellation before pro expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable"); expect(execute).not.toHaveBeenCalled(); }); + +it("delivers the selected retained page in one analysis and reads it later without a provider or source APK", async () => { + const parent = createEvidence( + { path: "/never-reopened.apk", sha256: "a".repeat(64), format: "apk" }, + { id: "jadx", name: "JADX", version: "0.7.1" }, + { + operation: "inspect_android_package", + parameters: { path: "/never-reopened.apk" }, + result: { + engine: { + name: "jadx", + version: null, + artifact_sha256: "b".repeat(64), + source_revision: null, + worker_count: 1, + heap_limit_mib: null, + }, + package_name: "example", + version_name: null, + version_code: null, + min_sdk: null, + target_sdk: "35", + permissions: [], + manifest: { + status: "complete", + text: "", + reported_total_bytes: 11, + }, + components: { + status: "complete", + limitations: [], + items: [ + { + kind: "activity", + declared_name: ".Main", + name: "example.Main", + exported: true, + exported_default: null, + permission: null, + permission_source: "none", + intent_filter_count: 1, + actions: ["VIEW"], + categories: [], + foreground_service_type: null, + authorities: null, + target_activity: null, + }, + ], + }, + class_count: 1, + resource_count: 0, + signature_verification: "not_performed", + }, + }, + ); + const provider = { + execute: vi.fn(), + close: async () => {}, + inspectAvailability: vi.fn(), + }; + const service = new AndroidAnalysisService(provider); + const execute = vi.spyOn(service, "execute").mockResolvedValue(ok(parent)); + const session = createTestBinarySession(() => { + throw new Error("Unexpected deep provider"); + }); + onTestFinished(async () => { + const closed = await session.close(); + expect(closed.ok).toBe(true); + }); + const view = { + kind: "android", + facet: "components", + actions: ["VIEW"], + fields: ["name"], + limit: 1, + }; + const selected = await inspectAndroidPackage( + service, + { path: "/never-reopened.apk", detail: "summary", view }, + { + recordEvidence: session.recordEvidence.bind(session), + evidenceById: session.evidenceById.bind(session), + }, + ); + if (!selected.ok) throw selected.error; + const first = androidComponentsViewResultSchema.parse( + selected.value.normalized_result, + ); + expect(first.items).toEqual([ + { source_index: 0, name: "example.Main", declaration_coverage: "unknown" }, + ]); + expect( + session.evidenceById(first.parent_evidence_id)?.normalized_result, + ).toEqual(parent.normalized_result); + const later = await inspectAnalysisView( + { + source: { + kind: "retained-evidence", + evidence_id: first.parent_evidence_id, + }, + view, + }, + session.evidenceById.bind(session), + ); + if (!later.ok) throw later.error; + expect(later.value.normalized_result).toEqual(first); + expect(execute).toHaveBeenCalledTimes(1); + expect(provider.execute).not.toHaveBeenCalled(); +}); diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts index 1e64477190..a09acba305 100644 --- a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -33,48 +33,63 @@ import { parseNativeExports } from "../../../src/native/exports/NativeExportPars import { createNativeExportsService } from "../../../src/composition/binaryDiagnostics.js"; import { registerNativeExportsTool } from "../../../src/server/registerNativeExportsTool.js"; -it("refuses a native exports summary before file admission when retention is unavailable", async () => { - const service = createNativeExportsService(); - const server = new EvidenceMcpServer( - { name: "exports-preflight", version: "1" }, - {}, - undefined, - createToolResultDelivery(undefined), - ); - registerNativeExportsTool(server, service, silentLogger); - const client = new Client({ name: "exports-preflight-client", version: "1" }); - onTestFinished(async () => { - await client.close(); - await server.close(); - await service.close(); - }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - const schemas = await advertisedMcpToolSchemas( - client, - "inspect_native_exports", - ); - expect( - schemas.ajv.validate(schemas.inputSchema, { - path: "/absent/native-library.so", - detail: "summary", - }), - ).toBe(true); - const refused = await client.callTool({ - name: "inspect_native_exports", - arguments: { path: "/absent/native-library.so", detail: "summary" }, - }); - expect(parseMcpToolError(refused).error).toMatchObject({ - code: "capability_unavailable", - }); - const complete = await client.callTool({ - name: "inspect_native_exports", - arguments: { path: "/absent/native-library.so" }, - }); - expect(parseMcpToolError(complete).error.code).toBe("invalid_request"); -}); +it.each(["inspect_native_exports", "inspect_byte_entropy"] as const)( + "refuses %s summary before file admission when retention is unavailable", + async (name) => { + const service = + name === "inspect_native_exports" + ? createNativeExportsService() + : new ByteEntropyService(); + const server = new EvidenceMcpServer( + { name: "exports-preflight", version: "1" }, + {}, + undefined, + createToolResultDelivery(undefined), + ); + if (service instanceof ByteEntropyService) + registerByteEntropyTool( + server, + service, + silentLogger, + undefined, + undefined, + undefined, + ); + else registerNativeExportsTool(server, service, silentLogger); + const client = new Client({ + name: "exports-preflight-client", + version: "1", + }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await service.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const schemas = await advertisedMcpToolSchemas(client, name); + expect( + schemas.ajv.validate(schemas.inputSchema, { + path: "/absent/native-library.so", + detail: "summary", + }), + ).toBe(true); + const refused = await client.callTool({ + name, + arguments: { path: "/absent/native-library.so", detail: "summary" }, + }); + expect(parseMcpToolError(refused).error).toMatchObject({ + code: "capability_unavailable", + }); + const complete = await client.callTool({ + name, + arguments: { path: "/absent/native-library.so" }, + }); + expect(parseMcpToolError(complete).error.code).toBe("invalid_request"); + }, +); it.each([false, true])( "preserves a completed byte report through MCP cleanup failure and retry (retained=%s)", diff --git a/tests/boundary/mcp/composedWorkflowEvidenceLinksMcp.test.ts b/tests/boundary/mcp/composedWorkflowEvidenceLinksMcp.test.ts index 88c1383b20..88fd62affb 100644 --- a/tests/boundary/mcp/composedWorkflowEvidenceLinksMcp.test.ts +++ b/tests/boundary/mcp/composedWorkflowEvidenceLinksMcp.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it } from "vitest"; import { createServer } from "../../../src/server/createServer.js"; @@ -9,7 +10,12 @@ import { composedWorkflowFixture } from "../../fixtures/composedWorkflowEvidence it("returns the same consumed atomic provenance through CLI application and MCP boundaries", async () => { const cli = await composedWorkflowFixture(); const direct = parseEvidence( - await runDirectAnalysis(cli.dependencies, cli.path, "binary_overview", {}), + await runDirectAnalysis( + cli.dependencies, + cli.path, + "binary_overview", + {}, + ).then(projectCliAnalysisResult), ); const fixture = await composedWorkflowFixture(); const server = createServer( diff --git a/tests/boundary/mcp/createServerCleanup.test.ts b/tests/boundary/mcp/createServerCleanup.test.ts new file mode 100644 index 0000000000..909b0fcd67 --- /dev/null +++ b/tests/boundary/mcp/createServerCleanup.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { BinarySession } from "../../../src/application/binary/BinarySession.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { SessionProviderRouter } from "../../../src/application/binary/SessionProviderRouter.js"; +import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js"; +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { createServer } from "../../../src/server/createServer.js"; + +afterEach(() => vi.restoreAllMocks()); + +it("preserves both shutdown failures and retries provider cleanup after onclose", async () => { + const transportFailure = new Error("transport cleanup failed"); + const providerFailure = new Error("browser cleanup failed"); + const transportClose = vi + .spyOn(EvidenceMcpServer.prototype, "close") + .mockImplementation(async function (this: EvidenceMcpServer) { + this.server.onclose?.(); + throw transportFailure; + }); + const browser = new CdpBrowserProvider(); + const providerClose = vi + .spyOn(browser, "close") + .mockRejectedValueOnce(providerFailure) + .mockResolvedValue(undefined); + const session = new BinarySession( + SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []), + new InvestigationRecords(), + ); + const server = createServer( + { kind: "session", session }, + { + browserObservation: browser, + }, + ); + const failure: unknown = await server + .close() + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AggregateError); + const causes = (cause: unknown): unknown[] => + cause instanceof AggregateError ? cause.errors.flatMap(causes) : [cause]; + expect(causes(failure)).toEqual([transportFailure, providerFailure]); + expect(providerClose).toHaveBeenCalledTimes(1); + transportClose.mockResolvedValue(undefined); + await expect(server.close()).resolves.toBeUndefined(); + expect(providerClose).toHaveBeenCalledTimes(2); + transportClose.mockRestore(); + await session.close(); +}); diff --git a/tests/boundary/mcp/evidenceImportCancellation.test.ts b/tests/boundary/mcp/evidenceImportCancellation.test.ts index 0e62746af1..cd1fe90b33 100644 --- a/tests/boundary/mcp/evidenceImportCancellation.test.ts +++ b/tests/boundary/mcp/evidenceImportCancellation.test.ts @@ -10,6 +10,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createServer } from "../../../src/server/createServer.js"; import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; import { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js"; +import * as evidenceBundleFiles from "../../../src/application/EvidenceBundleFiles.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -51,13 +52,13 @@ it("preserves the exact reason when import is cancelled after reading the bundle const reason = { request: "cancel after authenticated bundle read" }; const controller = new AbortController(); const before = session.exportEvidenceBundle(); - const snapshot = session.immutableEvidenceBundle.bind(session); - const snapshotRead = vi - .spyOn(session, "immutableEvidenceBundle") - .mockImplementationOnce(() => { - const bundle = snapshot(); + const read = evidenceBundleFiles.readImmutableEvidenceBundle; + const authenticatedRead = vi + .spyOn(evidenceBundleFiles, "readImmutableEvidenceBundle") + .mockImplementationOnce(async (...args) => { + const loaded = await read(...args); controller.abort(reason); - return bundle; + return loaded; }); const imported = vi.spyOn(session, "importEvidenceBundle"); const delivery = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult"); @@ -73,7 +74,7 @@ it("preserves the exact reason when import is cancelled after reading the bundle expect(response.isError).toBe(true); expect(delivered.error._tag).toBe("AnalysisCancelledError"); expect(delivered.error.cause).toBe(reason); - expect(snapshotRead).toHaveBeenCalledOnce(); + expect(authenticatedRead).toHaveBeenCalledOnce(); expect(imported).not.toHaveBeenCalled(); expect(session.exportEvidenceBundle()).toEqual(before); }); diff --git a/tests/boundary/mcp/evidenceImportLifecycle.test.ts b/tests/boundary/mcp/evidenceImportLifecycle.test.ts index f0371ef897..23ddbb531f 100644 --- a/tests/boundary/mcp/evidenceImportLifecycle.test.ts +++ b/tests/boundary/mcp/evidenceImportLifecycle.test.ts @@ -153,3 +153,61 @@ it("admits a later import after close_binary without retaining the earlier bundl ).toEqual(["after_close"]); await fixture.client.ping(); }); + +it("reports newly committed Unknown revisions and counts a repeated import once", async () => { + const root = await createTestTempDirectory("rea-mcp-import-counts-"); + const source = createTestBinarySession(() => { + throw new Error("Evidence import must not start a binary provider"); + }); + const unknown = source.recordUnknown({ + question: "Does this artifact have an unresolved dependency?", + severity: "medium", + domain: "investigation", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "analyst-inference", + required_confidence: "derived", + required_environment: null, + recommended_probes: [], + relationships: [], + }); + if (!unknown.ok) throw unknown.error; + const bundle = source.exportEvidenceBundle(); + const path = join(root, "unknowns.json"); + await writeFile(path, JSON.stringify(bundle)); + const session = createTestBinarySession(() => { + throw new Error("Evidence import must not start a binary provider"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "import-counts", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + await source.close(); + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + const first = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(first.isError).not.toBe(true); + expect(first.structuredContent).toEqual({ + result: { + imported: bundle.records.length, + unknowns_added: 1, + total: bundle.records.length, + }, + }); + const repeated = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(repeated.isError).not.toBe(true); + expect(repeated.structuredContent).toEqual({ + result: { imported: 0, unknowns_added: 0, total: bundle.records.length }, + }); +}); diff --git a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts index a8132588a9..8691ba8cce 100644 --- a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts +++ b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -86,7 +87,11 @@ it("advertises and executes Android and Apple inventory projections", async () = const root = await createTestTempDirectory("rea-mobile-graph-mcp-"); const apkInventory = parseEvidence( - await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}), + await runProviderAnalysis( + await writeApk(root), + "inventory_artifact", + {}, + ).then(projectCliAnalysisResult), ); const apkSources = fragmentInventoryEvidence(apkInventory, 2).toReversed(); const android = await client.callTool({ @@ -135,7 +140,11 @@ it("advertises and executes Android and Apple inventory projections", async () = ); const ipaInventory = parseEvidence( - await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}), + await runProviderAnalysis( + await writeIpa(root), + "inventory_artifact", + {}, + ).then(projectCliAnalysisResult), ); const apple = await client.callTool({ name: "project_apple_application_graph", @@ -152,7 +161,11 @@ it("projects a single inventory page as partial and links only that source", asy const client = await connect(); const root = await createTestTempDirectory("rea-mobile-partial-page-mcp-"); const inventory = parseEvidence( - await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}), + await runProviderAnalysis( + await writeApk(root), + "inventory_artifact", + {}, + ).then(projectCliAnalysisResult), ); const facts = artifactInventoryResultSchema.parse( inventory.normalized_result, @@ -271,10 +284,18 @@ it("rejects authentic pages from different roots without retaining either", asyn const client = await connect(); const root = await createTestTempDirectory("rea-mobile-mixed-pages-mcp-"); const apk = parseEvidence( - await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}), + await runProviderAnalysis( + await writeApk(root), + "inventory_artifact", + {}, + ).then(projectCliAnalysisResult), ); const ipa = parseEvidence( - await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}), + await runProviderAnalysis( + await writeIpa(root), + "inventory_artifact", + {}, + ).then(projectCliAnalysisResult), ); const rejected = await client.callTool({ @@ -337,7 +358,9 @@ it.each([ }); await writeFile(path, await writer.close()); const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); const reply = await client.callTool({ name: operation, diff --git a/tests/boundary/mcp/officialArguments.test.ts b/tests/boundary/mcp/officialArguments.test.ts index 8621f13ce6..7f3caae7cd 100644 --- a/tests/boundary/mcp/officialArguments.test.ts +++ b/tests/boundary/mcp/officialArguments.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it, onTestFinished } from "vitest"; @@ -86,7 +87,9 @@ describe("official operation arguments", () => { ], ] as const) { const cli = parseEvidence( - await runDirectAnalysis(dependencies, path, name, cliParameters), + await runDirectAnalysis(dependencies, path, name, cliParameters).then( + projectCliAnalysisResult, + ), ); const mcp = await client.callTool({ name, arguments: mcpParameters }); expect(mcp.isError).not.toBe(true); diff --git a/tests/boundary/mcp/outputSchemaProfile.test.ts b/tests/boundary/mcp/outputSchemaProfile.test.ts index b3277d3262..943efdd533 100644 --- a/tests/boundary/mcp/outputSchemaProfile.test.ts +++ b/tests/boundary/mcp/outputSchemaProfile.test.ts @@ -17,6 +17,7 @@ import type { ToolContract } from "../../../src/contracts/toolContractTypes.js"; import { FULL_CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; import type { McpOutputSchemaProfile } from "../../../src/config/mcpOutputSchemaProfile.js"; import { advertisedSchemaErrors } from "../../fixtures/mcpAdvertisedSchemaValidity.js"; +import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; const createServer = ( outputSchemaProfile: McpOutputSchemaProfile, @@ -183,6 +184,12 @@ it("keeps the default compact-input catalog below a measured one-megabyte frame" const compactInput: CompactInputSchemaPresentation = { budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES, }; + const canonicalInputs = new Map>( + TOOL_CONTRACTS.map((contract) => [ + contract.name, + advertisedInputJsonSchema(contract), + ]), + ); const measure = async ( profile: McpOutputSchemaProfile, inputPresentation: CompactInputSchemaPresentation | undefined, @@ -203,6 +210,19 @@ it("keeps the default compact-input catalog below a measured one-megabyte frame" await client.connect(clientTransport); try { const listed = await client.listTools(); + expect(listed.tools).toHaveLength(canonicalInputs.size); + expect(new Set(listed.tools.map((tool) => tool.name))).toEqual( + new Set(canonicalInputs.keys()), + ); + if (inputPresentation === undefined) + for (const tool of listed.tools) + expect(tool.inputSchema, tool.name).toEqual( + canonicalInputs.get(tool.name), + ); + if (profile === "off") + expect( + listed.tools.every((tool) => tool.outputSchema === undefined), + ).toBe(true); if (profile === "compact") { // Validate the advertised documents as the client receives them, not // just their bytes: a projection that broke a reference or left an @@ -237,7 +257,10 @@ it("keeps the default compact-input catalog below a measured one-megabyte frame" expect(fullBytes).toBeGreaterThan(1024 * 1024); expect(compactBytes).toBeLessThan(1024 * 1024); expect(offBytes).toBeLessThan(1024 * 1024); - expect(fullInputOffBytes).toBeLessThan(1024 * 1024); + // Full input is an explicit guidance choice, not the bounded compatibility + // profile. Omitting output schemas must preserve those complete inputs. + expect(fullInputOffBytes).toBeGreaterThan(offBytes); + expect(fullInputOffBytes).toBeLessThan(fullBytes); expect(compactBytes).toBeLessThan(fullBytes); expect(compactBytes).toBeGreaterThan(offBytes); }); diff --git a/tests/boundary/mcp/processCleanupRetryMcp.test.ts b/tests/boundary/mcp/processCleanupRetryMcp.test.ts index fcfa70a581..0f3f6a5cb1 100644 --- a/tests/boundary/mcp/processCleanupRetryMcp.test.ts +++ b/tests/boundary/mcp/processCleanupRetryMcp.test.ts @@ -121,7 +121,7 @@ it("projects the retained process root through close_binary and resolves its ret const firstWire = JSON.stringify(failed); expect(failed.error).toMatchObject({ code: "cleanup_incomplete", - details: { resources: ["temporary_root"] }, + details: { resources: [root] }, }); expect(fault.removalAttempts).toBe(2); expect(fault.retryFailure).toBeInstanceOf(ProcessCaptureError); @@ -129,7 +129,7 @@ it("projects the retained process root through close_binary and resolves its ret throw new Error("Expected the process scope's typed retry error"); const retryError = fault.retryFailure; expect(retryError.cleanupIncomplete).toBe(true); - expect(retryError.cleanupResources).toEqual(["temporary_root"]); + expect(retryError.cleanupResources).toEqual([root]); denyRemoval = false; const recovered = await callClose(); @@ -185,7 +185,7 @@ it.each(["returned", "thrown", "synchronous"] as const)( expect(failed.error).toMatchObject({ code: "cleanup_incomplete", details: { - resources: ["temporary_root"], + resources: [root], diagnostics: { primary_error: { details: { @@ -207,7 +207,9 @@ it.each(["returned", "thrown", "synchronous"] as const)( throw new Error("Expected the process scope's typed retry error"); const retryError = fault.retryFailure; expect(retryError.cleanupIncomplete).toBe(true); + expect(retryError.cleanupResources).toEqual([root]); expect(composed.cleanupIncomplete).toBe(true); + expect(composed.cleanupResources).toEqual([root]); denyRemoval = false; const recovered = parseMcpToolError(await callClose()); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index b68e4a2a37..5a9d0278cd 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -1,3 +1,6 @@ +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { ProcessCaptureResourceScope } from "../../../src/process/capture/ProcessCaptureLifecycle.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { readFile, realpath, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -38,7 +41,7 @@ import { toolAvailabilitySummaryInventory, } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; -import { ok as resultOk } from "../../../src/domain/result.js"; +import { err, ok as resultOk } from "../../../src/domain/result.js"; import { createSessionMcpHarness, snapshotAndRecordUnknown, @@ -430,6 +433,89 @@ describe("target-free MCP lifecycle", () => { }, 30_000); }); +describe("snapshot receipt after MCP cleanup", () => { + it.each([false, true])( + "retains the saved snapshot receipt when process cleanup fails (session failure=%s)", + async (sessionFailure) => { + directory = await createTestTempDirectory( + "rea-mcp-snapshot-process-cleanup-", + ); + const cleanupObservation = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "health", parameters: {}, result: "cleanup facts" }, + ); + const { mcp, first } = await createSessionMcpHarness( + directory, + (closed) => { + const base = provider(closed); + return { + ...base, + createClient: (target, options) => { + const active = base.createClient(target, options); + let failed = false; + return { + ...active, + close: async () => { + if (sessionFailure && !failed) { + failed = true; + return err( + new ProviderCleanupError( + "fixture", + ["fixture child"], + {}, + { partialObservation: cleanupObservation }, + ), + ); + } + return active.close(); + }, + }; + }, + }; + }, + resources, + ); + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: first }, + }); + expect(opened.isError).not.toBe(true); + expect( + (await mcp.callTool({ name: "current_document", arguments: {} })) + .isError, + ).not.toBe(true); + const cleanup = vi + .spyOn(ProcessCaptureResourceScope.prototype, "retryCleanup") + .mockRejectedValueOnce(new Error("Process cleanup failed")); + const path = join(directory, "saved.json"); + const result = await mcp.callTool({ + name: "close_binary", + arguments: { snapshot_path: path }, + }); + expect(result.isError).toBe(true); + const committed = await readFile(path); + expect( + parseMcpToolError(result).error.details?.partial_observation, + ).toEqual({ + saved_snapshot: { + path, + bytes: committed.byteLength, + primitive_entries: 0, + workflow_entries: 0, + evidence_records: 1, + }, + ...(sessionFailure ? { cleanup_observation: cleanupObservation } : {}), + }); + cleanup.mockRestore(); + expect( + (await mcp.callTool({ name: "close_binary", arguments: {} })).isError, + ).not.toBe(true); + expect((await readFile(path)).equals(committed)).toBe(true); + }, + ); +}); + describe("target-free MCP workflow", () => { it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => { directory = await createTestTempDirectory("rea-mcp-session-"); diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index 6e55d22583..ea3f81d79a 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -220,6 +220,88 @@ function resolveReference(schema: unknown, reference: string): unknown { return value; } +/** Follow SDK-shared local definitions at each schema path component. */ +function schemaAt( + root: unknown, + path: readonly string[], + node: unknown = root, +): unknown { + const resolve = (value: unknown): unknown => { + while (isRecord(value) && typeof value.$ref === "string") + value = resolveReference(root, value.$ref); + return value; + }; + for (const key of path) { + node = resolve(node); + if (!isRecord(node)) return undefined; + node = node[key]; + } + return resolve(node); +} + +/** Validate retained locals facts through the output schema the SDK actually advertised. */ +function expectManagedLocalSignatureSchema( + tool: ToolSchemas | undefined, +): void { + const managedOutput = tool?.outputSchema; + if (managedOutput === undefined) + throw new Error("Missing managed member output schema"); + const managedAlternatives = schemaAt(managedOutput, [ + "properties", + "normalized_result", + "anyOf", + ]); + if (!Array.isArray(managedAlternatives)) + throw new Error("Missing managed result alternatives"); + const inventory = managedAlternatives.find( + (alternative: unknown) => + schemaAt(managedOutput, ["properties", "methods"], alternative) !== + undefined, + ); + const locals = schemaAt( + managedOutput, + [ + "properties", + "methods", + "items", + "properties", + "body", + "properties", + "local_var_signature", + ], + inventory, + ); + if (!isRecord(locals)) + throw new Error("Missing advertised local signature facts"); + const validateLocals = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile({ + ...locals, + $defs: managedOutput.$defs, + }); + expect(validateLocals(null)).toBe(true); + expect( + validateLocals({ + raw_sha256: "a".repeat(64), + has_context_dependent_types: false, + }), + ).toBe(true); + expect( + validateLocals({ + raw_sha256: "a".repeat(64), + has_context_dependent_types: true, + }), + ).toBe(true); + expect( + validateLocals({ + raw_sha256: "a".repeat(64), + has_context_dependent_types: "false", + }), + ).toBe(false); + expect(validateLocals({ has_context_dependent_types: false })).toBe(false); +} + function recursiveReferences(tools: readonly ToolSchemas[]): string[] { return tools.flatMap((tool) => (["inputSchema", "outputSchema"] as const).flatMap((kind) => { @@ -499,6 +581,7 @@ describe("MCP JSON Schema validity", () => { expect(recursiveReferences(tools)).toEqual([]); expectKnownAuthorityHints(tools); const byName = new Map(tools.map((tool) => [tool.name, tool])); + expectManagedLocalSignatureSchema(byName.get("inspect_managed_members")); for (const side of ["before", "after"]) expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty( [ diff --git a/tests/boundary/mcp/transportCleanup.test.ts b/tests/boundary/mcp/transportCleanup.test.ts new file mode 100644 index 0000000000..0b7247b2a2 --- /dev/null +++ b/tests/boundary/mcp/transportCleanup.test.ts @@ -0,0 +1,74 @@ +import { expect, it, vi } from "vitest"; +import { createManagedBinarySession } from "../../../src/composition/binary.js"; +import { silentLogger } from "../../../src/logger.js"; +import { startMcpTransport } from "../../../src/main/transport.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; + +it("preserves Android sibling cleanup failures and retries only unfinished owners", async () => { + const session = createManagedBinarySession(); + const failures = [ + new Error("first Android cleanup"), + new Error("second Android cleanup"), + ]; + const closes: ReturnType[] = []; + let fail = true; + const transport = await startMcpTransport( + { + env: {}, + serve: async (factory) => { + // The SDK can create a discovery probe followed by replacement servers. + await factory({ era: "modern" }); + await factory({ era: "legacy" }); + await factory({ era: "modern" }); + return { close: async () => undefined }; + }, + createServer: (source, options) => { + const provider = options?.androidAnalysis; + if (provider === undefined) + throw new Error("Missing owned Android provider"); + const failure = failures[closes.length]; + closes.push( + vi.spyOn(provider, "close").mockImplementation(async () => { + if (fail && failure !== undefined) throw failure; + }), + ); + return createServer(source, options); + }, + writeStderr: () => undefined, + setExitCode: () => undefined, + registerShutdown: () => () => undefined, + }, + session, + { + logger: silentLogger, + serverLogger: silentLogger, + environment: {}, + delivery: createToolResultDelivery(undefined), + loadOptionalProviders: async () => ({}), + }, + ); + try { + expect(transport.ok).toBe(true); + await expect(transport.closeAndroid()).rejects.toMatchObject({ + name: "AggregateError", + errors: failures, + }); + expect(closes.map((close) => close.mock.calls.length)).toEqual([1, 1, 1]); + // One recovered owner leaves the exact remaining failure available. + closes[0]?.mockResolvedValue(undefined); + await expect(transport.closeAndroid()).rejects.toBe(failures[1]); + expect(closes.map((close) => close.mock.calls.length)).toEqual([2, 2, 1]); + fail = false; + await transport.closeAndroid(); + await transport.closeAndroid(); + expect(closes.map((close) => close.mock.calls.length)).toEqual([2, 3, 1]); + } finally { + fail = false; + for (const close of closes) close.mockRestore(); + await transport.handle.close(); + await transport.closeAndroid(); + await transport.closeOptionalProviders(); + await session.close(); + } +}); diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 3fcd36b5a8..3739230002 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { access, chmod, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -341,7 +342,7 @@ describe("MCP composed workflow snapshot replay", () => { "binary_overview", {}, { snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(output).not.toHaveProperty("caller_input_path"); expect(replay).toEqual({ ...output, caller_input_path: targetPath }); @@ -642,9 +643,7 @@ const makeProvider = ( ] : operation === "list_documents" ? ["fixture"] - : operation === "list_procedures" - ? ["0x1000"] - : [{ address: "0x1000", value: "fixture" }]; + : [{ address: "0x1000", value: "fixture" }]; return ok(createAnalysisExecution(result, identity)); }, close: () => Promise.resolve(ok(null)), diff --git a/tests/boundary/pcap/pcapRetainedView.test.ts b/tests/boundary/pcap/pcapRetainedView.test.ts index ace6bf09f9..58ff0ecec1 100644 --- a/tests/boundary/pcap/pcapRetainedView.test.ts +++ b/tests/boundary/pcap/pcapRetainedView.test.ts @@ -29,6 +29,15 @@ const createPcapToolFixture = async (root: string) => { "dns.qry.name": ["many.example"], "dns.flags.response": ["True"], "dns.a": [`198.51.100.${index}`], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["many.example"], + "dns.resp.type": ["1"], + "dns.resp.class": ["0x0001"], "tls.handshake.extensions_server_name": [`host${index}.example`], "http.request.full_uri": [ "http://user:password@example.invalid/p?q=keep", diff --git a/tests/boundary/process/processCli.test.ts b/tests/boundary/process/processCli.test.ts index d73106a3c0..b2e068699c 100644 --- a/tests/boundary/process/processCli.test.ts +++ b/tests/boundary/process/processCli.test.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,6 +17,7 @@ import { import { createEvidence } from "../../../src/domain/evidence.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; +import { observeProcessStartIdentity } from "../../../src/process/ProcessOwnershipObservation.js"; import { analysisCliErrorEnvelopeSchema, analysisErrorProjectionSchema, @@ -76,6 +77,66 @@ describe("documented process CLI workflow", () => { ); }); +describe("process CLI cleanup errors", () => { + cliTest.skipIf(process.platform !== "darwin")( + "reports the retained capture root when platform-binary finalization cannot establish ownership", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-process-cli-cleanup-"); + const scenario = join(root, "scenario.json"); + const marker = join(root, "target.pid"); + await writeFile( + scenario, + JSON.stringify({ + executable: "/bin/sh", + arguments: [ + "-c", + 'printf "%s" "$$" > "$1"; exec /bin/sleep 3', + "capture-cleanup-fixture", + marker, + ], + timeout_ms: 500, + idle_timeout_ms: 500, + finalization_ms: 100, + }), + ); + const result = await cli.run({ + arguments: ["capture-process", scenario, "--json"], + environment: { TMPDIR: root }, + }); + const targetPid = Number(await readFile(marker, "utf8")); + expect(Number.isSafeInteger(targetPid) && targetPid > 0).toBe(true); + // The fail-closed command deliberately leaves the platform binary alive. + // Observe its bounded natural exit before the temporary fixture is removed. + await expect + .poll(() => observeProcessStartIdentity(targetPid), { + timeout: 7_000, + interval: 25, + }) + .toBeUndefined(); + expect(result.exitCode, result.stdout || result.stderr).toBe(1); + const retained = (await readdir(root)).filter((name) => + /^rea-process-(?!token-)/u.test(name), + ); + expect(retained).toHaveLength(1); + const retainedRoot = retained[0]; + if (retainedRoot === undefined) throw new Error("Missing retained root"); + expect(result.json).toMatchObject({ + code: "cleanup_incomplete", + message: expect.stringContaining( + "platform_binary_environment_withheld", + ), + details: { + resources: ["owned_process_group", join(root, retainedRoot)], + cleanup_report: { + temporary_root: { state: "unverified" }, + }, + }, + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); +}); + describe("process CLI errors", () => { cliTest( "returns canonical JSON errors for missing and malformed process files", diff --git a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts index 1772af643c..d3166d4bc1 100644 --- a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts +++ b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts @@ -629,3 +629,33 @@ it }, 20_000, ); + +itWithCaptureCapability( + "retains raw symlink target mutations through the real capture CLI", + { timeout: 20_000 }, + async () => { + const root = await createTestTempDirectory("rea-symlink-byte-cli-"); + await symlink(Buffer.from([0x61, 0xff]), join(root, "link")); + const evidence = await captureViaCli({ + executable: process.execPath, + arguments: [ + "-e", + "const fs=require('node:fs');fs.unlinkSync('link');fs.symlinkSync(Buffer.from([0x61,0xfe]),'link')", + ], + working_directory: root, + filesystem_observation_paths: [root], + timeout_ms: 5000, + }); + const capture = parseProcessCapture(evidence.normalized_result); + expect( + capture.files_before.find(({ path }) => path === "root_0:link"), + ).toMatchObject({ symlink_target: null, symlink_target_hex: "61ff" }); + expect( + capture.files_after.find(({ path }) => path === "root_0:link"), + ).toMatchObject({ symlink_target: null, symlink_target_hex: "61fe" }); + expect( + capture.filesystem_effects.find(({ path }) => path === "root_0:link") + ?.status, + ).toBe("modified"); + }, +); diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index 539893a940..1b6a139ead 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -247,6 +247,63 @@ it.skipIf(process.platform === "win32").each(["discovery", "command"] as const)( }, ); +describe("CutterBridgeClient: selected POSIX paths", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + it + .skipIf(process.platform === "win32") + .each(["explicit", "tilde", "default"] as const)( + "discovers descriptors through symlink parent traversal in %s directory selection", + async (selection) => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-selected-path-")); + const home = `${directory}/home`; + const target = `${directory}/actual`; + await mkdir(home); + await mkdir(`${target}/child`, { recursive: true }); + await symlink(`${target}/child`, `${home}/linked`); + const selectedHome = `${home}/linked/..`; + const suffix = + selection === "default" ? ".cache/rea/cutter-bridge" : "bridge"; + const actualDirectory = `${target}/${suffix}`; + await mkdir(actualDirectory, { recursive: true, mode: 0o700 }); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-selected-path-token"; + server = statusServer(token, "/tmp/selected.bin", 7); + const port = await listen(server); + await writeDescriptor(actualDirectory, sessionId, port, token); + const selectedDirectory = `${selectedHome}/${suffix}`; + const client = createClient({ + HOME: selection === "tilde" ? home : selectedHome, + ...(selection === "default" + ? {} + : { + REA_CUTTER_BRIDGE_DIR: + selection === "tilde" + ? "~/linked/../bridge" + : selectedDirectory, + }), + }); + try { + const result = await client.listSessions(); + expect(result.bridge_directory).toBe(selectedDirectory); + expectDiscoveredSession(result, sessionId, 7, "/tmp/selected.bin"); + } finally { + await client.close(); + } + }, + ); +}); + describe("CutterBridgeClient: scenario group 1", () => { let directory: string | undefined; let server: ReturnType | undefined; diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index b10f21b6d0..fb1005e952 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -438,6 +438,35 @@ it("keeps a consumed startup failure primary when caller abort arrives during cl }); }); +it("retains failed startup ownership when stop rejects and resolves it on retry", async () => { + const fake = createFakeSpawner((child, token) => { + child.stdout.write(`${token}^error,msg="configuration rejected"\n`); + }); + let recovered = false; + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async (session, name) => { + if (!recovered) throw new Error("startup stop rejected"); + return stopOwnedDebuggerProcess(session, name); + }, + }); + const started = await manager.start(); + expect(started).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cause: { _tag: "AnalysisCapabilityUnavailableError" }, + message: expect.stringContaining("configuration"), + }, + }); + if (started.ok) return; + expect(started.error.cleanupResources).toHaveLength(1); + expect((await manager.retryCleanupFailures()).ok).toBe(false); + recovered = true; + expect((await manager.retryCleanupFailures()).ok).toBe(true); + expect(started.error.cleanupIncomplete).toBe(false); + expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); +}); + it("cancels startup when aborted during the final readiness drain", async () => { const controller = new AbortController(); const reason = "caller cancelled during readiness drain"; @@ -3023,7 +3052,10 @@ it("retains unknown inferior ownership after GDB exits during a command", async error: { cleanupIncomplete: true, partialObservation: { + parameters: { command: "exit during command" }, + raw_result: { command: "exit during command" }, normalized_result: { + command: "exit during command", process_exit_observed: true, completion_status: "unknown", }, @@ -3182,36 +3214,46 @@ it("retains caller cancellation and cleanup authority after active-command stop expect(stopCalls).toBe(2); }); -it("returns a tagged cleanup failure thrown while closing", async () => { - const fake = createFakeSpawner(); - const manager = managerWithFakeSpawner(fake.spawnProcess, { - stopProcess: async () => { - throw new AnalysisTimeoutError("close_gdb_session", 17); - }, - }); - const started = await expectGdbSessionStarted(manager); - - const closed = await manager.close(started.value.session_id); - - expect(closed).toMatchObject({ - ok: false, - error: { _tag: "AnalysisTimeoutError", operation: "close_gdb_session" }, - }); -}); - -it("rethrows an unexpected non-domain cleanup failure while closing", async () => { - const fake = createFakeSpawner(); - const manager = managerWithFakeSpawner(fake.spawnProcess, { - stopProcess: async () => { - throw new Error("unexpected cleanup failure"); - }, - }); - const started = await expectGdbSessionStarted(manager); - - await expect(manager.close(started.value.session_id)).rejects.toThrow( - "unexpected cleanup failure", - ); -}); +it.each(["tagged", "unexpected"] as const)( + "retains %s stop failures while closing and resolves them after retry", + async (failureKind) => { + const fake = createFakeSpawner(); + let recovered = false; + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: () => { + if (recovered) { + fake.children[0]?.exit(0, null); + return Promise.resolve({ status: "stopped" }); + } + // Include synchronous throws at this injected provider boundary. + throw failureKind === "tagged" + ? new AnalysisTimeoutError("foreign_cleanup", 17) + : new Error("unexpected cleanup failure"); + }, + }); + const started = await expectGdbSessionStarted(manager); + const closed = await manager.close(started.value.session_id); + expect(closed).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + operation: "close_gdb_session", + cleanupIncomplete: true, + cleanupResources: [started.value.session_id], + message: expect.stringContaining( + failureKind === "tagged" + ? "foreign_cleanup" + : "unexpected cleanup failure", + ), + }, + }); + expect((await manager.retryCleanupFailures()).ok).toBe(false); + recovered = true; + expect((await manager.retryCleanupFailures()).ok).toBe(true); + expect(manager.status(started.value.session_id)).toBeUndefined(); + if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(false); + }, +); it("returns partial command evidence when the active MI request times out after stop", async () => { const fake = createFakeSpawner((_child, token, command) => { @@ -3573,68 +3615,86 @@ it("completes an MI record split after a chunk without a newline", async () => { expect(result.value.result.records).toContain('=thread-created,id="1"'); }); -it("preserves the protocol failure when an incomplete stop adds uncertainty", async () => { - let stopCalls = 0; - const fake = createFakeSpawner(); - const manager = managerWithFakeSpawner(fake.spawnProcess, { - stopProcess: async (session, name) => { - stopCalls += 1; - if (stopCalls === 1) { - return { - status: "incomplete", - reason: "simulated protocol stop uncertainty", - }; - } - return stopOwnedDebuggerProcess(session, name); - }, - }); - const started = await expectGdbSessionStarted(manager); - const stdin = fake.children[0]?.stdin; - if (stdin === null || stdin === undefined) return; - Object.defineProperty(stdin, "write", { - value: ( - _chunk: string, - _encoding: BufferEncoding, - callback: (error?: Error | null) => void, - ): boolean => { - fake.children[0]?.stdout.write('~"protocol output before failure"\n'); - callback(new Error("simulated protocol write failure")); - return false; - }, - }); - - const failed = await manager.execute(started.value.session_id, "will-fail"); - - expect(failed.ok).toBe(false); - if (!failed.ok) { - expect(failed.error.message).toContain( - "GDB stdin write failed: simulated protocol write failure", - ); - expect(failed.error.message).toContain( - "simulated protocol stop uncertainty", - ); - expect(failed.error.cleanup).toEqual({ - reason: "simulated protocol stop uncertainty", - resources: [started.value.session_id], - }); - expect(failed.error.partialObservation).toMatchObject({ - predicate_type: "rea.debugger.console-observation", - raw_result: { - records: ['~"protocol output before failure"'], - completion_status: "unknown", +it.each(["incomplete", "rejected"] as const)( + "preserves the protocol failure and cleanup owner when stop is %s", + async (stopOutcome) => { + let stopCalls = 0; + const fake = createFakeSpawner(); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) { + if (stopOutcome === "rejected") + throw new Error("simulated protocol stop uncertainty"); + return { + status: "incomplete", + reason: "simulated protocol stop uncertainty", + }; + } + if (stopCalls === 3) { + // Simulate later owner verification after recovery completed. + fake.children[0]?.exit(0, null); + return { status: "stopped" }; + } + return stopOwnedDebuggerProcess(session, name); }, }); - } - expect(manager.status(started.value.session_id)?.state).toBe("closed"); - const subsequent = await manager.execute( - started.value.session_id, - "must-not-run", - ); - expect(subsequent).toMatchObject({ - ok: false, - error: { _tag: "AnalysisInputError" }, - }); -}); + const started = await expectGdbSessionStarted(manager); + const stdin = fake.children[0]?.stdin; + if (stdin === null || stdin === undefined) return; + Object.defineProperty(stdin, "write", { + value: ( + _chunk: string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): boolean => { + fake.children[0]?.stdout.write('~"protocol output before failure"\n'); + callback(new Error("simulated protocol write failure")); + return false; + }, + }); + + const failed = await manager.execute(started.value.session_id, "will-fail"); + + expect(failed.ok).toBe(false); + if (!failed.ok) { + expect(failed.error.message).toContain( + "GDB stdin write failed: simulated protocol write failure", + ); + expect(failed.error.message).toContain( + "simulated protocol stop uncertainty", + ); + expect(failed.error.cleanup).toEqual({ + reason: expect.stringContaining("simulated protocol stop uncertainty"), + resources: [started.value.session_id], + }); + expect(failed.error.partialObservation).toMatchObject({ + predicate_type: "rea.debugger.console-observation", + raw_result: { + command: "will-fail", + records: ['~"protocol output before failure"'], + completion_status: "unknown", + }, + }); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + const subsequent = await manager.execute( + started.value.session_id, + "must-not-run", + ); + expect(subsequent).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect((await manager.retryCleanupFailures()).ok).toBe(false); + expect(stopCalls).toBe(2); + if (!failed.ok) expect(failed.error.cleanupIncomplete).toBe(true); + expect((await manager.retryCleanupFailures()).ok).toBe(true); + expect(stopCalls).toBe(3); + expect(manager.status(started.value.session_id)).toBeUndefined(); + if (!failed.ok) expect(failed.error.cleanupIncomplete).toBe(false); + }, +); it("drains buffered startup stdout before capturing early-exit Evidence", async () => { const children: FakeGdbProcess[] = []; diff --git a/tests/boundary/providers/RizinDebugSessionManagers.test.ts b/tests/boundary/providers/RizinDebugSessionManagers.test.ts index 3ccc558779..ea1d90a9d4 100644 --- a/tests/boundary/providers/RizinDebugSessionManagers.test.ts +++ b/tests/boundary/providers/RizinDebugSessionManagers.test.ts @@ -4,10 +4,12 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { evidenceSchema } from "../../../src/domain/evidence.js"; import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; import { createRizinTestManager, rizinTestInferiorTty, + stopFakeNoInferior, } from "./RizinTestInferiorTty.js"; describe.skipIf(process.platform === "win32")( @@ -97,13 +99,34 @@ process.stdin.on("data", (chunk) => { expect((await manager.close(started.value.session_id)).ok).toBe(true); expect(manager.status(started.value.session_id)).toBeUndefined(); }); + }, +); - it("stops and invalidates a Rizin session after its completion-marker timeout", async () => { - directory = await mkdtemp(join(tmpdir(), "rea-rizin-timeout-")); - const provider = join(directory, "fake-rizin-stall.mjs"); - await writeFile( - provider, - `#!/usr/bin/env node +describe.skipIf(process.platform === "win32")( + "Rizin debugger timeout cleanup", + () => { + let directory: string | undefined; + const managers: Array<{ closeAll(): Promise }> = []; + + afterEach(async () => { + await Promise.all( + managers + .splice(0) + .map((manager) => manager.closeAll().catch(() => undefined)), + ); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; + }); + + it.each(["stopped", "throw", "reject"] as const)( + "retains timeout output and retryable cleanup when stop outcome is %s", + async (stopOutcome) => { + directory = await mkdtemp(join(tmpdir(), "rea-rizin-timeout-")); + const provider = join(directory, "fake-rizin-stall.mjs"); + await writeFile( + provider, + `#!/usr/bin/env node process.stdout.write("\\0"); let input = ""; let stalled = false; @@ -124,38 +147,65 @@ process.stdin.on("data", (chunk) => { } }); `, - ); - await chmod(provider, 0o700); - const manager = createRizinTestManager( - { REA_RIZIN_COMMAND: provider }, - { frameTimeoutMs: 50 }, - ); - managers.push(manager); - - const started = await manager.start({ - path: "/tmp/fixture.bin", - inferior_tty: await rizinTestInferiorTty(), - }); - expect(started.ok).toBe(true); - if (!started.ok) return; - const timedOut = await manager.execute(started.value.session_id, "slow"); - expect(timedOut.ok).toBe(true); - if (timedOut.ok) { - expect(timedOut.value.result).toMatchObject({ - output: "partial-timeout", - output_truncated: true, - completion_status: "unknown", - }); - expect(timedOut.value.evidence.limitations.join(" ")).toContain( - "timed out", ); - } - expect(manager.status(started.value.session_id)?.state).toBe("closed"); - expect((await manager.execute(started.value.session_id, "dr")).ok).toBe( - false, - ); - expect((await manager.close(started.value.session_id)).ok).toBe(true); - }); + await chmod(provider, 0o700); + let stopRejected = false; + const manager = createRizinTestManager( + { REA_RIZIN_COMMAND: provider }, + { + frameTimeoutMs: 50, + stopProcess: (session, name, options) => { + if (stopOutcome !== "stopped" && !stopRejected) { + stopRejected = true; + if (stopOutcome === "throw") throw new Error("stop failed"); + return Promise.reject("stop failed"); + } + return stopFakeNoInferior(session, name, options); + }, + }, + ); + managers.push(manager); + + const started = await manager.start({ + path: "/tmp/fixture.bin", + inferior_tty: await rizinTestInferiorTty(), + }); + expect(started.ok).toBe(true); + if (!started.ok) return; + const timedOut = await manager.execute( + started.value.session_id, + "slow", + ); + expect(timedOut.ok).toBe(stopOutcome === "stopped"); + if (!timedOut.ok) { + expect(timedOut.error.cleanupIncomplete).toBe(true); + const partialEvidence = evidenceSchema.parse( + timedOut.error.partialObservation, + ); + expect(partialEvidence.normalized_result).toMatchObject({ + output: "partial-timeout", + completion_status: "unknown", + }); + expect(timedOut.error.cleanup?.reason).toContain("stop failed"); + } + if (timedOut.ok) { + expect(timedOut.value.result).toMatchObject({ + output: "partial-timeout", + output_truncated: true, + completion_status: "unknown", + }); + expect(timedOut.value.evidence.limitations.join(" ")).toContain( + "timed out", + ); + } + expect(manager.status(started.value.session_id)?.state).toBe("closed"); + expect((await manager.execute(started.value.session_id, "dr")).ok).toBe( + false, + ); + expect((await manager.close(started.value.session_id)).ok).toBe(true); + if (!timedOut.ok) expect(timedOut.error.cleanupIncomplete).toBe(false); + }, + ); }, ); diff --git a/tests/boundary/providers/bundleResourceComposition.test.ts b/tests/boundary/providers/bundleResourceComposition.test.ts index 448c04e1df..521aae94ba 100644 --- a/tests/boundary/providers/bundleResourceComposition.test.ts +++ b/tests/boundary/providers/bundleResourceComposition.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -59,7 +60,7 @@ it("routes a CLI app resource path through the resource session and preserves nu bundlePath, "inspect_keyed_archive", { path: "Resources/Archive.plist" }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ operation: "inspect_keyed_archive", subject: null, diff --git a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts index 3167be3aa6..a0102a0954 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts @@ -15,6 +15,7 @@ export const ghidraFixturePath = fileURLToPath( ); type FixtureMode = + | "unicode_search" | "reject_first_connection" | "reject_all_connections" | "success" diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 2242043fb8..104fbcccac 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -2481,3 +2481,48 @@ it("preserves a producer failure while its result waits for the startup deadline error: { kind: "start", cause: physical }, }); }); + +it.each(["search_procedures", "search_strings"] as const)( + "%s retains native candidate rows for normal and ready-only dispatch", + async (operation) => { + const client = clientFor(new FixtureLauncher("unicode_search")); + const query = { + pattern: "source", + mode: "literal", + case_sensitive: false, + document: null, + }; + const expected = { + ok: true, + value: [ + { address: "0x1000", value: "ſource" }, + { address: "0x1001", value: "SOURCE" }, + { address: "0x1002", value: "İource" }, + { address: "0x1003", value: "unrelated" }, + ], + }; + await expect(client.callTool(operation, query)).resolves.toEqual(expected); + await expect(client.callToolIfReady(operation, query)).resolves.toEqual( + expected, + ); + for (const pattern of ["ſOURCE", "Σ"]) { + await expect( + client.callTool(operation, { ...query, pattern }), + ).resolves.toEqual(expected); + await expect( + client.callToolIfReady(operation, { ...query, pattern }), + ).resolves.toEqual(expected); + } + const regex = await client.callTool(operation, { ...query, mode: "regex" }); + expect(regex.ok && Array.isArray(regex.value) && regex.value.length).toBe( + 4, + ); + const sensitive = await client.callTool(operation, { + ...query, + case_sensitive: true, + }); + expect( + sensitive.ok && Array.isArray(sensitive.value) && sensitive.value.length, + ).toBe(4); + }, +); diff --git a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts index d1d7ab548f..972f3f1e8c 100644 --- a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts @@ -1,4 +1,14 @@ -import { mkdir, readFile, readdir, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + rm, + mkdir, + readFile, + readdir, + realpath, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, expect, it } from "vitest"; import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; @@ -170,3 +180,96 @@ it.each([null, "caller stopped", { kind: "stop-project-read" }])( ).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason }); }, ); + +it.skipIf(process.platform === "win32")( + "rejects a selected marker with a non-directory traversal before canonicalizing its parent", + async () => { + const root = await createTestTempDirectory("rea-ghidra-project-traversal-"); + await writeFile(join(root, "file"), "not a directory"); + await writeFile(join(root, "project.gpr"), "marker"); + await mkdir(join(root, "project.rep")); + const path = `${root}/file/../project.gpr`; + await expect(stat(path)).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect( + resolveGhidraProject({ path }, { path: join(root, "target") }, resources), + ).rejects.toMatchObject({ diagnostics: { system_code: "ENOTDIR" } }); + }, +); + +it.skipIf(process.platform === "win32")( + "follows native parent symlinks and preserves missing marker failures", + async () => { + const root = await createTestTempDirectory("rea-ghidra-project-selection-"); + await mkdir(join(root, "actual", "nested"), { recursive: true }); + await symlink("actual/nested", join(root, "link")); + await writeFile(join(root, "actual", "project.gpr"), "marker"); + await mkdir(join(root, "actual", "project.rep")); + const path = `${root}/link/../project.gpr`; + const commitment = await resolveGhidraProject( + { path }, + { path: join(root, "target") }, + resources, + ); + expect(commitment).toMatchObject({ + configured_path: path, + project_path: await realpath(join(root, "actual")), + project_name: "project", + }); + await expect( + resolveGhidraProject( + { path: join(root, "missing.gpr") }, + { path: join(root, "target") }, + resources, + ), + ).rejects.toMatchObject({ diagnostics: { system_code: "ENOENT" } }); + }, +); + +it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "preserves denied access between profile resolution and snapshot copying", + async () => { + const { source, destination } = await fixture(); + const project = await resolveGhidraProject( + { path: join(source, "selected.gpr") }, + { path: "/tmp/target" }, + resources, + ); + try { + await chmod(source, 0); + await expect( + copyExistingGhidraProject(project.project_path, destination, { + projectName: project.project_name, + expectedSha256: project.sha256, + resources, + }), + ).rejects.toMatchObject({ + _tag: "AnalysisAccessDeniedError", + operation: "open_binary", + systemCode: "EACCES", + path: join(project.project_path, "selected.gpr"), + }); + } finally { + await chmod(source, 0o700); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps missing entries and symbolic links classified as invalid selections", + async () => { + const { source, destination } = await fixture(); + await rm(join(source, "selected.gpr")); + await expect( + copyExistingGhidraProject(source, destination, { + projectName: "selected", + }), + ).rejects.toMatchObject({ _tag: "ConfigurationError" }); + await writeFile(join(source, "other.gpr"), "marker"); + await symlink("other.gpr", join(source, "selected.gpr")); + await expect( + copyExistingGhidraProject(source, destination, { + projectName: "selected", + }), + ).rejects.toMatchObject({ _tag: "ConfigurationError" }); + }, +); diff --git a/tests/boundary/providers/hopper/bridgeBootstrapCleanup.test.ts b/tests/boundary/providers/hopper/bridgeBootstrapCleanup.test.ts index b45e68233f..447fe7c504 100644 --- a/tests/boundary/providers/hopper/bridgeBootstrapCleanup.test.ts +++ b/tests/boundary/providers/hopper/bridgeBootstrapCleanup.test.ts @@ -1,12 +1,96 @@ import { lstat } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it } from "vitest"; +import { expect, it, onTestFinished, vi } from "vitest"; import { HopperCancelledError } from "../../../../src/domain/hopperErrors.js"; import { HopperApplicationLauncher } from "../../../../src/hopper/BridgeLauncher.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +const removal = vi.hoisted(() => ({ + root: undefined as string | undefined, + failBootstrapWrite: false, + rootFailures: 0, + bootstrapAttempts: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + chmod: async (...args: Parameters) => { + if ( + removal.failBootstrapWrite && + args[0] === join(removal.root ?? "", "bootstrap.py") + ) + throw new Error("injected bootstrap permission failure"); + return actual.chmod(...args); + }, + rm: async (...args: Parameters) => { + if (removal.root !== undefined) { + if (args[0] === join(removal.root, "bootstrap.py")) { + removal.bootstrapAttempts++; + throw new Error("injected bootstrap removal failure"); + } + if (args[0] === removal.root && removal.rootFailures > 0) { + removal.rootFailures--; + throw new Error("injected runtime removal failure"); + } + } + return actual.rm(...args); + }, + }; +}); + +it.each(["bootstrap", "launch"] as const)( + "retries authoritative runtime cleanup after %s failure leaves its bootstrap behind", + async (stage) => { + removal.failBootstrapWrite = stage === "bootstrap"; + removal.rootFailures = 1; + removal.bootstrapAttempts = 0; + const application = new HopperApplicationLauncher( + { + environment: {}, + launcherPath: "/nonexistent-rea-bootstrap-fixture/hopper", + targetPath: "/fixture-target", + targetKind: "database", + loaderArgs: [], + bridgeScriptPath: "/fixture-bridge.py", + launchMode: "native", + }, + { platform: "win32" }, + ); + const client = new HopperClient({ + startupTimeoutMs: 5_000, + launcher: { + launch: (session, options) => { + removal.root = session.directory; + return application.launch(session, options); + }, + cleanup: () => application.cleanup(), + }, + }); + onTestFinished(async () => { + removal.rootFailures = 0; + await client.close(); + removal.root = undefined; + removal.failBootstrapWrite = false; + }); + const started = await client.start(); + expect(started.ok).toBe(false); + if (started.ok) throw new Error("Expected failed fixture launch"); + expect(removal.bootstrapAttempts).toBe(1); + expect(started.error.cleanupIncomplete).toBe(true); + const root = removal.root; + if (root === undefined) throw new Error("Fixture session was not created"); + expect(started.error.cleanupResources).toContain(root); + expect((await lstat(join(root, "bootstrap.py"))).isFile()).toBe(true); + expect((await client.close()).ok).toBe(true); + await expect(lstat(root)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + it.each(["failed", "cancelled"] as const)( "removes the token-bearing bootstrap and releases the lease after a %s launch", async (outcome) => { diff --git a/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts index b87aee72c5..0f18c5b271 100644 --- a/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts +++ b/tests/boundary/providers/hopper/linuxPrivateDisplay.test.ts @@ -407,12 +407,22 @@ it.skipIf(process.platform === "win32").each(["exited", "cancelled"] as const)( async (outcome) => { const { helper, ready } = await inertProbe(outcome === "cancelled"); const controller = new AbortController(); + const scheduled = vi.spyOn(globalThis, "setTimeout"); + const cleared = vi.spyOn(globalThis, "clearTimeout"); + onTestFinished(() => { + scheduled.mockRestore(); + cleared.mockRestore(); + }); const original = ownership.cleanupOwnedProcessGroup; const cleanup = vi .spyOn(ownership, "cleanupOwnedProcessGroup") - .mockResolvedValue({ - cleaned: false, - reason: "controlled display probe cleanup refusal", + .mockImplementation(async () => { + // Finalization cancellation must not replace a completed probe outcome. + if (outcome === "exited") controller.abort("cancelled during cleanup"); + return { + cleaned: false, + reason: "controlled display probe cleanup refusal", + }; }); let pending: | Extract< @@ -435,6 +445,13 @@ it.skipIf(process.platform === "win32").each(["exited", "cancelled"] as const)( } const result = await operation; expect(result.outcome).toBe(outcome); + // The probe has settled despite refused cleanup; no abandoned output wait + // should keep its original deadline alive until a later cleanup retry. + const deadlines = scheduled.mock.calls.flatMap((arguments_, index) => + arguments_[1] === 5000 ? [scheduled.mock.results[index]!.value] : [], + ); + expect(deadlines.length).toBeGreaterThan(0); + for (const timer of deadlines) expect(cleared).toHaveBeenCalledWith(timer); expect(result.stderr).toBe("authored probe diagnostic"); expect(result.cleanup.status).toBe("pending"); if (result.cleanup.status !== "pending") diff --git a/tests/boundary/providers/managed/managedDirectAnalysis.test.ts b/tests/boundary/providers/managed/managedDirectAnalysis.test.ts index df23e261d0..a209d4721f 100644 --- a/tests/boundary/providers/managed/managedDirectAnalysis.test.ts +++ b/tests/boundary/providers/managed/managedDirectAnalysis.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../../src/cli/projectCliAnalysisResult.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -18,7 +19,9 @@ describe("managed direct-analysis path boundary", () => { await writeFile(path, buildManagedPeFixture()); const evidence = parseEvidence( - await runProviderAnalysis(path, "inspect_managed_artifact", {}), + await runProviderAnalysis(path, "inspect_managed_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect(evidence).toMatchObject({ operation: "inspect_managed_artifact", @@ -31,7 +34,9 @@ describe("managed direct-analysis path boundary", () => { }); const memberEvidence = parseEvidence( - await runProviderAnalysis(path, "inspect_managed_members", {}), + await runProviderAnalysis(path, "inspect_managed_members", {}).then( + projectCliAnalysisResult, + ), ); expect(memberEvidence).toMatchObject({ operation: "inspect_managed_members", @@ -44,7 +49,11 @@ describe("managed direct-analysis path boundary", () => { }); const boundaryEvidence = parseEvidence( - await runProviderAnalysis(path, "inspect_managed_native_boundaries", {}), + await runProviderAnalysis( + path, + "inspect_managed_native_boundaries", + {}, + ).then(projectCliAnalysisResult), ); expect(boundaryEvidence).toMatchObject({ operation: "inspect_managed_native_boundaries", diff --git a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts index c70c003237..51154ab80d 100644 --- a/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparisonFromMetadata.test.ts @@ -1024,6 +1024,132 @@ describe("managed member structural matching", () => { }); }); +describe("managed LocalVarSig comparison", () => { + const observe = (signature: Buffer, path: string, maxStack = 8) => { + const body = Buffer.alloc(13); + body.writeUInt16LE(0x3003, 0); + body.writeUInt16LE(maxStack, 2); + body.writeUInt32LE(1, 4); + body.writeUInt32LE(0x11000001, 8); + body[12] = 0x2a; + return inspect( + buildManagedPeFixture({ + ilBody: body, + standaloneSignatures: [signature], + mvid: createHash("md5").update(path).digest(), + }), + path, + ); + }; + + it("retains changed validated primitive local signature bytes with identical CIL", () => { + const left = observe( + Buffer.from([0x07, 0x01, 0x08]), + "/fixture/locals-int32.dll", + ); + const right = observe( + Buffer.from([0x07, 0x01, 0x0a]), + "/fixture/locals-int64.dll", + ); + expect(left.result.coverage).toMatchObject({ + state: "complete", + issues: [], + }); + expect(right.result.coverage).toMatchObject({ + state: "complete", + issues: [], + }); + expect(left.result.methods[0]?.body).toMatchObject({ + status: "present", + local_var_sig_token: "0x11000001", + local_var_signature: { has_context_dependent_types: false }, + il_sha256: right.result.methods[0]?.body.il_sha256, + normalized_il_sha256: right.result.methods[0]?.body.normalized_il_sha256, + }); + const result = managedMemberComparisonResultSchema.parse( + compareManagedMembers(left, right), + ); + expect(result.methods[0]).toMatchObject({ + status: "changed", + dimensions: ["metadata"], + match: { basis: "exact-il-signature" }, + limitations: [expect.stringContaining("encoding difference")], + }); + }); + + it.each([ + ["named type", [0x12, 0x04]], + ["type variable", [0x13, 0x00]], + ["method variable", [0x1e, 0x00]], + ["custom modifier", [0x1f, 0x04, 0x08]], + ] as const)( + "keeps cross-artifact %s locals unknown while retaining observed header changes", + (_name, type) => { + const signature = Buffer.from([0x07, 0x01, ...type]); + const left = observe(signature, "/fixture/locals-context-left.dll"); + const right = observe(signature, "/fixture/locals-context-right.dll", 16); + expect(left.result.methods[0]?.body).toMatchObject({ + status: "present", + local_var_signature: { has_context_dependent_types: true }, + }); + expect(compareManagedMembers(left, right).methods[0]).toMatchObject({ + status: "changed", + dimensions: ["metadata", "availability"], + limitations: [ + expect.stringContaining( + "Local variable signature identity is unknown", + ), + ], + }); + const sameHeader = observe( + signature, + "/fixture/locals-context-same-header.dll", + ); + expect(compareManagedMembers(left, sameHeader).methods[0]).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + }); + expect(compareManagedMembers(left, left).methods[0]).toMatchObject({ + status: "unchanged", + dimensions: [], + }); + }, + ); + + it("preserves historical locals uncertainty across artifacts and byte identity within one artifact", () => { + const left = observe( + Buffer.from([0x07, 0x01, 0x08]), + "/fixture/locals-current.dll", + ); + const currentRight = observe( + Buffer.from([0x07, 0x01, 0x0a]), + "/fixture/locals-old.dll", + ); + const historical = (side: typeof left) => ({ + ...side, + result: managedMemberInspectionSchema.parse({ + ...side.result, + methods: side.result.methods.map((method) => ({ + ...method, + body: { ...method.body, local_var_signature: undefined }, + })), + }), + }); + expect( + compareManagedMembers(left, historical(currentRight)).methods[0], + ).toMatchObject({ + status: "unknown", + dimensions: ["availability"], + }); + expect( + compareManagedMembers(left, historical(left)).methods[0], + ).toMatchObject({ + status: "unchanged", + dimensions: [], + }); + }); +}); + describe("managed member comparison", () => { it.each([ { label: "initialization flag", flags: 0x3013, maxStack: 8 }, diff --git a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts index 506212f201..59a192b9ca 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveInventory.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../../src/cli/projectCliAnalysisResult.js"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -34,7 +35,9 @@ describe("artifact archive inventory", () => { zipResult.occurrences.map(({ artifact_kind }) => artifact_kind), ).toEqual(expect.arrayContaining(["javascript", "dynamic-library"])); const cliEvidence = parseEvidence( - await runProviderAnalysis(zipPath, "inventory_artifact", {}), + await runProviderAnalysis(zipPath, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect(cliEvidence).toMatchObject({ operation: "inventory_artifact", @@ -42,7 +45,9 @@ describe("artifact archive inventory", () => { subject: { format: "apk" }, }); const inspectionEvidence = parseEvidence( - await runProviderAnalysis(zipPath, "inspect_artifact", {}), + await runProviderAnalysis(zipPath, "inspect_artifact", {}).then( + projectCliAnalysisResult, + ), ); const inspection = artifactInspectionResultSchema.parse( inspectionEvidence.normalized_result, @@ -81,7 +86,7 @@ describe("artifact archive inventory", () => { {}, undefined, cancellation.signal, - ), + ).then(projectCliAnalysisResult), ).resolves.toMatchObject({ code: "cancelled" }); await expect( runProviderAnalysis( @@ -90,7 +95,7 @@ describe("artifact archive inventory", () => { {}, undefined, cancellation.signal, - ), + ).then(projectCliAnalysisResult), ).resolves.toMatchObject({ code: "cancelled" }); const source = join(root, "asar-source"); diff --git a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts index 95ea672de8..dd406e1bb5 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../../src/cli/projectCliAnalysisResult.js"; import { lstat, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -69,7 +70,9 @@ describe("artifact archive safety", () => { ); expect( parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ), ).toMatchObject({ operation: "inventory_artifact", diff --git a/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts b/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts index 9f032b327c..ac184b35d8 100644 --- a/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directAnalysis.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../../src/cli/projectCliAnalysisResult.js"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -42,7 +43,9 @@ it("runs static .app decoding without registering native analysis engines", asyn const evidence = parseEvidence( await createDirectAnalysis({ REA_ANALYSIS_PROVIDER: "auto", - }).runProviderAnalysis(app, "decode_interface_builder", {}), + }) + .runProviderAnalysis(app, "decode_interface_builder", {}) + .then(projectCliAnalysisResult), ); expect(evidence.operation).toBe("decode_interface_builder"); expect(evidence.provider.id).toBe("rea-artifact-graph"); @@ -64,7 +67,9 @@ it("inspects an artifact through the direct CLI workflow without an REA grant", await createPackageWithOptions(source, archive, {}); const evidence = parseEvidence( - await runProviderAnalysis(archive, "inspect_artifact", {}), + await runProviderAnalysis(archive, "inspect_artifact", {}).then( + projectCliAnalysisResult, + ), ); expect(evidence.operation).toBe("inspect_artifact"); expect(evidence.provider.id).toBe("rea-artifact-graph"); diff --git a/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts b/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts index fc4fc45195..34566555eb 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryProbes.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../../../src/cli/projectCliAnalysisResult.js"; import { mkdir, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -32,7 +33,9 @@ const writeTree = async ( const probeOperations = async (path: string): Promise => { const evidence = parseEvidence( - await runProviderAnalysis(path, "inspect_artifact", {}), + await runProviderAnalysis(path, "inspect_artifact", {}).then( + projectCliAnalysisResult, + ), ); const inspection = artifactInspectionResultSchema.parse( evidence.normalized_result, diff --git a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts index 6ceda198fb..e92dcc5b48 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionAdmission.test.ts @@ -6,6 +6,7 @@ import { readFile, rename, rm, + truncate, writeFile, } from "node:fs/promises"; import { join } from "node:path"; @@ -266,7 +267,10 @@ it.each([ await writeFile(source, bytes()); const pck = vi.spyOn(pckLocation, "locateGodotPck"); const sea = vi.spyOn(seaLocation, "locateNodeSea"); - const pyInstaller = vi.spyOn(pyInstallerLocation, "findPyInstallerCookie"); + const pyInstaller = vi.spyOn( + pyInstallerLocation, + "observePyInstallerCookie", + ); const hashRoot = rootHash.hashStableRootArtifactHandle; let probesBeforeHash: number[] | undefined; const hash = vi @@ -307,10 +311,10 @@ it.each([ expect(extracted.manifest.root_format).toBe(format); expect(extracted.artifacts.length).toBeGreaterThan(0); expect(probesBeforeHash).toEqual(probes); - // Preflight's cookie also supplies inventory. Extraction opens its - // own reader and establishes that reader's cookie independently. + // Preflight's cookie supplies inventory and the transferred extraction + // admission; descriptor/path and digest checks still validate freshness. if (format === "pyinstaller") - expect(pyInstaller).toHaveBeenCalledTimes(2); + expect(pyInstaller).toHaveBeenCalledTimes(1); }, ); } finally { @@ -321,3 +325,104 @@ it.each([ } }, ); + +it("refuses an incomplete PyInstaller detection tail without reporting unsupported format", async () => { + const root = await createTestTempDirectory( + "rea-extraction-pyinstaller-tail-", + ); + const source = join(root, "app"); + const output = join(root, "output"); + const bytes = cArchiveExecutable({ + layout: "2.1", + pythonVersion: 314, + members: [], + }); + await writeFile(source, bytes); + const observe = pyInstallerLocation.observePyInstallerCookie; + const detect = vi + .spyOn(pyInstallerLocation, "observePyInstallerCookie") + .mockImplementation(async (...arguments_) => { + // The admitted size is now larger than the bytes the descriptor can read. + await truncate(source, 64); + return observe(...arguments_); + }); + const close = vi.spyOn(OwnedFileHandle.prototype, "close"); + const hash = vi.spyOn(rootHash, "hashStableRootArtifactHandle"); + try { + await withArtifactClient( + { + path: source, + sourcePath: source, + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }, + async (client) => { + expect( + await client.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + reason: "format", + detail: "CArchive tail is truncated", + }, + }); + expect(detect).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalled(); + expect(hash).not.toHaveBeenCalled(); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + } finally { + detect.mockRestore(); + close.mockRestore(); + hash.mockRestore(); + } +}); + +it("keeps incidental rejected cookie signatures unsupported during native extraction", async () => { + const root = await createTestTempDirectory( + "rea-extraction-pyinstaller-candidate-", + ); + const source = join(root, "app"); + const output = join(root, "output"); + const bytes = Buffer.concat([ + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 314, + members: [], + }).subarray(0, 64), + pyInstallerLocation.PYINSTALLER_COOKIE_MAGIC, + Buffer.alloc(88), + ]); + await writeFile(source, bytes); + const hash = vi.spyOn(rootHash, "hashStableRootArtifactHandle"); + try { + await withArtifactClient( + { + path: source, + sourcePath: source, + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }, + async (client) => { + expect( + await client.execute("extract_artifact", { output_root: output }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisUnsupportedTargetError", path: source }, + }); + expect(hash).not.toHaveBeenCalled(); + await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + } finally { + hash.mockRestore(); + } +}); diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index 86eccf6858..2ab84014f6 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -977,6 +977,39 @@ it("reads exports from the symbol table without llvm-nm's dyld-info pass", async expect(nmCommands(execution.value.result)).toEqual(nmArguments); }); +it("binds leading-space exports to their exact nm identities", async () => { + // Real clang dylib: .globl " _spaced"; " _spaced": ret; .globl _spaced; _spaced: ret. + // dyld_info separates the address from the literal symbol with two spaces. + const names = " _spaced\n_spaced\n"; + const { runner, nmArguments } = symbolTableRunner( + { + "dyld_info:-exports": [ + "fixture [arm64]:", + " -exports:", + " offset symbol", + " 0x000002A0 _spaced", + " 0x000002A4 _spaced", + "", + ].join("\n"), + }, + { symbolTable: names, withDyldInfo: names }, + ); + const execution = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(machoTarget("/private/fixture")) + .execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(nmArguments).toHaveLength(1); + const exported = ( + execution.value.result as { + exports: { items: { name: string; address: string | null }[] }; + } + ).exports.items; + expect(exported).toEqual([ + expect.objectContaining({ name: " _spaced", address: "0x1000002a0" }), + expect.objectContaining({ name: "_spaced", address: "0x1000002a4" }), + ]); +}); + it("keeps an export name ending in a carriage return distinct", async () => { // Captured from a clang executable exporting "trailing\r" and "trailing". const names = "__mh_execute_header\n_main\ntrailing\ntrailing\r\n"; diff --git a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts index da2dfd2829..6379a914bf 100644 --- a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts +++ b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts @@ -595,3 +595,61 @@ it.each([ ); expect(load.builds[0]?.tools).toEqual([{ name, version: "1.0" }]); }); + +it("projects build tools from the retained ordered field facts", () => { + const load = parseOtoolLoadCommands( + withOtoolMachHeader( + [ + "Load command 0", + "cmd LC_BUILD_VERSION", + "cmdsize 40", + "platform 1", + "minos 1.0", + "sdk 1.0", + "ntools 2", + "tool 1", + "", + "version 001", + "tool 99", + "version 0x10", + "", + ].join("\n"), + ), + ); + expect(load.builds[0]?.tools).toEqual([ + { name: "CLANG", version: "001" }, + { name: "99", version: "0x10" }, + ]); + expect(load.commands[0]?.fields).toMatchObject({ + "tool #1": 1, + "tool #2": 99, + "version #1": 1, + "version #2": 16, + }); +}); + +it.each([ + ["version 9.0", "tool 1", "version 1.0"], + ["tool 1", "version 1.0", "tool 2"], + ["tool 1", "version 1.0", "version 2.0"], + ["tool 1", "unexpected 2", "version 1.0"], + ["tool 1", "version"], +])("rejects unpaired build tool/version facts: %j", (...toolRows) => { + expect(() => + parseOtoolLoadCommands( + withOtoolMachHeader( + [ + "Load command 0", + "cmd LC_BUILD_VERSION", + "cmdsize 32", + "platform 1", + "minos 1.0", + "sdk 1.0", + "ntools 1", + ...toolRows, + "", + ].join("\n"), + ), + ), + ).toThrow(/otool build.tool/u); +}); diff --git a/tests/boundary/providers/native/nativePlistFileCleanup.test.ts b/tests/boundary/providers/native/nativePlistFileCleanup.test.ts index fa7aaccf63..6058ff033b 100644 --- a/tests/boundary/providers/native/nativePlistFileCleanup.test.ts +++ b/tests/boundary/providers/native/nativePlistFileCleanup.test.ts @@ -6,6 +6,7 @@ import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFi import { NativeFixtureRunner, nativeMachoTarget, + nativeMachoTargetForFile, } from "../../../fixtures/nativeCommands.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; @@ -451,3 +452,66 @@ it.for([ }); }, ); + +it("preserves a failed app plist read and its cleanup when the executable changes", async () => { + const root = await createTestTempDirectory("rea-app-plist-primary-"); + const path = join(root, "Info.plist"); + const executable = join(root, "program"); + await writeFile(path, "value"); + await writeFile(executable, "original executable"); + const target = { + ...(await nativeMachoTargetForFile(executable)), + bundleInfoPlist: path, + }; + const probe = await open(path, "r"); + const inode = (await probe.stat()).ino; + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const primary = Object.assign(new Error("controlled plist read failure"), { + code: "EIO", + }); + const cleanup = new Error("controlled plist close failure"); + const original = prototype.read; + let release: (() => Promise) | undefined; + let closeAttempts = 0; + const read = vi.spyOn(prototype, "read").mockImplementation(async function ( + this: FileHandle, + ...args: Parameters + ) { + if ((await this.stat()).ino !== inode) + return Reflect.apply(original, this, args); + await writeFile(executable, "changed executable"); + release = this.close.bind(this); + this.close = async () => { + closeAttempts += 1; + if (closeAttempts === 1) throw cleanup; + await release!(); + }; + throw primary; + }); + const client = new NativeMacOSProvider( + {}, + new NativeFixtureRunner({ file: "ASCII text" }), + "darwin", + ).createClient(target); + onTestFinished(async () => { + read.mockRestore(); + await release?.(); + await client.close(); + }); + const result = await client.execute("inspect_plist", {}); + if (result.ok) throw new Error("Expected retained plist failure"); + expect(result.error.cleanupResources).toEqual([path]); + expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure); + const failure = result.error.cause as ArtifactFileCleanupFailure; + expect(failure.fileCleanupFailure.outcome).toEqual({ + kind: "failed", + cause: primary, + }); + expect(failure.fileCleanupFailure.cleanupCause).toBe(cleanup); + expect(closeAttempts).toBe(1); + expect((await client.close()).ok).toBe(true); + expect(closeAttempts).toBe(2); + expect(result.error.cleanupIncomplete).toBe(false); + expect(resolvedAnalysisError(result.error).cause).toBe(primary); +}); diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts index f1a72d7b69..180276a35e 100644 --- a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -153,6 +153,57 @@ it.each([undefined, "", "/selected/cache", "relative/cache"])( }, ); +portableTar.each(["HOME", "XDG_CACHE_HOME"] as const)( + "retains and reads signed packages under the selected %s symlink traversal", + async (setting) => { + const f = await fixture("arch"); + admitFixturePublisher(f.fingerprint); + const directory = await createTestTempDirectory( + "rea-linux-selected-cache-", + ); + const physical = join(directory, "physical"); + await mkdir(join(physical, "child"), { recursive: true }); + await symlink(join(physical, "child"), join(directory, "alias"), "dir"); + const selected = `${directory}/alias/..`; + const suffix = + setting === "HOME" ? ".cache/rea/hopper-releases" : "rea/hopper-releases"; + const expected = `${physical}/${suffix}/${f.release.main_exe_hash}`; + const decoy = `${directory}/${suffix}/${f.release.main_exe_hash}`; + await mkdir(decoy, { recursive: true }); + await writeFile(`${decoy}/release.json`, "caller evidence"); + const publisher = new LinuxHopperPublisher( + { [setting]: selected }, + async () => "arch", + ); + try { + const verified = await verifyFixtureArchive(f, "arch"); + expect( + await publisher.retainVerifiedArchive(verified, f.archive), + ).toEqual({ + ok: true, + value: undefined, + }); + expect( + (await readFile(`${expected}/archive`)).equals( + await readFile(f.archive), + ), + ).toBe(true); + expect(publisher.cacheRoot).toBe(`${selected}/${suffix}`); + expect(await publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + value: { source: "signed_package_cache" }, + }); + expect(await readFile(`${decoy}/release.json`, "utf8")).toBe( + "caller evidence", + ); + expect(await readdir(decoy)).toEqual(["release.json"]); + } finally { + await publisher.close(); + await f.publisher.close(); + } + }, +); + const feedRelease = () => ({ version: "1.0", filename: diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index 64b9acac88..535144585b 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -606,10 +606,24 @@ describe("evidence metadata imports and snapshot cache", () => { ); expect( session.importEvidenceBundle(createEvidenceBundle([addition])), - ).toEqual({ ok: true, value: 1 }); + ).toEqual({ + ok: true, + value: { + recordsAdded: 1, + unknownsAdded: 0, + totalRecords: before.value.evidence_bundle.records.length + 1, + }, + }); expect( session.importEvidenceBundle(createEvidenceBundle([addition])), - ).toEqual({ ok: true, value: 0 }); + ).toEqual({ + ok: true, + value: { + recordsAdded: 0, + unknownsAdded: 0, + totalRecords: before.value.evidence_bundle.records.length + 1, + }, + }); expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: true, value: { @@ -630,7 +644,9 @@ describe("evidence metadata imports and snapshot cache", () => { expect(session.exportAnalysisSnapshot().ok).toBe(true); await session.close(); }); +}); +describe("Evidence display metadata refresh", () => { it.each(["active", "unrelated"] as const)( "refreshes %s Evidence display metadata without invalidating provider observations", async (scope) => { @@ -671,7 +687,14 @@ describe("evidence metadata imports and snapshot cache", () => { const unsubscribe = session.onAnalysisSnapshotChanged(changed); expect( session.importEvidenceBundle(createEvidenceBundle([relocated])), - ).toEqual({ ok: true, value: 0 }); + ).toEqual({ + ok: true, + value: { + recordsAdded: 0, + unknownsAdded: 0, + totalRecords: session.immutableEvidenceBundle().records.length, + }, + }); expect(changed).toHaveBeenCalledTimes(1); unsubscribe(); expect(session.evidenceById(evidence.evidence_id)).toEqual(relocated); diff --git a/tests/composition/analysis-sessions/investigationRecords.test.ts b/tests/composition/analysis-sessions/investigationRecords.test.ts index 9a18120eb4..61e757ce6d 100644 --- a/tests/composition/analysis-sessions/investigationRecords.test.ts +++ b/tests/composition/analysis-sessions/investigationRecords.test.ts @@ -138,12 +138,20 @@ it("notifies an Unknown-only bundle change even when no new Evidence is added", }); expect(recipient.importEvidenceBundle(bundle)).toEqual({ ok: true, - value: 0, + value: { + recordsAdded: 0, + unknownsAdded: 1, + totalRecords: bundle.records.length, + }, }); expect(revisions).toEqual([1]); expect(recipient.importEvidenceBundle(bundle)).toEqual({ ok: true, - value: 0, + value: { + recordsAdded: 0, + unknownsAdded: 0, + totalRecords: bundle.records.length, + }, }); expect(revisions).toEqual([1]); }); diff --git a/tests/composition/directAnalysisCleanup.test.ts b/tests/composition/directAnalysisCleanup.test.ts index c29635052e..7428c4fad3 100644 --- a/tests/composition/directAnalysisCleanup.test.ts +++ b/tests/composition/directAnalysisCleanup.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { mkdir, open, @@ -163,7 +164,7 @@ it.each([false, true])( {}, undefined, controller.signal, - ), + ).then(projectCliAnalysisResult), ); expect(result.code).toBe(closeFails ? "cleanup_incomplete" : "cancelled"); expect(result.category).toBe("cancelled"); @@ -201,6 +202,45 @@ it.each([false, true])( }, ); +it("retains both execution and final-close resources in one-shot failures", async () => { + const [path] = await createBinarySessionTargets(); + const executionError = new AnalysisTimeoutError("read_bytes", 100, { + capturedOutput: { + stdout: "execution observation", + stderr: "", + truncated: false, + }, + cleanup: { + reason: "execution worker remains", + resources: ["execution-worker", "shared-owner"], + }, + }); + const closeError = new ProviderCleanupError( + "fixture", + ["session-owner", "shared-owner"], + { reason: "session remains" }, + ); + const { dependencies } = factories({ + executionError, + cleanupError: closeError, + }); + const result = analysisCliErrorEnvelopeSchema.parse( + await runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + length: 1, + }).then(projectCliAnalysisResult), + ); + expect(result).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: ["execution-worker", "shared-owner", "session-owner"], + execution_failure: "provider_timeout", + captured_output: executionError.capturedOutput, + cleanup_error: projectAnalysisError(closeError), + }, + }); +}); + describe("one-shot finalization cancellation", () => { it.each(["read_bytes", "analyze_function"] as const)( "retains completed %s Evidence when cancellation arrives during final close", @@ -214,7 +254,7 @@ describe("one-shot finalization cancellation", () => { path, tool, parameters, - ), + ).then(projectCliAnalysisResult), ); const controller = new AbortController(); const reason = { request: "cancel during native close" }; @@ -227,7 +267,7 @@ describe("one-shot finalization cancellation", () => { tool, parameters, { signal: controller.signal }, - ); + ).then(projectCliAnalysisResult); expect(result).toEqual({ error: "Analysis failed", ...projectAnalysisError( @@ -257,7 +297,7 @@ describe("one-shot finalization cancellation", () => { "read_bytes", {}, { signal: controller.signal }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: closeFails ? "cleanup_incomplete" : "provider_timeout", message: projectAnalysisError(primaryError).message, @@ -284,7 +324,7 @@ describe("one-shot finalization cancellation", () => { "read_bytes", {}, { signal: controller.signal }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "cleanup_incomplete", category: "cancelled", @@ -341,7 +381,7 @@ describe("snapshot publication finalization", () => { "read_bytes", {}, { signal: controller.signal, snapshotPath }, - ); + ).then(projectCliAnalysisResult); const loaded = await readAnalysisSnapshot(snapshotPath); if (!loaded.ok) throw loaded.error; const evidence = loaded.value.evidence_bundle.records.find( @@ -403,7 +443,7 @@ describe("snapshot publication finalization", () => { "read_bytes", {}, { signal: controller.signal, snapshotPath: failure.path }, - ), + ).then(projectCliAnalysisResult), ); expect(result.code).toBe(projectAnalysisError(failure).code); expect(result.details).toMatchObject({ @@ -419,6 +459,46 @@ describe("snapshot publication finalization", () => { }); }); +it.each([false, true])( + "retains original cleanup diagnostics when final close fails: %s", + async (closeFails) => { + const [path] = await createBinarySessionTargets(); + const executionError = new AnalysisTimeoutError("read_bytes", 100, { + cleanup: { + reason: "snapshot removal failed during execution", + resources: ["primary-snapshot"], + }, + capturedOutput: primaryError.capturedOutput, + }); + const primary = projectAnalysisError(executionError); + const { dependencies } = factories({ + executionError, + ...(closeFails ? { cleanupError } : {}), + }); + const result = analysisCliErrorEnvelopeSchema.parse( + await runDirectAnalysis(dependencies, path, "read_bytes", {}).then( + projectCliAnalysisResult, + ), + ); + expect(result).toMatchObject({ + code: "cleanup_incomplete", + details: { + ...primary.details, + ...(closeFails + ? { + resources: ["primary-snapshot", "fixture-provider-process"], + cleanup_error: projectAnalysisError(cleanupError), + } + : {}), + }, + }); + expect(JSON.stringify(result).match(/retained observation/g)).toHaveLength( + 1, + ); + if (!closeFails) expect(result.details).not.toHaveProperty("cleanup_error"); + }, +); + describe("one-shot analysis cleanup outcomes", () => { it("retains successful evidence and reports CLI failure when close fails", async () => { const [path] = await createBinarySessionTargets(); @@ -428,7 +508,7 @@ describe("one-shot analysis cleanup outcomes", () => { path, "read_bytes", {}, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ error: "Analysis failed", code: "cleanup_incomplete", @@ -458,7 +538,7 @@ describe("one-shot analysis cleanup outcomes", () => { path, "read_bytes", {}, - ); + ).then(projectCliAnalysisResult); const callerError = analysisCliErrorEnvelopeSchema.parse(result); expect(callerError).toMatchObject({ code: "cleanup_incomplete", @@ -490,7 +570,7 @@ describe("one-shot analysis cleanup outcomes", () => { path, "read_bytes", {}, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "cleanup_incomplete", details: { @@ -503,48 +583,14 @@ describe("one-shot analysis cleanup outcomes", () => { }); }); - it.each([false, true])( - "retains original cleanup diagnostics when final close fails: %s", - async (closeFails) => { - const [path] = await createBinarySessionTargets(); - const executionError = new AnalysisTimeoutError("read_bytes", 100, { - cleanup: { - reason: "snapshot removal failed during execution", - resources: ["primary-snapshot"], - }, - capturedOutput: primaryError.capturedOutput, - }); - const primary = projectAnalysisError(executionError); - const { dependencies } = factories({ - executionError, - ...(closeFails ? { cleanupError } : {}), - }); - const result = analysisCliErrorEnvelopeSchema.parse( - await runDirectAnalysis(dependencies, path, "read_bytes", {}), - ); - expect(result).toMatchObject({ - code: "cleanup_incomplete", - details: { - ...primary.details, - ...(closeFails - ? { cleanup_error: projectAnalysisError(cleanupError) } - : {}), - }, - }); - expect( - JSON.stringify(result).match(/retained observation/g), - ).toHaveLength(1); - if (!closeFails) - expect(result.details).not.toHaveProperty("cleanup_error"); - }, - ); - it("retains both unexpected execution failure and cleanup failure", async () => { const [path] = await createBinarySessionTargets(); const thrownExecution = new Error("unexpected execution rejection"); const { dependencies } = factories({ thrownExecution, cleanupError }); await expect( - runDirectAnalysis(dependencies, path, "read_bytes", {}), + runDirectAnalysis(dependencies, path, "read_bytes", {}).then( + projectCliAnalysisResult, + ), ).rejects.toMatchObject({ cause: thrownExecution, errors: [thrownExecution, cleanupError], @@ -559,7 +605,7 @@ describe("one-shot analysis cleanup outcomes", () => { path, "read_bytes", {}, - ); + ).then(projectCliAnalysisResult); expect(parseEvidence(result)).toMatchObject({ operation: "read_bytes", normalized_result: { observation: "retained result" }, @@ -580,7 +626,7 @@ it.each(["complete", "summary"] as const)( path, "inspect_managed_members", {}, - ), + ).then(projectCliAnalysisResult), ); const controller = new AbortController(); const reason = new Error("cancel during managed close"); @@ -600,7 +646,7 @@ it.each(["complete", "summary"] as const)( detail, evidenceBundlePath: join(dirname(path), "bundle.json"), }, - ) + ).then(projectCliAnalysisResult) : await runProviderAnalysis( dependencies, path, @@ -608,7 +654,7 @@ it.each(["complete", "summary"] as const)( {}, undefined, controller.signal, - ); + ).then(projectCliAnalysisResult); expect(result).toEqual({ error: "Analysis failed", ...projectAnalysisError( @@ -640,7 +686,7 @@ it("preserves a managed provider failure when cancellation arrives during close" {}, undefined, controller.signal, - ); + ).then(projectCliAnalysisResult); expect(result).toEqual({ error: "Analysis failed", ...projectAnalysisError(failure), @@ -727,7 +773,7 @@ describe("managed one-shot cleanup outcomes", () => { path, "inspect_managed_artifact", {}, - ); + ).then(projectCliAnalysisResult); expect(isCliOperationFailure(result)).toBe(true); expect(result).toMatchObject({ error: "Analysis failed", diff --git a/tests/composition/directAnalysisFactories.test.ts b/tests/composition/directAnalysisFactories.test.ts index 006b418a0a..b4c58f1411 100644 --- a/tests/composition/directAnalysisFactories.test.ts +++ b/tests/composition/directAnalysisFactories.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { EvidenceFileError } from "../../src/domain/evidenceErrors.js"; import { err, ok as resultOk } from "../../src/domain/result.js"; import { dirname, join } from "node:path"; @@ -68,7 +69,7 @@ it("requires a durable summary destination before acquiring a managed session", undefined, undefined, { detail: "summary" }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "invalid_request", details: { @@ -97,7 +98,9 @@ it.each([ { detail: "summary", evidenceBundlePath: "/fixture/bundle.json" }, ] as const; // @ts-expect-error JavaScript callers can bypass managed-members-only delivery. - const result = await runProviderAnalysis(...invocation); + const result = await runProviderAnalysis(...invocation).then( + projectCliAnalysisResult, + ); expect(result).toMatchObject({ code: "invalid_request", details: { @@ -117,7 +120,9 @@ describe("one-shot analysis factory boundary", () => { const interruptListeners = process.listenerCount("SIGINT"); const terminateListeners = process.listenerCount("SIGTERM"); await expect( - runDirectAnalysis(factories.dependencies, path, "read_bytes", {}), + runDirectAnalysis(factories.dependencies, path, "read_bytes", {}).then( + projectCliAnalysisResult, + ), ).rejects.toBe(failure); expect(factories.clientsClosed()).toBe(1); expect(process.listenerCount("SIGINT")).toBe(interruptListeners); @@ -136,7 +141,7 @@ describe("one-shot analysis factory boundary", () => { "read_bytes", {}, { signal: controller.signal }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ error: "Analysis failed", code: "cancelled", @@ -194,7 +199,7 @@ describe("one-shot analysis factory boundary", () => { "inspect_native_dispatch_metadata", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ).rejects.toBe(expected); expect(factories.sessions).toEqual([]); }, @@ -227,7 +232,7 @@ describe("one-shot analysis factory boundary", () => { path, tool, arguments_, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ operation: tool, normalized_result: { operation: tool }, @@ -256,7 +261,7 @@ describe("one-shot analysis factory boundary", () => { path, tool, {}, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ operation: tool }); expect(factories.clientsClosed()).toBe(1); }, @@ -279,7 +284,7 @@ it("preserves a managed execution profile in direct analysis Evidence", async () path, "inspect_managed_artifact", {}, - ); + ).then(projectCliAnalysisResult); expect(evidence).toMatchObject({ analysis_profile: profile }); }); @@ -298,7 +303,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover "read_bytes", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ).not.toHaveProperty("code"); const mismatch = await runDirectAnalysis( factories.dependencies, @@ -306,7 +311,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover "read_bytes", {}, { snapshotPath }, - ); + ).then(projectCliAnalysisResult); expect(mismatch).toMatchObject({ code: "evidence_integrity_mismatch", remediation: { @@ -321,7 +326,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover "read_bytes", {}, { snapshotPath }, - ), + ).then(projectCliAnalysisResult), ).not.toHaveProperty("code"); }); @@ -362,7 +367,7 @@ it("retries snapshot file cleanup before final close even when a published ident "read_bytes", {}, { snapshotPath: join(dirname(path), "excluded.snapshot") }, - ); + ).then(projectCliAnalysisResult); expect(events[0]).toBe("file-cleanup"); expect(events).toContain("session-close"); expect(result).toMatchObject({ details: { published: true, bytes: 42 } }); diff --git a/tests/composition/directAnalysisLimitations.test.ts b/tests/composition/directAnalysisLimitations.test.ts index 1975559f57..5420fbf37f 100644 --- a/tests/composition/directAnalysisLimitations.test.ts +++ b/tests/composition/directAnalysisLimitations.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { expect, it } from "vitest"; import { parseConfig } from "../../src/config/parseConfig.js"; import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js"; @@ -42,7 +43,7 @@ it("retains provider coverage caveats in a composed CLI result and closes the se path, "binary_overview", {}, - ); + ).then(projectCliAnalysisResult); expect(output).toMatchObject({ limitations: [ "Derived by an REA composed workflow.", diff --git a/tests/composition/directAnalysisXrefs.test.ts b/tests/composition/directAnalysisXrefs.test.ts index 13802cddc7..f32601ffe8 100644 --- a/tests/composition/directAnalysisXrefs.test.ts +++ b/tests/composition/directAnalysisXrefs.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { parseConfig } from "../../src/config/parseConfig.js"; import { describe, expect, it } from "vitest"; import { runDirectAnalysis } from "../../src/application/DirectAnalysis.js"; @@ -114,7 +115,7 @@ describe("direct CLI cross-reference selectors", () => { path, "xrefs", { address: "table_only", extra: true }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "invalid_request", details: { operation: "xrefs", issues }, @@ -132,7 +133,7 @@ describe("direct CLI cross-reference selectors", () => { const evidence = parseEvidence( await runDirectAnalysis(provider.dependencies, path, "xrefs", { address, - }), + }).then(projectCliAnalysisResult), ); expect(evidence.parameters).toEqual({ address }); expect(evidence.normalized_result).toEqual(["0x4000"]); @@ -160,7 +161,7 @@ describe("direct CLI cross-reference selectors", () => { const evidence = parseEvidence( await runDirectAnalysis(provider.dependencies, path, "xrefs", { address, - }), + }).then(projectCliAnalysisResult), ); expect(evidence.parameters).toEqual({ address }); expect(evidence.normalized_result).toEqual(["0x4000"]); @@ -178,7 +179,7 @@ describe("direct CLI cross-reference name resolution", () => { const evidence = parseEvidence( await runDirectAnalysis(provider.dependencies, path, "xrefs", { address, - }), + }).then(projectCliAnalysisResult), ); expect(evidence.parameters).toEqual({ address }); expect(evidence.normalized_result).toEqual(["0x4000"]); @@ -197,7 +198,7 @@ describe("direct CLI cross-reference name resolution", () => { const empty = parseEvidence( await runDirectAnalysis(provider.dependencies, path, "xrefs", { address: "unreferenced", - }), + }).then(projectCliAnalysisResult), ); expect(empty.normalized_result).toEqual([]); const missing = await runDirectAnalysis( @@ -207,7 +208,7 @@ describe("direct CLI cross-reference name resolution", () => { { address: "missing_symbol", }, - ); + ).then(projectCliAnalysisResult); expect(missing).toMatchObject({ code: "invalid_request", details: { operation: "xrefs", issues: [{ path: ["address"] }] }, @@ -226,7 +227,7 @@ describe("direct CLI cross-reference name resolution", () => { { address: "ambiguous", }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "invalid_request" }); expect(JSON.stringify(result)).toContain("0x1000"); expect(JSON.stringify(result)).toContain("0x2000"); @@ -245,7 +246,7 @@ describe("direct CLI cross-reference name resolution", () => { const evidence = parseEvidence( await runDirectAnalysis(provider.dependencies, path, "xrefs", { address: "table_only", - }), + }).then(projectCliAnalysisResult), ); expect(evidence.parameters).toEqual({ address: "table_only" }); expect(evidence.normalized_result).toEqual(["0x4000"]); @@ -270,7 +271,7 @@ describe("direct CLI cross-reference name resolution", () => { { address: "table_only", }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code }); expect(provider.closed()).toBe(1); }, diff --git a/tests/conformance/android/ReaJadxMetadataRegression.java b/tests/conformance/android/ReaJadxMetadataRegression.java index 6539235f4f..791161a649 100644 --- a/tests/conformance/android/ReaJadxMetadataRegression.java +++ b/tests/conformance/android/ReaJadxMetadataRegression.java @@ -184,6 +184,76 @@ public class ReaJadxMetadataRegression { System.out.println("RAW_CLASS_SELECTOR_REGRESSION_PASS nested=true metadata=true references=true"); } + /** Source generation can populate def_pos; it must not change reference identity. */ + private static JsonObject referenceIdentities(JsonObject references) { + JsonObject identity = references.deepCopy(); + for (JsonElement item : identity.getAsJsonArray("items")) + item.getAsJsonObject().remove("def_pos"); + return identity; + } + + private static void verifyDefaultPackageSelectors(Method handle, Object bridge, JadxSession session) + throws Exception { + JavaClass selected = session.getClasses().stream().filter(cls -> cls.getRawName().equals("sb")) + .findFirst().orElseThrow(() -> new AssertionError("Default-package fixture class missing")); + String displayed = selected.getFullName(); + require(!displayed.equals("sb"), "Default-package fixture must exercise a distinct JADX display name"); + ProcessState state = selected.getClassNode().getState(); + JsonObject rawClass = arguments("sb"); + JsonObject displayedClass = arguments(displayed); + JsonObject rawMethod = methodArguments("sb", "readNative"); + JsonObject displayedMethod = methodArguments(displayed, "readNative"); + JsonObject summary = call(handle, bridge, "get_class_summary", rawClass); + require(summary.get("raw_name").getAsString().equals("sb") + && summary.get("full_name").getAsString().equals(displayed), + "Default-package summary mixed its raw and displayed identities"); + JsonObject body = call(handle, bridge, "get_method_body", rawMethod); + require(body.get("class_name").getAsString().equals(displayed) + && body.get("dex_descriptor").getAsString().equals("(I)I") + && body.get("body").getAsString().isEmpty() + && body.getAsJsonArray("markers").equals(JsonParser.parseString("[\"native_method\"]")), + "Default-package native method lost its identity or parsed absence fact"); + JsonObject classReferences = call(handle, bridge, "get_xrefs_to_class", rawClass); + require(classReferences.get("target").getAsString().equals(displayed), + "Default-package class references replaced display identity with the raw selector"); + JsonObject methodReferences = call(handle, bridge, "get_xrefs_to_method", rawMethod); + require(methodReferences.get("target").getAsString().equals(displayed + ".readNative") + && methodReferences.get("total").getAsInt() == 1 + && methodReferences.get("count").getAsInt() == 1, + "Default-package method reference did not retain its single caller"); + JsonArray invokes = methodReferences.getAsJsonArray("items").get(0).getAsJsonObject() + .getAsJsonArray("direct_invokes"); + require(invokes.size() == 1 + && invokes.get(0).getAsJsonObject().get("source_raw_method_id").getAsString() + .equals("sb.callNative(I)I"), + "Default-package reference lost the caller's raw DEX identity"); + require(selected.getClassNode().getState() == state, + "Default-package metadata or native absence lookup generated source"); + for (boolean generateNeighbour : List.of(true, false)) { + require(summary.equals(call(handle, bridge, "get_class_summary", rawClass)) + && summary.equals(call(handle, bridge, "get_class_summary", displayedClass)), + "Raw and displayed default-package selectors changed frozen class facts"); + require(body.equals(call(handle, bridge, "get_method_body", rawMethod)) + && body.equals(call(handle, bridge, "get_method_body", displayedMethod)), + "Raw and displayed default-package selectors changed native method facts"); + require(classReferences.equals(call(handle, bridge, "get_xrefs_to_class", rawClass)) + && classReferences.equals(call(handle, bridge, "get_xrefs_to_class", displayedClass)), + "Raw and displayed default-package selectors changed incoming class references"); + require(referenceIdentities(methodReferences).equals( + referenceIdentities(call(handle, bridge, "get_xrefs_to_method", rawMethod))) + && referenceIdentities(methodReferences).equals( + referenceIdentities(call(handle, bridge, "get_xrefs_to_method", displayedMethod))), + "Raw and displayed default-package selectors changed incoming method identities"); + if (generateNeighbour) { + JsonObject neighbour = call(handle, bridge, "get_method_body", methodArguments("sb", "callNative")); + require(neighbour.get("mode").getAsString().equals("java") + && neighbour.get("body").getAsString().contains("readNative("), + "Default-package neighbour did not generate its own native-calling Java body"); + } + } + System.out.println("DEFAULT_PACKAGE_SELECTOR_REGRESSION_PASS raw=true display=true metadata=true method=true references=true frozen=true"); + } + private static void verifyClassPages(Method handle, Object bridge, List inventory) throws Exception { int pages = 0; @@ -234,6 +304,7 @@ public class ReaJadxMetadataRegression { Path.of(sources, "NativeOverload.smali").toString(), Path.of(sources, "AbstractShape.smali").toString(), Path.of(sources, "MergedMethod.smali").toString(), + Path.of(sources, "DefaultPackageSelector.smali").toString(), Path.of(sources, "ConstantFields.smali").toString())), "Initializer fixture assembly failed"); Path apk = directory.resolve("initializers.apk"); try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(apk))) { @@ -330,6 +401,7 @@ public class ReaJadxMetadataRegression { BuildersKt.runBlocking(EmptyCoroutineContext.INSTANCE, (scope, continuation) -> holder.load(apk.toString(), config, continuation)); Object bridge = constructor.newInstance(holder); + verifyDefaultPackageSelectors(handle, bridge, holder.current()); String[] classNames = { "fixture.InitializationPolicy", "fixture.MixedInitialization" }; String[][] witnesses = { { "declared-policy", "->singletonList", "->POLICY:Ljava/util/List;" }, diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 3616ffed1b..a9bed7943e 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -647,6 +647,76 @@ it("keeps empty identities unknown and compares suffix-only identities as data", }); }); +it("retains a decorated module match when another decorated candidate has unknown identity", () => { + const input = inputWithImportScope("nativehelper.dll"); + const parent = input.managed_boundaries; + const normalized = managedNativeBoundaryInspectionSchema.parse( + parent.normalized_result, + ); + const managed = createEvidence(undefined, parent.provider, { + operation: parent.operation, + parameters: parent.parameters, + result: { + ...normalized, + pinvoke_imports: normalized.pinvoke_imports.map((item) => ({ + ...item, + char_set: "unicode", + no_mangle: false, + })), + }, + rawResult: null, + }); + const known = nativeFunctionEvidence( + "/examples/nativehelper.dll", + "pe", + "open_nativeW", + ); + const subjectless = (name: string) => { + const source = nativeFunctionEvidence("/examples/unknown.dll", "pe", name); + return createEvidence(undefined, source.provider, { + operation: source.operation, + parameters: source.parameters, + result: source.normalized_result, + rawResult: null, + }); + }; + for (const name of ["open_nativeW", "open_native"]) { + const unknown = subjectless(name); + for (const observations of [ + [known, unknown], + [unknown, known], + ]) { + const result = executeManagedNativeBoundaries({ + managed_boundaries: managed, + native_observations: observations, + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const output = managedNativeVerificationResultSchema.parse( + result.value.evidence.normalized_result, + ); + expect(output.pinvoke_imports[0]).toMatchObject( + name === "open_nativeW" + ? { + status: "inferred", + basis: "decorated-name-candidate", + matched_native: { evidence_id: known.evidence_id }, + } + : { + status: "unresolved", + basis: "module-identity-unknown", + matched_native: { evidence_id: unknown.evidence_id }, + }, + ); + expect(output.pinvoke_imports[0]?.candidates).toHaveLength(2); + expect(output.evidence_links).toEqual( + expect.arrayContaining([known.evidence_id, unknown.evidence_id]), + ); + expect(result.value.sourceEvidence).toEqual([managed, ...observations]); + } + } +}); + it("matches versioned ELF SONAME variations without dropping their version", () => { const result = verifyManagedNativeVerificationSource( readFixtureSource({ diff --git a/tests/fixtures/android/static-initializers/DefaultPackageSelector.smali b/tests/fixtures/android/static-initializers/DefaultPackageSelector.smali new file mode 100644 index 0000000000..ed806fbe92 --- /dev/null +++ b/tests/fixtures/android/static-initializers/DefaultPackageSelector.smali @@ -0,0 +1,12 @@ +.class public Lsb; +.super Ljava/lang/Object; + +.method public static native readNative(I)I +.end method + +.method public static callNative(I)I + .registers 2 + invoke-static {p0}, Lsb;->readNative(I)I + move-result v0 + return v0 +.end method diff --git a/tests/fixtures/apkSignature/v1SignatureBlock.ts b/tests/fixtures/apkSignature/v1SignatureBlock.ts index 0a2562adc1..6a72af4bf5 100644 --- a/tests/fixtures/apkSignature/v1SignatureBlock.ts +++ b/tests/fixtures/apkSignature/v1SignatureBlock.ts @@ -117,15 +117,18 @@ const certificateParts = (certificate: Uint8Array) => { }; /** tbsCertificate fields a SignerInfo names: issuer Name TLV and serial content. */ -const issuerAndSerial = (certificate: Uint8Array): Uint8Array => { +const issuerAndSerial = ( + certificate: Uint8Array, + serial?: Uint8Array, +): Uint8Array => { // Fixture certificates are v3 with short-form serials: walk the known layout. const { fields } = certificateParts(certificate); - const serial = fields[1]!; + const serialField = fields[1]!; const issuer = fields[3]!; return tlv( 0x30, certificate.subarray(issuer.start, issuer.end), - certificate.subarray(serial.start, serial.end), + serial ?? certificate.subarray(serialField.start, serialField.end), ); }; @@ -162,6 +165,23 @@ export const withSubject = ( ); }; +/** Replace a fixture serial INTEGER (the full TLV) without asserting a valid signature. */ +export const withSerial = ( + certificate: Uint8Array, + serial: Uint8Array, +): Uint8Array => { + const { fields, outer } = certificateParts(certificate); + const part = ({ start, end }: DerSpan) => certificate.subarray(start, end); + return tlv( + 0x30, + tlv( + 0x30, + ...fields.map((field, index) => (index === 1 ? serial : part(field))), + ), + ...outer.map(part), + ); +}; + /** Replace a fixture's issuer or extensions without asserting a valid signature. */ export const withIssuer = ( certificate: Uint8Array, @@ -205,13 +225,25 @@ export interface SignerInfoSpec { readonly names: Uint8Array; /** Replace the required signature TLV, including omission for malformed fixtures. */ readonly signature?: Uint8Array; + /** + * INTEGER content of issuerAndSerialNumber.serialNumber when it must differ + * from the certificate named by `names`. + */ + readonly serial?: Uint8Array; } -export const signerInfo = ({ names, signature }: SignerInfoSpec): Uint8Array => +export const signerInfo = ({ + names, + signature, + serial, +}: SignerInfoSpec): Uint8Array => tlv( 0x30, integer(1), - issuerAndSerial(names), + issuerAndSerial( + names, + serial === undefined ? undefined : tlv(0x02, serial), + ), algorithm(OID_SHA256), algorithm(OID_ECDSA_SHA256), signature ?? tlv(0x04, Uint8Array.from([1, 2, 3])), diff --git a/tests/fixtures/binarySession.ts b/tests/fixtures/binarySession.ts index afe0981ef7..0e8f9dbe2a 100644 --- a/tests/fixtures/binarySession.ts +++ b/tests/fixtures/binarySession.ts @@ -29,6 +29,7 @@ import { observed } from "./analysisExecution.js"; import { createTestTempDirectory } from "./temporaryDirectory.js"; type TestBinarySessionOptions = { + readonly auxiliaryProviders?: readonly AnalysisProvider[]; readonly resolveAnalysisProfile?: ( target: BinaryTarget, options?: AnalysisProfileResolutionOptions, @@ -87,6 +88,7 @@ export const createTestProviderRouter = ( if (resolve === undefined) return SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), [ declared, + ...(options.auxiliaryProviders ?? []), ]); const candidate: AnalysisProviderCandidate = { identity: () => declared.identity(), @@ -109,7 +111,7 @@ export const createTestProviderRouter = ( }; return SessionProviderRouter.selectable( new AnalysisProviderRegistry([candidate]), - [], + options.auxiliaryProviders ?? [], ); }; diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 5a2a99f2ec..9b73fc5949 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -18,6 +18,7 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "DSH_HOME", "DSH_AGENTS_HOME", "COPILOT_HOME", + "FACTORY_HOME_OVERRIDE", "GROK_HOME", "GEMINI_CLI_HOME", "GEMINI_CLI_TRUST_WORKSPACE", diff --git a/tests/fixtures/directAnalysisInterrupt.mjs b/tests/fixtures/directAnalysisInterrupt.mjs index b3a4032d72..aa5c20c303 100644 --- a/tests/fixtures/directAnalysisInterrupt.mjs +++ b/tests/fixtures/directAnalysisInterrupt.mjs @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../dist/cli/projectCliAnalysisResult.js"; import { parseConfig } from "../../dist/config/parseConfig.js"; import { access, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -97,13 +98,13 @@ const result = ? await runDirectAnalysis(dependencies, target, "read_bytes", { address: "0x0", length: 1, - }) + }).then(projectCliAnalysisResult) : await runProviderAnalysis( dependencies, target, mode === "managed" ? "inspect_managed_artifact" : "procedure_address", mode === "managed" ? {} : { procedure: "fixture" }, - ); + ).then(projectCliAnalysisResult); clearInterval(keepAlive); if ( process.listenerCount("SIGINT") !== interruptListeners || diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 3af185a9fa..610024ef4f 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -232,12 +232,52 @@ const handleRequest = (socket, server, request, state) => { if (request.method !== "shutdown" && handleOutputFailure(socket, state)) return; if (state.mode === "hang_tools" && request.method !== "shutdown") return; + if ( + state.mode === "unicode_search" && + ["search_procedures", "search_strings"].includes(request.method) + ) { + const params = request.params; + const insensitiveLiteral = + params.mode === "literal" && params.case_sensitive === false; + // Fixed producer expectations deliberately do not reuse the host derivation. + const candidates = new Map([ + ["source", "source"], + ["ſOURCE", "source"], + ["Σ", null], + ]); + const valid = insensitiveLiteral + ? candidates.has(params.pattern) && + Object.hasOwn(params, "literal_ascii_candidate") && + params.literal_ascii_candidate === candidates.get(params.pattern) + : !Object.hasOwn(params, "literal_ascii_candidate"); + if (!valid) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: "invalid_request", + message: "Literal candidate wire fields drifted", + }, + })}\n`, + ); + return; + } + } const inventory = request.method === "list_strings" && state.mode === "fragmented" ? [stringItem("0x2000", "fixture 雪🦊")] : request.method === "list_strings" && state.mode === "oversized_result" ? [stringItem("0x2000", "x".repeat(64 * 1024 * 1024 + 1))] - : inventoryResultFor(request.method, request.params); + : state.mode === "unicode_search" && + ["search_procedures", "search_strings"].includes(request.method) + ? [ + { address: "0x1000", value: "ſource" }, + { address: "0x1001", value: "SOURCE" }, + { address: "0x1002", value: "İource" }, + { address: "0x1003", value: "unrelated" }, + ] + : inventoryResultFor(request.method, request.params); if (inventory !== undefined) { const response = `${JSON.stringify({ id: request.id, ok: true, result: inventory })}\n`; if (state.mode === "fragmented") { diff --git a/tests/fixtures/gpu/codeObjects.ts b/tests/fixtures/gpu/codeObjects.ts index e09be6509b..d4b711a296 100644 --- a/tests/fixtures/gpu/codeObjects.ts +++ b/tests/fixtures/gpu/codeObjects.ts @@ -213,7 +213,7 @@ export const cubinFixture = ( /** One producer-shaped AMDGPU MessagePack note, composable with later notes. */ export const amdNoteFixture = ( hidden = false, - target: string | null = "amdgcn-amd-amdhsa--gfx900", + target: string | number | null = "amdgcn-amd-amdhsa--gfx900", ): Buffer => { const args = [ { ".offset": 0, ".size": 8, ".value_kind": "global_buffer" }, @@ -252,7 +252,7 @@ export const amdNoteFixture = ( export const amdFixture = ( hidden = false, trailingNote?: Buffer, - target: string | null = "amdgcn-amd-amdhsa--gfx900", + target: string | number | null = "amdgcn-amd-amdhsa--gfx900", ): Buffer => { const note = amdNoteFixture(hidden, target); return elfFixture(224, [ diff --git a/tests/fixtures/javascriptArtifactGraph.ts b/tests/fixtures/javascriptArtifactGraph.ts index fe999410e6..0ec26829e6 100644 --- a/tests/fixtures/javascriptArtifactGraph.ts +++ b/tests/fixtures/javascriptArtifactGraph.ts @@ -8,4 +8,4 @@ export const immutableArtifactGraph = ( ): Promise => completeJavaScriptAnalysisSteps( buildImmutableJavaScriptArtifactGraphSteps(...args), - ); + ).then(({ graph }) => graph); diff --git a/tests/fixtures/pcap/pcapTestTools.ts b/tests/fixtures/pcap/pcapTestTools.ts index 6d9ccb2e6e..3d1b11821e 100644 --- a/tests/fixtures/pcap/pcapTestTools.ts +++ b/tests/fixtures/pcap/pcapTestTools.ts @@ -24,6 +24,15 @@ export const TSHARK_JSON = JSON.stringify([ "dns.qry.name": ["api.example.com"], "dns.flags.response": ["1"], "dns.a": ["93.184.216.34"], + "dns.qry.type": ["1"], + "dns.qry.class": ["0x0001"], + "dns.count.queries": ["1"], + "dns.count.answers": ["1"], + "dns.count.auth_rr": ["0"], + "dns.count.add_rr": ["0"], + "dns.resp.name": ["api.example.com"], + "dns.resp.type": ["1"], + "dns.resp.class": ["0x0001"], }, }, }, diff --git a/tests/process-global/bundleComparisonFailures.test.ts b/tests/process-global/bundleComparisonFailures.test.ts index 5c21e9c9cb..722bc47112 100644 --- a/tests/process-global/bundleComparisonFailures.test.ts +++ b/tests/process-global/bundleComparisonFailures.test.ts @@ -1,4 +1,4 @@ -import { readdir, writeFile } from "node:fs/promises"; +import { readdir, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -13,6 +13,11 @@ import { writeEvidenceBundle, } from "../../src/application/EvidenceBundleFiles.js"; import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { OwnedStagedOutput } from "../../src/filesystem/OwnedStagedOutput.js"; +import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js"; +import { RetainedRegularFileCleanupFailure } from "../../src/application/RegularFileRead.js"; +import * as bundleComparison from "../../src/domain/bundleComparison.js"; +import * as bundleSummary from "../../src/domain/evidenceRecordSummary.js"; import { bundleComparisonResultSchema } from "../../src/domain/bundleComparison.js"; import { processEvidence } from "../../src/application/ReconstructionObligationLedger.fixture.js"; import { processCaptureSchema } from "../../src/domain/process/processCapture.js"; @@ -43,6 +48,60 @@ const validBundleFile = async (directory: string, name = "bundle.json") => { return { bundle, path }; }; +it.each(["missing", "invalid", "cleanup"])( + "retains the right bundle close owner when the left bundle has a %s failure", + async (leftFailure) => { + const directory = await createTestTempDirectory( + "rea-bundle-mixed-cleanup-", + ); + const { path: rightPath } = await validBundleFile(directory, "right.json"); + const leftPath = join(directory, "left.json"); + if (leftFailure === "invalid") await writeFile(leftPath, "{}"); + if (leftFailure === "cleanup") + await validBundleFile(directory, "left.json"); + const rightIdentity = await stat(rightPath); + const original = OwnedFileHandle.prototype.close; + const owners = new Set(); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + const identity = await this.handle.stat(); + if (identity.ino === rightIdentity.ino || leftFailure === "cleanup") { + owners.add(this); + throw new Error("injected bundle descriptor close failure"); + } + await original.call(this); + }); + onTestFinished(async () => { + close.mockRestore(); + for (const owner of owners) await owner.close(); + }); + + const result = await compareEvidenceBundlesCommand({ leftPath, rightPath }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain(rightPath); + if (leftFailure === "cleanup") + expect(result.error.cleanupResources).toContain(leftPath); + expect([...owners].every((owner) => owner.handle.fd >= 0)).toBe(true); + + // Retry the exact leases carried through the combined failure, rather than + // closing an independently reconstructed descriptor owner. + close.mockRestore(); + const retry = async (cause: unknown): Promise => { + if (cause instanceof RetainedRegularFileCleanupFailure) { + await cause.cleanupLease.release(); + } else if (cause instanceof AggregateError) { + for (const nested of cause.errors) await retry(nested); + } else if (cause instanceof Error) await retry(cause.cause); + }; + await retry(result.error); + expect(result.error.cleanupIncomplete).toBe(false); + expect([...owners].every((owner) => owner.handle.fd < 0)).toBe(true); + }, +); + it.each([ new TypeError("capture validation implementation failed"), new Error("capture validator failed"), @@ -259,3 +318,248 @@ it.each([ ).rejects.toBe(failure); }, ); + +const standaloneBundleArguments = ( + command: string, + path: string, + output: string, +) => + command === "inspect-evidence-bundle" + ? [command, JSON.stringify({ path })] + : command === "compare" + ? [command, path, path] + : command === "evidence-export" + ? [command, path, output] + : [command, path]; + +it.each([ + "evidence-import", + "evidence-export", + "compare", + "inspect-evidence-bundle", +])( + "retries retained read cleanup through the standalone %s owner", + async (command) => { + const directory = await createTestTempDirectory( + "rea-standalone-bundle-cleanup-", + ); + const { path } = await validBundleFile(directory); + const output = join(directory, "output.json"); + const original = OwnedFileHandle.prototype.close; + const attempts = new Map(); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + const attempt = (attempts.get(this) ?? 0) + 1; + attempts.set(this, attempt); + if (attempt < 3) throw new Error("retryable descriptor close failure"); + return original.call(this); + }); + onTestFinished(async () => { + close.mockRestore(); + for (const owner of attempts.keys()) await owner.close(); + }); + let stdout = ""; + const previousExitCode = process.exitCode; + try { + await createCli({}).serve( + [...standaloneBundleArguments(command, path, output), "--json"], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + expect(attempts.size).toBe(command === "compare" ? 2 : 1); + expect([...attempts.values()]).toEqual( + command === "compare" ? [3, 3] : [3], + ); + expect([...attempts.keys()].every((owner) => owner.handle.fd < 0)).toBe( + true, + ); + const result = JSON.parse(stdout); + // A read which failed to complete its own cleanup remains a failed read; + // final cleanup resolves resource ownership rather than inventing data. + expect(result).toMatchObject({ code: "execution_failure" }); + expect(result.details?.cleanup_incomplete).not.toBe(true); + expect(await readdir(directory)).toEqual(["bundle.json"]); + } finally { + process.exitCode = previousExitCode; + } + }, +); + +it.each([ + "evidence-import", + "evidence-export", + "compare", + "inspect-evidence-bundle", +])("handles SIGINT during the standalone %s input close", async (command) => { + const directory = await createTestTempDirectory( + "rea-standalone-bundle-cancel-", + ); + const { path } = await validBundleFile(directory); + const output = join(directory, "output.json"); + const original = OwnedFileHandle.prototype.close; + const owners = new Set(); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + owners.add(this); + await original.call(this); + process.emit("SIGINT"); + }); + onTestFinished(() => close.mockRestore()); + const before = process.listenerCount("SIGINT"); + const previousExitCode = process.exitCode; + let stdout = ""; + try { + process.exitCode = undefined; + await createCli({}).serve( + [...standaloneBundleArguments(command, path, output), "--json"], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ code: "cancelled" }); + expect(process.exitCode).toBe(130); + expect(process.listenerCount("SIGINT")).toBe(before); + expect([...owners].every((owner) => owner.handle.fd < 0)).toBe(true); + expect(await readdir(directory)).toEqual(["bundle.json"]); + } finally { + process.exitCode = previousExitCode; + } +}); + +it.each(["compare", "inspect-evidence-bundle"])( + "retains completed %s output when SIGINT is queued during projection", + async (command) => { + const directory = await createTestTempDirectory( + "rea-standalone-bundle-projection-", + ); + const { path, bundle } = await validBundleFile(directory); + const queueInterrupt = () => { + setImmediate(() => process.emit("SIGINT")); + }; + const compare = bundleComparison.compareBundles; + const summarize = bundleSummary.summarizeEvidenceBundle; + const hook = + command === "compare" + ? vi + .spyOn(bundleComparison, "compareBundles") + .mockImplementation((...args) => { + const result = compare(...args); + queueInterrupt(); + return result; + }) + : vi + .spyOn(bundleSummary, "summarizeEvidenceBundle") + .mockImplementation((...args) => { + const result = summarize(...args); + queueInterrupt(); + return result; + }); + onTestFinished(() => hook.mockRestore()); + const previousExitCode = process.exitCode; + const listeners = process.listenerCount("SIGINT"); + let stdout = ""; + try { + process.exitCode = undefined; + await createCli({}).serve( + [ + ...(command === "compare" + ? [command, path, path] + : [command, JSON.stringify({ path, detail: "summary" })]), + "--json", + ], + { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }, + ); + const expected = + command === "compare" ? compare(bundle, bundle) : summarize(bundle); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cancelled", + details: { diagnostics: { completed_result: expected } }, + }); + expect(hook).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(130); + expect(process.listenerCount("SIGINT")).toBe(listeners); + } finally { + process.exitCode = previousExitCode; + } + }, +); + +it.each(["before-commit", "after-commit", "cleanup-retry"])( + "preserves standalone export publication facts on SIGINT at %s", + async (phase) => { + const directory = await createTestTempDirectory( + "rea-standalone-export-publication-", + ); + const { path, bundle } = await validBundleFile(directory); + const output = join(directory, "output.json"); + const method = + phase === "before-commit" ? "assertReadyForPublication" : "close"; + const original = OwnedStagedOutput.prototype[method]; + let attempts = 0; + const hook = vi + .spyOn(OwnedStagedOutput.prototype, method) + .mockImplementation(async function ( + this: OwnedStagedOutput, + ...args: Parameters + ) { + attempts += 1; + if (phase === "cleanup-retry" && attempts === 1) + throw new Error("retry staged cleanup"); + await Reflect.apply(original, this, args); + if (phase === "before-commit") process.emit("SIGINT"); + else setImmediate(() => process.emit("SIGINT")); + }); + onTestFinished(() => hook.mockRestore()); + const previousExitCode = process.exitCode; + const listeners = process.listenerCount("SIGINT"); + let stdout = ""; + try { + process.exitCode = undefined; + await createCli({}).serve(["evidence-export", path, output, "--json"], { + stdout: (part) => { + stdout += part; + }, + exit: () => {}, + }); + const result = JSON.parse(stdout); + expect(result).toMatchObject({ code: "cancelled" }); + expect(process.exitCode).toBe(130); + expect(process.listenerCount("SIGINT")).toBe(listeners); + if (phase === "before-commit") { + expect(await readdir(directory)).toEqual(["bundle.json"]); + expect(result).not.toHaveProperty( + "details.diagnostics.evidence_bundle", + ); + } else { + const bytes = await stat(output); + expect(result.details.diagnostics.evidence_bundle).toEqual({ + path: output, + bytes: bytes.size, + }); + const loaded = await readEvidenceBundle(output); + expect(loaded).toEqual({ ok: true, value: bundle }); + expect(await readdir(directory)).toEqual([ + "bundle.json", + "output.json", + ]); + } + expect(attempts).toBe(phase === "cleanup-retry" ? 2 : 1); + } finally { + process.exitCode = previousExitCode; + } + }, +); diff --git a/tests/process-global/derivedOperationFailures.test.ts b/tests/process-global/derivedOperationFailures.test.ts index 9caa9b8e5c..ef0489e0cb 100644 --- a/tests/process-global/derivedOperationFailures.test.ts +++ b/tests/process-global/derivedOperationFailures.test.ts @@ -1,8 +1,8 @@ -import { writeFile } from "node:fs/promises"; +import { stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { InMemoryTransport, Client } from "@modelcontextprotocol/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { z } from "zod"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../../src/contracts/artifactComparisonExample.js"; @@ -28,6 +28,9 @@ import { managedReconstructionImportResultSchema } from "../../src/domain/manage import { captureProcessScenarioFile } from "../../src/application/process/ProcessCli.js"; import { compareManagedMemberPaths } from "../../src/application/managed/ManagedMemberComparisonService.js"; +import { RetainedRegularFileCleanupFailure } from "../../src/application/RegularFileRead.js"; +import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js"; +import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js"; import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { ProcessCaptureError } from "../../src/process/capture/ProcessCaptureError.js"; @@ -643,6 +646,62 @@ describe("managed comparison output failure with completed source reads", () => }); }); +it.each(["missing", "invalid", "cleanup"])( + "retains both managed admission cleanup owners when the left has a %s failure", + async (leftFailure) => { + const root = await createTestTempDirectory( + "rea-managed-admission-cleanup-", + ); + const leftPath = join(root, "left.dll"); + const rightPath = join(root, "right.dll"); + await writeFile(rightPath, buildManagedPeFixture()); + if (leftFailure === "invalid") await writeFile(leftPath, "invalid PE"); + if (leftFailure === "cleanup") + await writeFile(leftPath, buildManagedPeFixture()); + const rightIdentity = await stat(rightPath); + const original = OwnedFileHandle.prototype.close; + const owners = new Set(); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + const identity = await this.handle.stat(); + if (identity.ino === rightIdentity.ino || leftFailure === "cleanup") { + owners.add(this); + throw new Error("injected managed admission close failure"); + } + await original.call(this); + }); + onTestFinished(async () => { + close.mockRestore(); + for (const owner of owners) await owner.close(); + }); + + const result = await compareManagedMemberPaths({ leftPath, rightPath }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain(rightPath); + expect(primaryAnalysisError(result.error)).toMatchObject({ + path: leftPath, + }); + if (leftFailure === "cleanup") + expect(result.error.cleanupResources).toContain(leftPath); + expect([...owners].every((owner) => owner.handle.fd >= 0)).toBe(true); + + close.mockRestore(); + const retry = async (cause: unknown): Promise => { + if (cause instanceof RetainedRegularFileCleanupFailure) + await cause.cleanupLease.release(); + else if (cause instanceof AggregateError) + for (const nested of cause.errors) await retry(nested); + else if (cause instanceof Error) await retry(cause.cause); + }; + await retry(result.error); + expect(result.error.cleanupIncomplete).toBe(false); + expect([...owners].every((owner) => owner.handle.fd < 0)).toBe(true); + }, +); + describe("ledger generation failure ownership through the public SDK", () => { afterEach(() => vi.restoreAllMocks()); diff --git a/tests/process-global/directAnalysisStatFailure.test.ts b/tests/process-global/directAnalysisStatFailure.test.ts index 510cc8eeac..11c4dac63c 100644 --- a/tests/process-global/directAnalysisStatFailure.test.ts +++ b/tests/process-global/directAnalysisStatFailure.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { afterEach, expect, it, vi } from "vitest"; const statFailures = vi.hoisted(() => new Map()); @@ -69,7 +70,7 @@ it.each([ path, "inspect_keyed_archive", { path: "Resources/Archive.plist" }, - ); + ).then(projectCliAnalysisResult); expect(output).toMatchObject({ code: projectedCode, @@ -108,7 +109,7 @@ it("rethrows an unclassified stat failure instead of treating it as a file", asy await expect( runProviderAnalysis(dependencies, path, "inspect_keyed_archive", { path: "Resources/Archive.plist", - }), + }).then(projectCliAnalysisResult), ).rejects.toBe(cause); expect(session.open).not.toHaveBeenCalled(); expect(session.openBundleResources).not.toHaveBeenCalled(); diff --git a/tests/process-global/firmwarePublicationDeadline.test.ts b/tests/process-global/firmwarePublicationDeadline.test.ts index 762b229cf9..e0dea71d36 100644 --- a/tests/process-global/firmwarePublicationDeadline.test.ts +++ b/tests/process-global/firmwarePublicationDeadline.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { access, readFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; @@ -347,7 +348,7 @@ test.each([false, true])( "binwalk", "inspect_firmware_regions", new AbortController().signal, - ); + ).then(projectCliAnalysisResult); expect(delivered).toMatchObject({ code: "cleanup_incomplete" }); expect(JSON.stringify(delivered).split("binwalk 3.1.0")).toHaveLength(2); } diff --git a/tests/process-global/ghidraWindowsCleanupAuthority.test.ts b/tests/process-global/ghidraWindowsCleanupAuthority.test.ts new file mode 100644 index 0000000000..21ececa795 --- /dev/null +++ b/tests/process-global/ghidraWindowsCleanupAuthority.test.ts @@ -0,0 +1,96 @@ +import { EventEmitter } from "node:events"; +import { expect, it, vi } from "vitest"; + +vi.mock("../../src/windows/WindowsPrivateRuntime.js", () => ({ + windowsPrivateRuntime: () => ({ mkdir() {}, writeFile() {} }), +})); + +import { + GhidraHeadlessLauncher, + GhidraLaunchError, +} from "../../src/ghidra/GhidraLauncher.js"; +import type { + ProviderProcessHandle, + SpawnedOwnedProviderProcess, +} from "../../src/process/ProviderProcess.js"; +import type { ProcessCleanupResult } from "../../src/process/ProcessOwnership.js"; + +it.each([false, true])( + "retains Windows cleanup authority for retry after cancellation=%s without PID signaling", + async (cancel) => { + const controller = new AbortController(); + const kill = vi.fn(() => true); + const process: ProviderProcessHandle = Object.assign(new EventEmitter(), { + pid: 4242, + stdout: null, + stdin: null, + stderr: null, + exitCode: null, + signalCode: null, + kill, + }); + const spawned: SpawnedOwnedProviderProcess & { + cleanup?: () => Promise; + } = { + process, + ownership: { + runId: "fixture-run", + leaderPid: 4242, + processGroupId: 4242, + }, + }; + const launcher = new GhidraHeadlessLauncher({ + platform: "win32", + configurationDirectoryName: "ghidra_test_PUBLIC", + environment: {}, + analyzeHeadlessPath: "C:\\Ghidra\\support\\analyzeHeadless.bat", + bridgeScriptPath: "C:\\REA\\ReaGhidraBridge.java", + comSpec: "C:\\Windows\\System32\\cmd.exe", + javaLaunch: async () => ({ + command: "unused", + arguments: [], + environment: {}, + }), + spawnProcess: async () => { + if (cancel) controller.abort("fixture cancellation"); + return spawned; + }, + }); + const result = await launcher.launch( + { + runtimeRoot: "C:\\REA\\runtime", + transport: "authenticated-loopback-tcp", + endpointPath: "C:\\REA\\runtime\\endpoint.json", + token: "fixture-token", + runId: "fixture-run", + targetPath: "C:\\REA\\target.exe", + targetSha256: "a".repeat(64), + providerVersion: "test", + profileDigest: "b".repeat(64), + }, + { signal: controller.signal }, + ); + const launch = result.ok + ? result.value + : result.error instanceof GhidraLaunchError + ? result.error.partialLaunch + : undefined; + expect(result.ok).toBe(!cancel); + expect(launch).toBeDefined(); + if (launch === undefined || !launch.ownsProcessLifetime) + throw new Error("missing owner"); + expect(await launch.cleanup?.()).toEqual({ + cleaned: false, + reason: "Windows Ghidra process has no owned cleanup capability", + }); + expect(kill).not.toHaveBeenCalled(); + const cleanup = vi.fn(async (): Promise => ({ + cleaned: true, + signaled: true, + })); + spawned.cleanup = cleanup; + expect(await launch.cleanup?.()).toEqual({ cleaned: true, signaled: true }); + expect(cleanup).toHaveBeenCalledOnce(); + expect(kill).not.toHaveBeenCalled(); + }, +); diff --git a/tests/process-global/managedBundleReadGuards.test.ts b/tests/process-global/managedBundleReadGuards.test.ts index cca6a7f4c3..18d3676e95 100644 --- a/tests/process-global/managedBundleReadGuards.test.ts +++ b/tests/process-global/managedBundleReadGuards.test.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { getHeapStatistics } from "node:v8"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createServer } from "../../src/server/createServer.js"; @@ -324,12 +325,17 @@ it("projects a rejected managed parent by reference without cloning under low he detail: "summary", evidenceBundlePath: join(root, "parent.json"), }, - ); + ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "resource_constraint", details: { operation: "inspect_managed_members" }, }); - if (typeof result !== "object" || result === null || Array.isArray(result)) + if ( + typeof result !== "object" || + result === null || + Array.isArray(result) || + !("details" in result) + ) throw new Error("Expected CLI error"); const details = result.details; if (typeof details !== "object" || details === null || Array.isArray(details)) diff --git a/tests/process-global/processCaptureFilenameBytes.test.ts b/tests/process-global/processCaptureFilenameBytes.test.ts new file mode 100644 index 0000000000..7b268fab83 --- /dev/null +++ b/tests/process-global/processCaptureFilenameBytes.test.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { processFilesystemSnapshotSchema } from "../../src/domain/process/processCapture.js"; +import { processScenarioSchema } from "../../src/domain/process/processScenario.js"; +import { snapshotRoots } from "../../src/process/capture/FilesystemSnapshot.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { advertisedSchemaErrors } from "../fixtures/mcpAdvertisedSchemaValidity.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, opendir: vi.fn(actual.opendir) }; +}); + +const actualFs = await vi.importActual("node:fs/promises"); +afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(fs.opendir).mockImplementation(actualFs.opendir); +}); + +// macOS rejects these names at creation. Simulate only directory-entry bytes; +// subsequent lookup, hashing, resource cleanup and coverage use production code. +const injectNames = (names: Buffer[]): void => { + vi.mocked(fs.opendir).mockImplementation(async (...args) => { + expect(args[1]).toMatchObject({ encoding: "buffer" }); + const directory = await actualFs.opendir(...args); + const read = directory.read.bind(directory); + const pending = [...names]; + vi.spyOn(directory, "read").mockImplementation(async () => { + const name = pending.shift(); + return name === undefined ? read() : ({ name } as unknown as Dirent); + }); + return directory; + }); +}; + +it.each([false, true])( + "retains invalid basename bytes without looking up a replacement alias (sibling=%s)", + async (sibling) => { + const root = await createTestTempDirectory("rea-capture-name-bytes-"); + const content = "replacement sibling"; + if (sibling) await fs.writeFile(join(root, "�"), content); + injectNames([Buffer.from([0xff])]); + const snapshot = await snapshotRoots( + processScenarioSchema.parse({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }), + ); + expect(snapshot.truncated).toBe(true); + expect(snapshot.completeRoots).toEqual([]); + expect(snapshot.coverage.enumeration_omissions).toEqual([ + { + path: "root_0:.", + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: "ff", + }, + ]); + expect(snapshot.coverage.enumeration_truncated).toBe(true); + expect(snapshot.files.filter(({ type }) => type === "file")).toEqual( + sibling + ? [ + expect.objectContaining({ + path: "root_0:�", + sha256: createHash("sha256").update(content).digest("hex"), + }), + ] + : [], + ); + expect(snapshot.coverage.hashed_bytes).toBe(sibling ? content.length : 0); + expect(processFilesystemSnapshotSchema.parse(snapshot)).toEqual(snapshot); + }, +); + +it("bounds invalid-name enumeration with the selected files limit", async () => { + const root = await createTestTempDirectory("rea-capture-name-budget-"); + injectNames(Array.from({ length: 10 }, () => Buffer.from([0xff]))); + const snapshot = await snapshotRoots( + processScenarioSchema.parse({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + limits: { files: 3 }, + }), + ); + expect(snapshot.files).toHaveLength(1); + expect(snapshot.coverage.enumeration_omissions).toHaveLength(2); + expect(snapshot.coverage.enumeration_reasons).toEqual([ + "unrepresentable_name", + "files_limit", + ]); + expect(snapshot.completeRoots).toEqual([]); +}); + +it.skipIf(process.platform !== "linux")( + "keeps actual Linux non-UTF-8 filenames distinct from a replacement sibling", + async () => { + const root = await createTestTempDirectory("rea-capture-linux-name-"); + await fs.writeFile( + Buffer.concat([Buffer.from(`${root}/`), Buffer.from([0xff])]), + "invalid basename", + ); + await fs.writeFile(join(root, "�"), "valid sibling"); + const snapshot = await snapshotRoots( + processScenarioSchema.parse({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }), + ); + expect(snapshot.files.map(({ path }) => path)).toEqual([ + "root_0:.", + "root_0:�", + ]); + expect(snapshot.coverage.enumeration_omissions).toEqual([ + { + path: "root_0:.", + reason: "unrepresentable_name", + system_code: null, + raw_name_hex: "ff", + }, + ]); + expect(snapshot.coverage.hashed_bytes).toBe(13); + expect(snapshot.completeRoots).toEqual([]); + }, +); + +it("advertises byte-preserving omissions in valid SDK-converted MCP schemas", async () => { + const session = createTestBinarySession(() => { + throw new Error("Schema discovery must not launch a provider"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "capture-name-bytes", version: "1" }); + try { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const tool = (await client.listTools()).tools.find( + ({ name }) => name === "capture_process_scenario", + ); + expect(tool).toBeDefined(); + if (tool === undefined) throw new Error("Missing process capture contract"); + expect(advertisedSchemaErrors([tool])).toEqual([]); + expect(JSON.stringify(tool.outputSchema)).toContain("unrepresentable_name"); + expect(JSON.stringify(tool.outputSchema)).toContain("raw_name_hex"); + } finally { + await client.close(); + await server.close(); + await session.close(); + } +}); diff --git a/tests/process-global/setupPolicySnapshot.test.ts b/tests/process-global/setupPolicySnapshot.test.ts index a765906233..9ac1e5338c 100644 --- a/tests/process-global/setupPolicySnapshot.test.ts +++ b/tests/process-global/setupPolicySnapshot.test.ts @@ -77,3 +77,111 @@ it.each(["inspect", "configure"] as const)( } }, ); + +it("rechecks registration ownership after preflight before creating any backup", async () => { + const home = await createTestTempDirectory("rea-setup-ownership-recheck-"); + const configPath = join(home, "settings.json"); + const client: SetupClient = { name: "test", configPath, format: "json" }; + await writeFile( + configPath, + '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}', + ); + expect( + await inspectClientConfiguration(client, {}, [ + "npx", + "-y", + "rea-agents@6.8.0", + "mcp", + ]), + ).toMatchObject({ status: "update" }); + const replacement = + '{"mcpServers":{"rea":{"command":"other","args":["mcp"]}}}'; + // Change after layered discovery, so only the transaction's physical snapshot sees it. + refresh.afterDiscovery = () => writeFile(configPath, replacement); + expect( + await configureClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ + status: "failed", + reason: "registration_ownership", + detail: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(configPath, "utf8")).toBe(replacement); + await expect(readFile(`${configPath}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it("blocks an inherited foreign row before planning creation of a missing write target", async () => { + const home = await createTestTempDirectory("rea-setup-inherited-ownership-"); + const lower = join(home, "config.json"); + const configPath = join(home, "opencode.json"); + await writeFile( + lower, + '{"mcp":{"rea":{"type":"local","command":["other"]}}}', + ); + const client: SetupClient = { + name: "opencode", + format: "opencode", + configPath, + configPaths: [lower, configPath], + }; + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ + status: "invalid", + remediation: expect.stringContaining("foreign ownership"), + }); + await expect(readFile(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(`${lower}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it.each(["other", "rea"])( + "rechecks an inherited %s launcher after preflight for a policy fragment", + async (command) => { + const home = await createTestTempDirectory("rea-setup-layer-recheck-"); + const lower = join(home, "config.json"); + const configPath = join(home, "opencode.json"); + const client: SetupClient = { + name: "opencode", + format: "opencode", + configPath, + configPaths: [lower, configPath], + }; + await writeFile( + lower, + '{"mcp":{"rea":{"type":"local","command":["rea","mcp"]}}}', + ); + const original = '{"mcp":{"rea":{"environment":{"CUSTOM":"keep"}}}}'; + await writeFile(configPath, original); + expect( + await inspectClientConfiguration(client, {}, ["rea", "mcp"]), + ).toMatchObject({ status: "update" }); + // The write transaction must use a fresh source discovery after preflight. + await writeFile( + lower, + JSON.stringify({ + mcp: { rea: { type: "local", command: [command, "mcp"] } }, + }), + ); + const result = await configureClientConfiguration(client, {}, [ + "rea", + "mcp", + ]); + if (command === "other") { + expect(result).toMatchObject({ + status: "failed", + reason: "registration_ownership", + detail: expect.stringContaining("foreign ownership"), + }); + expect(await readFile(configPath, "utf8")).toBe(original); + await expect(readFile(`${configPath}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + } else { + expect(result).toMatchObject({ status: "configured" }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + } + }, +); diff --git a/tests/support/applicationSessionFixture.ts b/tests/support/applicationSessionFixture.ts index 4867d410f2..bdb5155342 100644 --- a/tests/support/applicationSessionFixture.ts +++ b/tests/support/applicationSessionFixture.ts @@ -1,3 +1,4 @@ +import { projectCliAnalysisResult } from "../../src/cli/projectCliAnalysisResult.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -53,7 +54,11 @@ export const writeApplicationZip = async ( export const inventoryApplicationPackage = async ( path: string, ): Promise => - parseEvidence(await runProviderAnalysis(path, "inventory_artifact", {})); + parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), + ); /** * Project the same inventory Evidence twice and return the schema-parsed @@ -93,6 +98,8 @@ export const createNonApplicationZipInventory = async ( await writer.add("one.js", new TextReader("one")); await writeFile(path, await writer.close()); return parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + await runProviderAnalysis(path, "inventory_artifact", {}).then( + projectCliAnalysisResult, + ), ); }; diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs index 42d11c8842..88742a5900 100644 --- a/tests/support/cli/cliCommandSessionWorker.mjs +++ b/tests/support/cli/cliCommandSessionWorker.mjs @@ -44,11 +44,13 @@ const { closeCliJavaScriptResources, createCli } = await import("../../../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../../../dist/cli-runtime/cliResultOutput.js"); -const { cliCompletionShell, splitIncurBuiltinArguments } = - await import("../../../dist/cli-runtime/cliBuiltinArguments.js"); +const { + cliCompletionShell, + normalizeCliArguments, + splitIncurBuiltinArguments, +} = await import("../../../dist/cli-runtime/cliBuiltinArguments.js"); const { cliConfigurationError, - normalizeCliArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput, parseCliOutputArguments, diff --git a/turbo.json b/turbo.json index 8b5ce3ae8e..e2b94188f9 100644 --- a/turbo.json +++ b/turbo.json @@ -92,28 +92,6 @@ "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" ] }, - "lint:built": { - "dependsOn": ["compile"], - "outputs": [".cache/static-checks/oxlint.json"], - "inputs": [ - ".gitignore", - ".oxlintrc.json", - "package.json", - "package-lock.json", - "scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "docs/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - ".github/actions/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "website/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "experiments/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}", - "!docs/.vitepress/cache/**", - "!docs/.vitepress/dist/**", - "!website/_site/**", - "!experiments/analysis-efficiency/results/**", - "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}" - ] - }, "format:check": {}, "knip": { "dependsOn": ["compile"],