From 25885b05c0c622ab2f9122a6e5cbf69f615311e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=BD=B3=E6=B5=A9Alben?= <35291372+XiaoBinGan@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:17:25 +0800 Subject: [PATCH] feat(v8): inspect V8 code cache containers and retained byte patterns (#8993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(v8): inspect V8 code cache containers and their retained byte patterns Bytenode-compiled `.jsc` files — the V8 code cache container that `vm.Script`, `vm.compileFunction` and bytenode 2.x write, and that Node single-executable applications and Electron apps ship — had no format-native reader. `inspect_binary_layout` shows the surrounding ELF/PE and `inspect_artifact` inventories the file, but neither decodes the V8 structures, so an agent could not tell which V8 line produced the file, could not learn the container is metadata rather than source, and had nothing to point at a version-pinned decompiler. Issue #8543. `inspect_v8_code_cache` joins the format-native family with `inspect_python_pyc`, `inspect_erlang_beam` and `inspect_kernel_module`: one explicit local file, bounded parsing, no execution, no installation, no child process. **The container is version-dependent, so the reader reports roles with confidence instead of assuming an ABI.** All eight header words are reported with byte offset, observed value, the role this reader believes the word has, and whether that role is `confirmed`, `inferred`, or `unconfirmed`. Only three facts gate admission: the header size this reader pins, the V8 code cache magic high half `0xc0de` (the low half is `ExternalReferenceTable::kSize` and varies per build, so it is a build fingerprint rather than a constant), and a declared payload length that reaches exactly the end of the file. I derived the field semantics from the producer rather than assuming them, and two findings changed the design: - **The word V8 calls the source hash holds the source length on the measured producers.** A 54-code-unit source stored `54`, and sources with the same code-unit length but different content stored the same value, including a source whose UTF-8 length was 154. The reader reports it as `source_length` and the docs say that this name is what the measurement supports, not a claim about what another build stores there. - **`vm.compileFunction` sets a high bit in that word.** The reader masks it out and reports the producer family as `script` or `compile_function`, so the source length stays correct for both producers. Embedded strings are reported as **byte-pattern observations with absolute byte ranges**, labelled as such in the field name and in the limitations. A reported sequence is not proof V8 retained it as a string, and a missing literal is not proof it is absent. **Fail-closed boundaries, all covered by tests:** an unknown magic is a typed `unsupported_target` failure because the file is not a V8 code cache; a short header or a payload-length disagreement is a typed `invalid_request` failure. Neither yields a partial parse and neither records Evidence. Non-goals are explicit in the tool description and the docs: the payload is serialized V8 heap data, not source; recovering logic needs a version-pinned View8-style decompiler that this tool neither provides nor depends on. Fixtures are generated from the running V8 instead of committing a version-pinned binary, so the tests exercise the real producer and stay valid across Node upgrades. - Semantic owner and earliest changed stage: format-native V8 code cache container decoding. - CLI and MCP/tool-catalog contract: one new `artifact-provider` tool and one new `inspect-v8-code-cache` CLI command, registered in `TOOL_CONTRACTS`, the `artifact` language family, `TOOL_EFFECTS`, the capability declarations and services, and the capability inventory. `docs:check` passes, so the generated catalog agrees. - Provider, bridge, target-format, or platform compatibility: no provider runs and no external tool is required, so the tool is available on every supported Node host. - Evidence, artifact, provenance, or reconstruction contract: complete Evidence with the payload byte range as its location; no artifact format, reader, or reconstruction change. - Generated metadata, package, or installation impact: catalog and document facts regenerated by the existing checks; no new dependency. Expected effects: - [x] Target content is returned as bounded structured facts. - `src/v8/V8CodeCacheParser.test.ts` (13 cases): real-producer header facts, per-field role and confidence, source length for both producers, an observation's absolute offset verified against the file bytes, the limitation text, foreign magic, payload-length mismatch, short header, missing payload bytes, a different build constant still admitted, and the observation budget. - `tests/boundary/mcp/v8CodeCacheMcp.test.ts` (4 cases): advertisement and annotations, header facts over MCP, relative-path rejection, unsupported magic, malformed container. - `tests/acceptance/v8CodeCacheCli.test.ts` (4 cases): the public CLI, relative-path resolution, byte-range observations, and the typed failure. - `npx vitest run src/contracts src/v8 src/application/v8CodeCache src/domain/v8` — 242 passed - `npx vitest run tests/boundary/mcp/v8CodeCacheMcp.test.ts tests/acceptance/v8CodeCacheCli.test.ts tests/boundary/filesystem/productCatalog.test.ts tests/acceptance/tools/localToolContracts.test.ts` — 20 passed, 4 skipped - `npx tsc --noEmit -p .` — exit 0 - `npm run build` — exit 0 - `npm run lint` — 0 errors - `npm run knip` — exit 0 - `npm run docs:check` — exit 0 - `npx oxfmt --check src tests docs` — exit 0 Gap: the header table is measured on node v24.22.1 / V8 13.6.233.17-node.58 only. No other V8 build is available here, so a different build's field order is untested; that is exactly why only three facts gate admission and the remaining roles carry a confidence level rather than being asserted. Additive. No existing tool, schema, or behaviour changes. The reader never executes the selected file, never launches a process, and never accesses the network; its audited effects are `mutatesSession: true` only, matching the other static format readers. Human owner: @XiaoBinGan - [x] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work. - [x] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps. - [x] I declare this PR ready for initial maintainer review. - [x] This PR addresses a concrete problem or an agreed enhancement. - [x] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. - [x] The PR has one focused outcome and the title follows `type(scope): outcome`. - [x] Related issue: #8543. - [x] Tests cover changed observable behavior and meaningful failure paths. - [x] Owning docs, contracts, and generated metadata are updated where needed. - [x] I checked the final diff for secrets, unrelated cleanup, and unsupported claims. * chore: retrigger title and readiness checks after the title was corrected * fix(v8): bound the payload scan and use the repository path conventions Review feedback from @greptile-apps on four points. **Bound the synchronous payload scan.** The scan is one synchronous pass over the payload, so the 512 MiB ceiling allowed a single request to hold the process for several seconds. The ceiling is now 64 MiB, matching the pinned CPython pyc reader, and the default is 32 MiB. The scan stays synchronous, like the BEAM and ELF note readers in this family, but the budget that bounds it is no longer large enough to matter. The new `max_file_bytes` ceiling and default are documented. **Use the absolute-path schema.** `path` now uses `absoluteLocalPathStringSchema` instead of `localPathStringSchema`, matching `inspect_erlang_beam`. A relative name previously passed validation and was resolved against the server's working directory, so `module.jsc` could select a different file than the caller meant. The rejection is now at the schema boundary. **State the extraction step.** The handoff in `docs/v8-code-cache.md` now says a cache embedded in a single-executable application or an Electron app must be extracted first, because this reader requires the magic at byte zero and returns `unsupported_target` for the surrounding ELF/PE executable. **Load only the owning contract group in tests.** Both tests now import `V8_CODE_CACHE_TOOL_CONTRACTS` directly instead of resolving the tool through the full catalog, so a focused test no longer constructs every other tool's schema. 21 cases pass across the parser, MCP boundary and CLI acceptance files; `tsc` 0; `build` 0; `knip` 0; `docs:check` 0; `oxfmt --check` 0. * fix(v8): select the tool contract by name in the focused tests Reverts the catalog-position selection I added for a review suggestion. This repository has a lint rule (`rea(no-positional-tool-contract)`) that forbids selecting a tool contract by catalog order, and `toolContract("inspect_v8_code_cache")` is how every neighbouring focused test selects one. The positional form I tried introduced two lint errors, so the named form stays. The review concern was that resolving through the full catalog constructs every other tool's schema. That cost is real but it is the repository's chosen trade: catalog ordering must never select an operation or schema, and `toolContract` is the named accessor that enforces it. * test(v8): cover the new tool in the absolute-path boundary test Main added `tests/boundary/mcp/absoluteLocalPathTools.test.ts` after this branch was opened. It enumerates every tool whose MCP `tools/list` schema must reject a relative path, and `inspect_v8_code_cache` uses `absoluteLocalPathStringSchema`, so the tool belongs in that list. Adding it keeps the enumerated field set complete instead of leaving one absolute-path tool untested. `tests/boundary/mcp` + v8 + contracts: 256 passing. `tsc` 0; `build` 0; `knip` 0; `docs:check` 0; `npm run lint` 0 errors. * perf(v8): read the payload by index instead of allocating per byte `for (const [index, byte] of payload.entries())` allocates a two-element array for every byte of the cache payload. On a 1 MiB payload that allocation dominated the scan. Measured with a real cache built from this parser, alternated so the two forms run against the same input: | payload | entries() | indexed reads | | --- | --- | --- | | 1 MiB | 15.5ms | 4.4ms | That is 3.49x on the scan, and the gap widens on smaller payloads where the allocation cost is a larger share (7.3x at 64 KiB in a synthetic run of the same shape). The scan is the whole cost of `inspect_v8_code_cache` once the file is read, so this is the tool's own hot loop. Output is unchanged: the parser returns one retained string for the 64 KiB fixture, and `src/v8` plus the CLI and MCP boundary files stay at 21 passing. `tsc` 0; `build` 0; `npm run lint` 0 errors. * fix(v8): admit only decimal tokens on the numeric CLI options Main added `tests/boundary/cli/cliDecimalArguments.test.ts` after this branch was opened. It enumerates every numeric CLI option and requires `0x10` and `1e3` to be rejected, because plain `Number` coercion would read them as 16 and 1000. This command's three options forwarded the domain fields unchanged, so the test failed on them: inspect-v8-code-cache options.maxFileBytes: Parser.parse accepts 1e3 inspect-v8-code-cache options.maxStrings: Parser.parse accepts 0x10 inspect-v8-code-cache options.maxStrings: Parser.parse accepts 1e3 inspect-v8-code-cache options.minStringBytes: Parser.parse accepts 0x10 `cliDecimalField` publishes the input view that parses the decimal token first and then lets the domain field own bounds, defaults and help, which is what the other numeric commands use. `tests/boundary/cli/cliDecimalArguments.test.ts` now passes. `tsc` 0; `build` 0; `npm run lint` 0 errors; `knip` 0; `docs:check` 0. * fix(v8): recognize module caches, and advise from remaining headroom Two review findings, each measured on Node 24.22.1 before changing anything. **A module cache was reported as a script.** `vm.SourceTextModule.createCachedData()` sets bit 30 of the source-length word, which the source-length mask `0x1fffffff` leaves out, and the producer check only tested the function bit at bit 29: | producer | source word | | --- | --- | | `vm.Script` | `0x00000013` for a 19-unit source | | `vm.compileFunction` | `0x20000009` for a 9-unit source | | `vm.SourceTextModule` | `0x40000013` for a 19-unit source | So a real module cache was labelled `script`, and `vm.Script` rejects that container. The producer enum gains `module` and the module bit is tested first; the source length is unchanged because both flags sit outside the mask. **The budget advice recommended a retry that could not work.** Both limits have ceilings — `max_strings` at 65536 and `min_string_bytes` at 256 — so an admitted file with more than 65536 runs of 256 bytes reached the branch while every suggested retry was a rejected input. `observationBudgetRemediation` now reads the remaining headroom and only recommends a change that is still admissible, falling back to selecting a smaller artifact when neither can move. The parser message states the fact and leaves recovery to the provider, which is the only place that can see both budgets. `MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES` names the `min_string_bytes` ceiling that the schema previously spelled inline, so the advice and the schema cannot drift. `docs/v8-code-cache.md` describes both flag bits. `src/v8` plus the CLI and MCP boundary files: 28 passing; 7 new cases fail on the base commit. `tsc` 0; `build` 0; `npm run lint` 0 errors; `knip` 0; `docs:check` 0. * refactor(v8): decide the budget recovery as data, and test the decision The remediation tests matched fragments of the advice sentence and banned old phrases. `docs/testing.md` asks for error codes, recorded limits and recovery behaviour instead of frozen prose, so a harmless reword could fail those tests while wrong advice passed them. `observationBudgetRecovery` now returns the decision — which retries remain within the schema's admitted range, and whether a smaller artifact is the only option — and `observationBudgetRemediation` renders one sentence from it. The tests assert the decision, so the wording is free to change and an incorrect choice cannot pass. This also fixes a bug in the previous shape: it took the `min_string_bytes` branch to mean "lower it", but that branch is reachable with the value already at the schema minimum, where lowering is not a legal retry. The decision now carries `canLowerMinStringBytes` and a `requiresSmallerArtifact` flag for that case. 5 cases, all failing on the base commit, covering both headroom states, either ceiling alone, and the run-length floor. `src/v8` plus the CLI and MCP boundary files: 28 passing. `tsc` 0; `build` 0; `npm run lint` 0 errors; `knip` 0; `docs:check` 0. * ci: rerun after the wakaru release download recovered The only failing job on this branch was `javascript_recovery / real-javascript-recovery`, which failed on `curl: (22) The requested URL returned error: 504` while downloading `wakaru-linux-x64.tar.gz`. The release is reachable again (HTTP 200), so this empty commit re-triggers the lane. * docs(v8): place the guide in the tree and record the feature The guide sat at `docs/v8-code-cache.md`, outside the `docs/guides/` tree every other guide uses, and neither index linked it, so it could not be reached from `docs/README.md` or the VitePress sidebar. Moved to `docs/guides/javascript/`, linked from both indexes, and recorded the feature under `docs/specs/v8-code-cache-inspection/` with `spec.md`, `plan.md` and `tasks.md`. The spec keeps only the premises that were measured on Node 24.22.1 — the three producer words and the retained-string layout — rather than restating the implementation. `npm run docs:check` 0; `npm run format:check` 0; `tsc` 0; `build` 0; `lint` 0 errors; `knip` 0. V8 reader, CLI and MCP files: 28 passing. * docs(v8): use the documented spec path and test the advice against its struct Two review findings. **The records were at `docs/specs/v8-code-cache-inspection/`.** `AGENTS.md:73` asks for `docs/specs///`, so they move to `docs/specs/v8/code-cache-inspection/`. **The verification section claimed more than the tests do.** It said the producer cases were built from real `vm.Script`, `vm.compileFunction` and `vm.SourceTextModule` output, and that the budget advice was compared with its owning formatter. In fact only `vm.Script` and `vm.compileFunction` are real producers — the module case sets the measured bit on a `vm.Script` cache — and only `observationBudgetRecovery` was asserted. Corrected the claim, and closed the second gap rather than only describing it: `observationBudgetRemediation` now has four cases holding the sentence to the struct it renders from. The sentence must not advise a knob the struct closed, and must mention a smaller artifact exactly when the struct requires one. Verified by making the sentence advise `max_strings` after the ceiling was reached — the test fails. Wording stays free; wrong advice does not. `npm run docs:check` 0; `npm run format:check` 0; `tsc` 0; `build` 0; `lint` 0 errors; `knip` 0. V8 reader, CLI, MCP, absolute-path and decimal-argument files: 34 passing. * fix(v8): restore the absolute-path contract entry after the rebase The rebase onto `main` conflicted in `tests/boundary/mcp/absoluteLocalPathTools.test.ts`: `main` gained `inspect_api_contract`, `inspect_devicetree`, `inspect_playwright_trace` and `inspect_python_pyc` in both the contract list and the path-field map while this branch added `inspect_v8_code_cache`. Both sides are additive, so both are kept. Without the resolution the branch carried conflict markers, which made `mergeable_state` `dirty` and every check on it meaningless. Re-verified after the rebase: `tsc` 0, `build` 0, `format:check` 0, `lint` 0 errors, `knip` 0, `docs:check` 0, and the V8 reader plus its CLI, MCP, absolute-path and decimal-argument boundary files at 34 passing. * test(v8): pin the producer from the runtime's own header word An independent reviewer ran these files on Node 22.23.4, 24.22.1 and 26.12.1. Only 22.23.4 failed, on `separates the compileFunction producer bit from the source length`, expecting `compile_function` and reading `script`. The cause is the fixture, not the parser: `vm.compileFunction(...).cachedData` carries `0x20000009` in the source-length word on Node 24.22.1, with bit 29 set, while Node 22.23.4 does not set that bit at all. Whether a given V8 build marks the word is a build detail, so asserting one build's spelling made the check version-dependent. The case now reads the word the runtime produced and asserts the parser maps it correctly, and keeps the part that must hold everywhere: the source length is the low bits, so it equals both `word & 0x1fffffff` and the source's length. The flag meanings themselves stay pinned by the synthetic `withHeaderWord` cases above, which do not depend on any build. Reverting the parser's `compile_function` arm fails this case and `keeps the function and module flags distinct`. `src/v8` plus the CLI, MCP and absolute-path boundary files: 24 and 34 passing. `tsc` 0; `build` 0; `npm run format:check` 0; `npm run lint` 0 errors; `knip` 0. * fix(v8): report an unflagged producer word as unknown, not as a script `vm.compileFunction` marks bit 29 of the source-length word on Node 24.22.1 (`0x20000004` for a 4-unit source) and does not mark it on Node 22.23.4 (`0x00000004` for the same call). Whether a V8 build marks a function cache is therefore a build detail, and an unflagged word is either a `vm.Script` cache or an unmarked function cache. The container carries nothing that separates the two. The reader reported `script` for that case, which is a confident claim the bytes do not support. My previous commit made the test accept whichever value the runtime produced, which recorded the ambiguity instead of resolving it in the contract. `unknown` is now a producer value, the unflagged word maps to it, and the observed word stays in `header.fields` for a caller that trusts a specific build fingerprint. A limitation line says the same in the report itself. `header.producer` widens to `["script", "compile_function", "module", "unknown"]`; `script` is no longer produced, because no unflagged word can be shown to be one. The spec's producer rule and its verification notes are updated to match. Reverting the mapping to `script` fails 2 of the 24 `src/v8` cases. `src/v8` with the CLI, MCP and absolute-path boundary files: 33 passing. `tsc` 0; `build` 0; `docs:check` 0; `npm run format:check` 0; `npm run lint` 0 errors. `knip` reports one unused export (`builtinDataEffectMethod`) on `main` as well, so it is not from this change. * fix(artifacts): retain completed identity across native parser failures * docs(v8): preserve unknown unflagged cache provenance --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/.vitepress/documentation.ts | 5 + docs/README.md | 1 + docs/guides/javascript/v8-code-cache.md | 121 +++++++ docs/specs/v8/code-cache-inspection/plan.md | 40 +++ docs/specs/v8/code-cache-inspection/spec.md | 58 ++++ docs/specs/v8/code-cache-inspection/tasks.md | 14 + scripts/lib/test-projects.mjs | 2 +- src/application/CapabilityInventory.ts | 1 + .../v8CodeCache/V8CodeCachePort.ts | 20 ++ .../v8CodeCache/V8CodeCacheService.ts | 101 ++++++ src/artifacts/StableArtifactFailure.ts | 4 +- src/artifacts/StableArtifactObservation.ts | 8 +- src/cli.ts | 2 + src/cli/v8CodeCacheCommands.ts | 66 ++++ src/cliCommandNames.ts | 1 + src/composition/capabilityDeclarations.ts | 8 + src/composition/capabilityServices.ts | 6 + src/composition/v8CodeCache.ts | 6 + src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.ts | 1 + src/contracts/toolFamilies.ts | 3 + src/contracts/v8/v8CodeCacheToolContracts.ts | 34 ++ src/domain/artifactOperationError.ts | 1 + src/domain/fileSnapshotObservation.ts | 2 +- src/domain/v8/v8CodeCache.ts | 227 +++++++++++++ src/domain/v8/v8CodeCacheBudget.ts | 5 + src/erlang/BeamProvider.ts | 17 +- src/firmware/devicetree/DevicetreeProvider.ts | 17 +- src/go/GoBinaryProvider.ts | 17 +- src/native/apple/AppleIpsProvider.ts | 12 +- src/native/elf/ElfNotesProvider.ts | 17 +- src/native/elf/KernelModuleProvider.ts | 12 +- src/native/exports/NativeExportsProvider.ts | 12 +- .../hardening/BinaryHardeningProvider.ts | 12 +- src/native/pe/PeCertificatesProvider.ts | 12 +- src/native/pe/PeImportsProvider.ts | 12 +- src/native/pe/PeLoadConfigProvider.ts | 12 +- src/native/pe/PeResourcesProvider.ts | 17 +- src/python/PythonPycProvider.ts | 17 +- src/rust/RustBinaryProvider.ts | 17 +- src/server/createServer.ts | 9 + src/server/registerV8CodeCacheTool.ts | 31 ++ src/v8/V8CodeCache.errors.ts | 24 ++ src/v8/V8CodeCache.fixture.ts | 26 ++ src/v8/V8CodeCacheParser.test.ts | 257 +++++++++++++++ src/v8/V8CodeCacheParser.ts | 187 +++++++++++ src/v8/V8CodeCacheProvider.test.ts | 143 +++++++++ src/v8/V8CodeCacheProvider.ts | 302 ++++++++++++++++++ src/v8/V8CodeCacheProviderIdentity.ts | 6 + .../stableArtifactParserFailureCli.test.ts | 45 +++ tests/acceptance/v8CodeCacheCli.test.ts | 148 +++++++++ .../mcp/absoluteLocalPathTools.test.ts | 4 + tests/boundary/mcp/v8CodeCacheMcp.test.ts | 142 ++++++++ 53 files changed, 2212 insertions(+), 54 deletions(-) create mode 100644 docs/guides/javascript/v8-code-cache.md create mode 100644 docs/specs/v8/code-cache-inspection/plan.md create mode 100644 docs/specs/v8/code-cache-inspection/spec.md create mode 100644 docs/specs/v8/code-cache-inspection/tasks.md create mode 100644 src/application/v8CodeCache/V8CodeCachePort.ts create mode 100644 src/application/v8CodeCache/V8CodeCacheService.ts create mode 100644 src/cli/v8CodeCacheCommands.ts create mode 100644 src/composition/v8CodeCache.ts create mode 100644 src/contracts/v8/v8CodeCacheToolContracts.ts create mode 100644 src/domain/v8/v8CodeCache.ts create mode 100644 src/domain/v8/v8CodeCacheBudget.ts create mode 100644 src/server/registerV8CodeCacheTool.ts create mode 100644 src/v8/V8CodeCache.errors.ts create mode 100644 src/v8/V8CodeCache.fixture.ts create mode 100644 src/v8/V8CodeCacheParser.test.ts create mode 100644 src/v8/V8CodeCacheParser.ts create mode 100644 src/v8/V8CodeCacheProvider.test.ts create mode 100644 src/v8/V8CodeCacheProvider.ts create mode 100644 src/v8/V8CodeCacheProviderIdentity.ts create mode 100644 tests/acceptance/stableArtifactParserFailureCli.test.ts create mode 100644 tests/acceptance/v8CodeCacheCli.test.ts create mode 100644 tests/boundary/mcp/v8CodeCacheMcp.test.ts diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index 02358ac553..d79ffaaeca 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -88,6 +88,11 @@ export const documentationSections = [ source: "guides/javascript/electron-observation.md", route: "/electron-observation", }, + { + text: "V8 code cache inspection", + source: "guides/javascript/v8-code-cache.md", + route: "/v8-code-cache", + }, ], }, { diff --git a/docs/README.md b/docs/README.md index 93aacf26a1..4be7663644 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ product pages. The VitePress documentation source is this `docs/` tree. - [Passive Node and Electron runtime observation](guides/javascript/javascript-runtime-observation.md) - [JavaScript static/runtime reconciliation](guides/javascript/javascript-runtime-reconciliation.md) - [Electron file-page observation](guides/javascript/electron-observation.md) +- [V8 code cache inspection](guides/javascript/v8-code-cache.md) ## Browser and captured websites diff --git a/docs/guides/javascript/v8-code-cache.md b/docs/guides/javascript/v8-code-cache.md new file mode 100644 index 0000000000..65dcde0d2f --- /dev/null +++ b/docs/guides/javascript/v8-code-cache.md @@ -0,0 +1,121 @@ +# V8 code cache inspection + +`inspect_v8_code_cache` reads one explicitly selected V8 code cache container as static +bytes: a bytenode `.jsc`, the bytes `vm.Script.createCachedData()` writes, or a cache +carried inside a Node single-executable application or an Electron app. It never executes +the file, never starts a V8 runtime, never installs bytenode, and never invokes a +decompiler. + +```bash +rea inspect-v8-code-cache /absolute/path/to/module.jsc --json +rea inspect-v8-code-cache /absolute/path/to/module.jsc --minStringBytes 8 --json +``` + +The MCP tool is `inspect_v8_code_cache` with the same inputs and the same retained +Evidence. + +## What is read + +A V8 code cache is a producer-written container, not a documented file format. This reader +admits one container shape and reports everything else as an observation, so a build it +has never seen still yields facts instead of a false parse. + +The 32-byte header is eight little-endian 32-bit words. Every word is reported in +`header.fields` with its byte offset, its observed value, the role this reader believes it +has, and how confident that role is: + +| Offset | Role | Status | +| -------------- | -------------- | ----------- | +| `0x00` | magic | confirmed | +| `0x04` | version hash | inferred | +| `0x08` | source length | confirmed | +| `0x0c` | flag hash | inferred | +| `0x10` | build constant | inferred | +| `0x14` | payload length | confirmed | +| `0x18`, `0x1c` | reserved | unconfirmed | + +`confirmed` means the role is demonstrated by the reader's own tests against the producer. +`inferred` means the word's behaviour is consistent with the role but a different producer +could plausibly differ. `unconfirmed` means the word is reported and nothing more is +claimed. + +Only three facts gate admission: + +1. the header size this reader pins (32 bytes); +2. the V8 code cache magic high half, `0xc0de`. The low half is + `ExternalReferenceTable::kSize` and varies per V8 build, so it is reported as a build + fingerprint and never required to match a fixed value; +3. the declared payload length must equal the bytes that follow the header exactly. + +`header.source_length` is the producer's recorded source length in UTF-16 code units. +V8's serializer calls this field the source hash; on the producers measured here its value +equals the source length exactly, including sources whose UTF-8 byte length differs, so the +reader reports it under the name the measurement supports. Treat that name as an +observation, not as a claim about what any other build stores there. +`header.producer` names the producer family from the flag bits in that word. Bit 29 +marks `vm.compileFunction` (`compile_function`); bit 30 marks `vm.SourceTextModule` +(`module`). When neither flag is present, the producer remains `unknown`: +`vm.Script`, bytenode output and older unflagged function caches cannot be +separated by those bits alone. The source-length mask is `0x1fffffff`, so both +flags sit outside the reported length. This describes the admitted header +profile rather than a version-independent V8 ABI. + +## Failed inspection and provenance + +After a stable read completes, format and observation-budget refusals retain a +`file-snapshot-hash` partial observation with the selected path, exact SHA-256 and +byte count. This proves which bytes were read; it does not claim the format was +successfully decoded. A refusal before read completion does not invent an identity. +Descriptor cleanup remains owned for retry if finalization fails. + +## Embedded byte patterns + +`embedded_strings` lists printable runs found in the payload, each with its absolute byte +offset in the selected file and its observed byte length. `min_string_bytes` (default 4) +sets the shortest reported run; `max_strings` caps how many may be reported, and exceeding +it fails without partial success. + +These are **byte-pattern observations inside serialized V8 heap data, not parsed heap +objects**. A reported run is not proof V8 retained it as a string. A literal that is absent +from the payload is not proof it is absent from the program: V8 may encode a string in a +form the run scan does not surface, and a string can be produced at runtime rather than +retained. + +A run longer than 4096 bytes is reported with `truncated: true` and a `retained_bytes` +prefix. + +## Limitations + +Every result states these, and they are part of the contract: + +- the payload is serialized V8 heap data, not source; no logic is recovered and no + bytecode is decompiled; +- recovering logic needs a version-pinned V8 decompiler (View8-style) that this tool + neither provides nor depends on; +- header roles beyond magic, source length, and payload length are version-dependent + observations; +- the tool never executes the selected file and never claims recovered source. + +## Failure behaviour + +An unknown magic is a typed `unsupported_target` failure, because the file is not a V8 +code cache. A short header, a payload-length disagreement, or bytes past the declared +payload is a typed `invalid_request` failure. Neither is a partial parse, and no Evidence +is recorded for them. + +`max_file_bytes` (default 32 MiB, ceiling 64 MiB) is the caller-selected read budget. The +payload SHA-256 is computed over the declared payload, so two containers with the same +payload length and content but different version hashes have different whole-file digests +and identical payload digests. + +## Relationship to other tools + +- `inspect_binary_layout` reads the surrounding ELF/PE container of a single-executable + application; it does not decode the embedded code cache. +- A cache embedded in a Node single-executable application or an Electron app must be + **extracted first**: this reader admits a container whose magic is at byte zero, so + passing the surrounding ELF/PE executable here returns `unsupported_target`. Run + `extract_artifact` on the application, then pass the extracted `.jsc` path to this tool. +- `inspect_artifact` inventories the file without decoding V8 structures. +- This tool is in the same family as `inspect_python_pyc`, `inspect_erlang_beam`, and + `inspect_kernel_module`: one explicit local file, bounded parsing, no execution. diff --git a/docs/specs/v8/code-cache-inspection/plan.md b/docs/specs/v8/code-cache-inspection/plan.md new file mode 100644 index 0000000000..d780e5ad01 --- /dev/null +++ b/docs/specs/v8/code-cache-inspection/plan.md @@ -0,0 +1,40 @@ +# Plan + +## Shape + +The container format is the only thing this feature knows, so the parser owns it and +everything else consumes its facts. + +- `src/domain/v8/v8CodeCache.ts` holds the schema, the bit constants and the bounds, so a + reader and a test share one definition of what a container is. +- `src/v8/V8CodeCacheParser.ts` decodes a `Buffer` into retained strings and producer + facts. It is given bytes only; it never opens a file. +- `src/v8/V8CodeCacheProvider.ts` owns the observation budget, the file read and the + remediation text. It is the boundary that turns bytes into a reported fact. +- `src/cli/v8CodeCacheCommands.ts` and the MCP registration are thin adapters over the + provider, so both surfaces report the same facts. + +## Measured premises + +Each of these was measured on Node 24.22.1 before the parser was written, because the +container is undocumented and the layout had to be derived rather than assumed: + +- `vm.Script.createCachedData()` writes `0x…|length` for its producer word. +- `vm.compileFunction` sets `0x20000000`, and `vm.SourceTextModule` sets `0x40000000`. +- The payload retains UTF-8 strings above the threshold, and the retained set grows with + the source's literals rather than with the bytecode size. + +## Order + +1. Pin the format in the domain schema so later steps cannot disagree about it. +2. Write the parser against the measured producer words. +3. Add the provider, the budget and its remediation. +4. Expose the CLI command, then the MCP tool, over the same provider. +5. Document the command and the report, and link the guide from both indexes. + +## Budget + +The retained-string scan is the only unbounded part of the read, so it is bounded on three +axes at once: a maximum string count, a minimum string length, and a maximum artifact size. +The provider reports which of the three was reached and whether raising or lowering it is +the caller's next step, so a refusal is actionable rather than a bare truncation. diff --git a/docs/specs/v8/code-cache-inspection/spec.md b/docs/specs/v8/code-cache-inspection/spec.md new file mode 100644 index 0000000000..bca7465571 --- /dev/null +++ b/docs/specs/v8/code-cache-inspection/spec.md @@ -0,0 +1,58 @@ +# Inspect a V8 code cache container + +## Outcome + +A bytenode `.jsc`, the bytes `vm.Script.createCachedData()` writes, and a code cache +carried inside a Node single-executable application or an Electron application are all +opaque today: the analysis reports the containing file and nothing about the cache. An +analyst has to install bytenode, guess a V8 version, and run the file to learn anything, +which executes attacker-controlled code and changes the state under study. + +`rea inspect-v8-code-cache [--minStringBytes ]` reads one explicitly selected +container as static bytes and reports its producer, the strings it retains above a +threshold, and the recoverable source metadata, without executing the file, starting a +V8 runtime, installing bytenode, or invoking a decompiler. The same facts are available +through the `inspect_v8_code_cache` MCP tool. + +## Requirements + +- The reader accepts a file whose first words describe a V8 code cache header; anything + else is refused before the payload is walked, so an unrelated file cannot be read as a + cache by accident. +- The producer is decided from the header's producer bits, not from the file extension: + a module bit means `vm.SourceTextModule`, a compile-function bit means + `vm.compileFunction`, and neither bit means `unknown`. The unflagged case is not + `vm.Script` by inference — whether V8 marks a function cache is a build detail. A + `vm.compileFunction` cache reads `0x20000004` for a 4-unit source on Node 24.22.1 and + `0x00000004` on Node 22.23.4, so an unflagged word could be either producer. The + observed word stays in `header.fields` for a caller that trusts a specific build + fingerprint. +- Retained strings are reported above the caller's byte threshold, with + `--minStringBytes` bounded so a pathological threshold cannot make the read unbounded. +- Observation is bounded by a budget that reports which bound it hit and how to raise it, + rather than silently truncating. +- The container's file location is reported as given, and the payload is never written + back or repaired. + +## Non-goals + +- Decoding the payload back into source. The container's bytecode is version-specific and + the tool does not attempt to reconstruct source from it. +- Executing the container to obtain runtime facts. +- Recognising an encrypted or compressed wrapper around a cache. + +## Verification + +- The `vm.Script` producer word and its retained strings come from output this suite + generates through `vm.Script.createCachedData()`, and the `vm.compileFunction` case + compiles through `vm.compileFunction` for the same reason. +- The `vm.SourceTextModule` producer bit is checked by setting the measured bit on a real + `vm.Script` cache rather than by compiling through `vm.SourceTextModule`, so that case + covers the bit's meaning and not a cache from that producer. +- The CLI, MCP, absolute-path and decimal-argument boundary contracts have their own + tests. +- The budget tests call `observationBudgetRecovery` and assert its structured facts — the + boundary reached and which knob may move. `observationBudgetRemediation` renders that + struct into a sentence, so it is held to the struct rather than to its wording: the + sentence must not advise a knob the struct closed, and must mention a smaller artifact + exactly when the struct requires one. Rewording stays free; wrong advice does not. diff --git a/docs/specs/v8/code-cache-inspection/tasks.md b/docs/specs/v8/code-cache-inspection/tasks.md new file mode 100644 index 0000000000..dd509ccb6f --- /dev/null +++ b/docs/specs/v8/code-cache-inspection/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Measure the producer words and the retained-string layout on Node 24.22.1. +- [x] Add the domain schema, the bit constants and the observation bounds. +- [x] Add the parser, reading the payload by index into the integer table. +- [x] Add the provider with the budget and its remediation. +- [x] Add the CLI command and register the MCP tool over the same provider. +- [x] Document the command, the report and the bounds. +- [x] Link the guide from `docs/README.md` and the VitePress index. +- [x] Add parser, CLI, MCP, absolute-path and decimal-argument tests. +- [x] Hold the budget advice to the recovery struct rather than to its wording. +- [x] Run the static, formatting, duplication, documentation and test gates. +- [x] Address the independent review. diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index af4c70630e..55ff667bec 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -19,7 +19,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,desktop,dotnet,electron,erlang,evm,firmware,flutter,frida,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,providers,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,desktop,dotnet,electron,erlang,evm,firmware,flutter,frida,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,providers,python,reference,rust,server,sqlite,v8,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a32b375d29..c36a664662 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -543,6 +543,7 @@ const workflowAvailabilityFor = ( name === "inspect_python_pyc" || name === "inspect_apple_ips" || name === "inspect_erlang_beam" || + name === "inspect_v8_code_cache" || name === "inspect_electron_fuses" ) return { reason: "available", remediation: null }; diff --git a/src/application/v8CodeCache/V8CodeCachePort.ts b/src/application/v8CodeCache/V8CodeCachePort.ts new file mode 100644 index 0000000000..a00aaf1604 --- /dev/null +++ b/src/application/v8CodeCache/V8CodeCachePort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; +import type { + InspectV8CodeCacheInput, + V8CodeCache, +} from "../../domain/v8/v8CodeCache.js"; + +/** Stable read and V8 code cache container decoding, independent of a V8 runtime. */ +export interface V8CodeCachePort { + readonly identity: ProviderIdentity; + close(): Promise; + inspect( + input: InspectV8CodeCacheInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/v8CodeCache/V8CodeCacheService.ts b/src/application/v8CodeCache/V8CodeCacheService.ts new file mode 100644 index 0000000000..f927acf23f --- /dev/null +++ b/src/application/v8CodeCache/V8CodeCacheService.ts @@ -0,0 +1,101 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { V8CodeCachePort } from "./V8CodeCachePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectV8CodeCacheInputSchema, + v8CodeCacheSchema, + type V8CodeCache, +} from "../../domain/v8/v8CodeCache.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; + +const OPERATION = "inspect_v8_code_cache"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class V8CodeCacheService { + constructor(readonly provider: V8CodeCachePort) {} + + close(): Promise { + return this.provider.close(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const input = parseAnalysisInput( + OPERATION, + rawInput, + inspectV8CodeCacheInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); + if (!result.ok) return result; + const report = v8CodeCacheSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.value.path || + !validReport(report.data, input.value) + ) + return err( + new AnalysisOutputError( + OPERATION, + "V8 code cache provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.value, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [ + { kind: "artifact-path" as const, path: value.artifact.path }, + { + kind: "file-offset-range" as const, + start: value.payload.offset, + end: value.payload.offset + value.payload.bytes, + }, + ], + }, + ), + options?.signal, + ); + } +} + +const validReport = ( + report: V8CodeCache, + input: { + max_file_bytes: number; + max_strings: number; + min_string_bytes: number; + }, +): boolean => { + if (report.artifact.bytes > input.max_file_bytes) return false; + if (report.embedded_strings.length > input.max_strings) return false; + if ( + report.payload.offset + report.payload.bytes !== report.artifact.bytes || + report.payload.offset !== report.header.header_bytes + ) + return false; + return report.embedded_strings.every( + (observation) => + observation.bytes >= input.min_string_bytes && + observation.offset >= report.payload.offset && + observation.offset + observation.bytes <= report.artifact.bytes, + ); +}; diff --git a/src/artifacts/StableArtifactFailure.ts b/src/artifacts/StableArtifactFailure.ts index 8265aeb15d..17bd02e64f 100644 --- a/src/artifacts/StableArtifactFailure.ts +++ b/src/artifacts/StableArtifactFailure.ts @@ -1,3 +1,4 @@ +import type { StableArtifactSnapshot } from "./readStableArtifact.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -52,8 +53,9 @@ export const projectStableArtifactFailure = ( path: string, providerId: string, operation: string, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { - const observation = completedStableArtifactObservation(cause, path); + const observation = completedStableArtifactObservation(cause, path, snapshot); if (observation !== undefined) primary.retainPartialObservation(observation); const cleanup = artifactCleanupFailure(cause, providerId, operation); if (cleanup === undefined) return primary; diff --git a/src/artifacts/StableArtifactObservation.ts b/src/artifacts/StableArtifactObservation.ts index f177d947f0..8185e9e437 100644 --- a/src/artifacts/StableArtifactObservation.ts +++ b/src/artifacts/StableArtifactObservation.ts @@ -2,14 +2,18 @@ import { fileSnapshotPartialObservationSchema, type FileSnapshotPartialObservation, } from "../domain/fileSnapshotObservation.js"; -import { completedStableArtifact } from "./readStableArtifact.js"; +import { + type StableArtifactSnapshot, + completedStableArtifact, +} from "./readStableArtifact.js"; /** Completed file identity survives finalization; it does not establish decoded content. */ export const completedStableArtifactObservation = ( cause: unknown, path: string, + completedSnapshot?: StableArtifactSnapshot, ): FileSnapshotPartialObservation | undefined => { - const snapshot = completedStableArtifact(cause); + const snapshot = completedSnapshot ?? completedStableArtifact(cause); return snapshot === undefined ? undefined : fileSnapshotPartialObservationSchema.parse({ diff --git a/src/cli.ts b/src/cli.ts index 20ad639049..2b04626d16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { registerIl2cppCommands } from "./cli/il2cppCommands.js"; import { registerRustCommands } from "./cli/rustCommands.js"; import { registerPythonPycCommands } from "./cli/pythonPycCommands.js"; import { registerErlangCommands } from "./cli/erlangCommands.js"; +import { registerV8CodeCacheCommands } from "./cli/v8CodeCacheCommands.js"; import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; @@ -211,6 +212,7 @@ export const createCli = ( registerRustCommands(cli, logger); registerPythonPycCommands(cli, logger); registerErlangCommands(cli, logger); + registerV8CodeCacheCommands(cli, logger); registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); }, diff --git a/src/cli/v8CodeCacheCommands.ts b/src/cli/v8CodeCacheCommands.ts new file mode 100644 index 0000000000..5c76991309 --- /dev/null +++ b/src/cli/v8CodeCacheCommands.ts @@ -0,0 +1,66 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { createV8CodeCacheService } from "../composition/v8CodeCache.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { cliDecimalField } from "./options.js"; +import { inspectV8CodeCacheInputSchema } from "../domain/v8/v8CodeCache.js"; +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; + +/** Inspect operator-selected V8 code caches through the same shared workflow as MCP. */ +export const registerV8CodeCacheCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectV8CodeCache, { + description: + "Inspect a V8 code cache header and its embedded printable byte patterns without executing it", + args: z.object({ + path: localPathStringSchema.describe("Local V8 code cache (.jsc) file"), + }), + options: z.object({ + // Decimal tokens only: numeric coercion would read "0x10" as 16 and "1e3" as + // 1000. The shared fields keep their bounds, defaults and help. + maxFileBytes: cliDecimalField( + inspectV8CodeCacheInputSchema.shape.max_file_bytes, + ), + maxStrings: cliDecimalField( + inspectV8CodeCacheInputSchema.shape.max_strings, + ), + minStringBytes: cliDecimalField( + inspectV8CodeCacheInputSchema.shape.min_string_bytes, + ), + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const v8CodeCache = createV8CodeCacheService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectV8CodeCache, + async () => + finishCliEvidenceCommand( + args.path, + () => + v8CodeCache.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_strings: options.maxStrings, + min_string_bytes: options.minStringBytes, + }, + { signal }, + ), + v8CodeCache, + "rea-v8-code-cache", + "inspect_v8_code_cache", + signal, + ).then(projectCliAnalysisResult), + ); + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index f80a9f97af..29e947b959 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -75,6 +75,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectRustBinary: "inspect-rust-binary", inspectPythonPyc: "inspect-python-pyc", inspectErlangBeam: "inspect-erlang-beam", + inspectV8CodeCache: "inspect-v8-code-cache", inspectPeResources: "inspect-pe-resources", inspectPeCertificates: "inspect-pe-certificates", inspectPeImports: "inspect-pe-imports", diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index e801eb65c1..80ce1da280 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -51,6 +51,7 @@ import { } from "../domain/webTraceProviders.js"; import { ELECTRON_FUSE_PROVIDER_IDENTITY } from "../electron/ElectronFuseProviderIdentity.js"; import { ERLANG_BEAM_PROVIDER_IDENTITY } from "../erlang/ErlangBeamProviderIdentity.js"; +import { V8_CODE_CACHE_PROVIDER_IDENTITY } from "../v8/V8CodeCacheProviderIdentity.js"; import { EVMOLE_PROVIDER_IDENTITY } from "../evm/EvmoleProviderIdentity.js"; import { BINWALK_PROVIDER_IDENTITY, @@ -448,6 +449,13 @@ export const CAPABILITY_DECLARATIONS = { }), ], }, + v8CodeCache: { + providers: [ + providerBinding(V8_CODE_CACHE_PROVIDER_IDENTITY, { + group: "V8_CODE_CACHE_TOOL_CONTRACTS", + }), + ], + }, recordedCrash: { providers: [ providerBinding(PWNTOOLS_PROVIDER_IDENTITY, ["inspect_recorded_crash"]), diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 8e8215c22f..8a3368367f 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -30,6 +30,7 @@ import { DesktopBuildComputers } from "../application/desktop/DesktopBuildComput import { createGoBinaryService } from "./go.js"; import { createRustBinaryService } from "./rust.js"; import { createBeamService } from "./erlang.js"; +import { createV8CodeCacheService } from "./v8CodeCache.js"; import { createModelArtifactService } from "./model.js"; import { createIl2cppMetadataService } from "./il2cpp.js"; import { createElectronFuseService } from "./electronFuses.js"; @@ -95,6 +96,7 @@ import type { ElectronFuseService } from "../application/electron/ElectronFuseSe import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; import type { PythonPycService } from "../application/python/PythonPycService.js"; import type { BeamService } from "../application/erlang/BeamService.js"; +import type { V8CodeCacheService } from "../application/v8CodeCache/V8CodeCacheService.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; @@ -130,6 +132,7 @@ export interface CapabilityServiceOptions { readonly rustBinary?: RustBinaryService; readonly pythonPyc?: PythonPycService; readonly erlangBeam?: BeamService; + readonly v8CodeCache?: V8CodeCacheService; readonly electronFuses?: ElectronFuseService; readonly recordedCrash?: RecordedCrashService; readonly firmwareAnalysis?: FirmwareAnalysisPort; @@ -337,6 +340,9 @@ const modules = { erlangBeam: service( ({ options }) => options.erlangBeam ?? createBeamService(), ), + v8CodeCache: service( + ({ options }) => options.v8CodeCache ?? createV8CodeCacheService(), + ), modelArtifact: service(() => createModelArtifactService()), gpuCodeObjects: service(() => createGpuCodeObjectsService()), il2cppMetadata: service( diff --git a/src/composition/v8CodeCache.ts b/src/composition/v8CodeCache.ts new file mode 100644 index 0000000000..b4bcffa622 --- /dev/null +++ b/src/composition/v8CodeCache.ts @@ -0,0 +1,6 @@ +import { V8CodeCacheService } from "../application/v8CodeCache/V8CodeCacheService.js"; +import { V8CodeCacheProvider } from "../v8/V8CodeCacheProvider.js"; + +/** Compose bundled V8 code cache inspection without a V8 runtime or any child process. */ +export const createV8CodeCacheService = (): V8CodeCacheService => + new V8CodeCacheService(new V8CodeCacheProvider()); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 00e213b942..3a7b6608da 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -13,6 +13,7 @@ import { IL2CPP_TOOL_CONTRACTS } from "./il2cpp/il2cppToolContracts.js"; import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js"; import { PYTHON_PYC_TOOL_CONTRACTS } from "./python/pythonPycToolContracts.js"; import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; +import { V8_CODE_CACHE_TOOL_CONTRACTS } from "./v8/v8CodeCacheToolContracts.js"; import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; @@ -63,6 +64,7 @@ export const TOOL_CONTRACTS = [ ...RUST_TOOL_CONTRACTS, ...PYTHON_PYC_TOOL_CONTRACTS, ...ERLANG_BEAM_TOOL_CONTRACTS, + ...V8_CODE_CACHE_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, ...APK_SIGNATURE_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 5f7f0ffc17..55cebccab2 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -295,6 +295,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_apple_ips: evidence, inspect_devicetree: evidence, inspect_kernel_module: evidence, + inspect_v8_code_cache: evidence, inspect_pe_load_config: evidence, inspect_binary_hardening: evidence, inspect_analysis_view: evidence, diff --git a/src/contracts/toolFamilies.ts b/src/contracts/toolFamilies.ts index 034d0919c6..234bbb41dc 100644 --- a/src/contracts/toolFamilies.ts +++ b/src/contracts/toolFamilies.ts @@ -18,6 +18,7 @@ import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; import { APK_SIGNATURE_TOOL_CONTRACTS } from "./apkSignature/apkSignatureToolContracts.js"; import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; +import { V8_CODE_CACHE_TOOL_CONTRACTS } from "./v8/v8CodeCacheToolContracts.js"; import { PYTHON_PYC_TOOL_CONTRACTS } from "./python/pythonPycToolContracts.js"; import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js"; import { DESKTOP_BUILD_TOOL_CONTRACTS } from "./desktopBuildToolContracts.js"; @@ -95,6 +96,7 @@ export const TOOL_CONTRACT_GROUPS = { SESSION_TOOL_CONTRACTS, SIGNAL_TOOL_CONTRACTS, SQLITE_TOOL_CONTRACTS, + V8_CODE_CACHE_TOOL_CONTRACTS, WASM_TOOL_CONTRACTS, WEB_NETWORK_CAPTURE_TOOL_CONTRACTS, WEB_RUNTIME_TOOL_CONTRACTS, @@ -152,6 +154,7 @@ export const TOOL_FAMILY_DEFINITIONS = [ ...TOOL_CONTRACT_GROUPS.RUST_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.PYTHON_PYC_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.ERLANG_BEAM_TOOL_CONTRACTS, + ...TOOL_CONTRACT_GROUPS.V8_CODE_CACHE_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.WASM_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.APK_SIGNATURE_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.SQLITE_TOOL_CONTRACTS, diff --git a/src/contracts/v8/v8CodeCacheToolContracts.ts b/src/contracts/v8/v8CodeCacheToolContracts.ts new file mode 100644 index 0000000000..c4fec1d2a9 --- /dev/null +++ b/src/contracts/v8/v8CodeCacheToolContracts.ts @@ -0,0 +1,34 @@ +import { + inspectV8CodeCacheInputSchema, + v8CodeCacheSchema, +} from "../../domain/v8/v8CodeCache.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; + +/** Static V8 code cache inspection is independent of active native-analysis providers. */ +export const V8_CODE_CACHE_TOOL_CONTRACTS = [ + { + name: "inspect_v8_code_cache", + ...toolContractMetadata("inspect_v8_code_cache"), + kind: "artifact-provider", + description: + "Inspect one explicit local V8 code cache container (a bytenode .jsc, the bytes vm.Script.createCachedData writes, or a cache embedded in a Node single-executable application or Electron app) as static bytes, with no child process, no bytenode dependency, and no decompiler. Returns the artifact path/SHA-256/byte length, the eight observed header words each with its role and whether that role is confirmed, inferred, or unconfirmed, the observed magic and the build-stable high half, the producer family, the recorded source length, the payload offset/length/SHA-256, and printable byte sequences inside the payload with absolute byte ranges. Admission is narrow: a header this reader pins, the V8 code cache magic high half, and a payload length that reaches exactly the end of the file; an unknown magic, a short header, or a payload-length disagreement returns a typed failure and never a partial parse. Header field roles other than magic, source length, and payload length are inferred from one producer and are version-dependent, so they are reported as observations rather than a fixed ABI. Embedded strings are byte-pattern observations inside serialized V8 heap data, not parsed heap objects: a reported sequence is not proof V8 retained it as a string and a missing literal is not proof it is absent. The payload is not source; recovering logic needs a version-pinned View8-style decompiler that this tool neither provides nor depends on. Reads a stable regular file; exhausted observation budgets return no partial success. Portable on supported Node hosts; no V8 runtime is required.", + inputSchema: inspectV8CodeCacheInputSchema, + outputSchema: evidenceResultOf(v8CodeCacheSchema), + examples: [ + { + title: "Identify the V8 producer of a bytenode code cache", + input: { path: "/artifacts/module.jsc" }, + }, + { + title: "Locate retained config keys in the payload", + input: { + path: "/artifacts/module.jsc", + min_string_bytes: 8, + max_strings: 256, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 85b548d9b2..a53d7a9536 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -38,6 +38,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_playwright_trace" | "inspect_kernel_module" | "inspect_erlang_beam" + | "inspect_v8_code_cache" | "extract_artifact" | "decode_interface_builder" | "inspect_asset_catalog" diff --git a/src/domain/fileSnapshotObservation.ts b/src/domain/fileSnapshotObservation.ts index 3df6fac533..a57c537147 100644 --- a/src/domain/fileSnapshotObservation.ts +++ b/src/domain/fileSnapshotObservation.ts @@ -3,7 +3,7 @@ import { z } from "zod"; const byteCount = z.number().int().nonnegative().safe(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); -/** Completed immutable-file facts retained when read finalization fails. */ +/** Completed immutable-file facts retained when finalization or later interpretation fails. */ // Null identity fields mean caller-declared exclusion, never an absent file or // sensitivity inferred from its name. Unmarked identities remain exact. export const fileSnapshotPartialObservationSchema = z.discriminatedUnion( diff --git a/src/domain/v8/v8CodeCache.ts b/src/domain/v8/v8CodeCache.ts new file mode 100644 index 0000000000..a6f86cf3b4 --- /dev/null +++ b/src/domain/v8/v8CodeCache.ts @@ -0,0 +1,227 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const offset = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); + +/** + * Hard ceiling on the caller-selected input read budget. The payload scan is a + * single synchronous pass, so this bounds how long one request can hold the + * process; it matches the pinned CPython pyc reader's 64 MiB ceiling. + */ +export const MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES = 67108864; +/** Hard ceiling on the V8 code cache container header this reader accepts. */ +export const V8_CODE_CACHE_HEADER_BYTES = 32; +/** + * V8 stamps every serialized code cache with `0xC0DE0000 ^ ExternalReferenceTable::kSize`. + * Only the high half is stable across builds, so the low half is a build fingerprint and + * is never used as an admission condition. + */ +export const V8_CODE_CACHE_MAGIC_HIGH = 0xc0de; +/** Hard ceiling on reported embedded byte-pattern observations. */ +export const MAX_INSPECT_V8_CODE_CACHE_STRINGS = 65536; +/** Shortest printable run reported as a byte-pattern observation. */ +export const MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES = 4; + +/** Upper bound the input schema admits for `min_string_bytes`. */ +export const MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES = 256; +/** Longest single reported run; longer runs are truncated and said to be truncated. */ +export const MAX_INSPECT_V8_CODE_CACHE_STRING_BYTES = 4096; +/** `vm.compileFunction` sets this bit in the source-length word. */ +export const V8_CODE_CACHE_COMPILE_FUNCTION_BIT = 0x20000000; + +/** + * Bit 30 of the source-length word marks `vm.SourceTextModule` output. + * + * Measured on Node 24.22.1: a module cache reads `0x40000013` for a 19-unit source and a + * script cache reads `0x00000013` for the same length. The source-length mask is + * `0x1fffffff`, so this bit and the function bit are both outside it. + */ +export const V8_CODE_CACHE_MODULE_BIT = 0x40000000; +/** Source length occupies the low bits of that word. */ +export const V8_CODE_CACHE_SOURCE_LENGTH_MASK = 0x1fffffff; + +/** Bounded inspection of one V8 code cache container. Never executes the payload. */ +export const inspectV8CodeCacheInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to the selected V8 code cache (.jsc) file", + ), + max_file_bytes: z + .number() + .int() + .min(36) + .max(MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES) + .default(32 * 1024 * 1024) + .describe( + "Maximum stable artifact bytes to read; exceeding the budget fails without partial success", + ), + max_strings: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_V8_CODE_CACHE_STRINGS) + .default(4096) + .describe( + "Maximum embedded byte-pattern observations to report; exceeding the budget fails without partial success", + ), + min_string_bytes: z + .number() + .int() + .min(MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES) + .max(MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES) + .default(MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES) + .describe("Shortest printable run reported as one observation"), +}); + +export type InspectV8CodeCacheInput = z.infer< + typeof inspectV8CodeCacheInputSchema +>; + +/** Every header role is reported as observed, with how confident that role is. */ +export const V8_CODE_CACHE_FIELD_STATUSES = [ + "confirmed", + "inferred", + "unconfirmed", +] as const; + +/** + * One 32-bit header word. `role` names what the reader believes the word is; `status` + * says whether that role is demonstrated, inferred from behaviour, or merely observed. + * The container is version-dependent, so this table is data, not a fixed ABI. + */ +const headerFieldSchema = z.strictObject({ + /** Byte offset of this word inside the container header. */ + offset: uint, + /** The observed little-endian value. */ + value: uint, + role: z.string().min(1), + status: z.enum(V8_CODE_CACHE_FIELD_STATUSES), +}); + +const headerSchema = z.strictObject({ + /** The full observed magic word. Its low half is a V8 build fingerprint. */ + magic: uint, + /** High half of the magic, the only part stable across V8 builds. */ + magic_build_stable: uint, + /** Every header word, in container order. */ + fields: z.array(headerFieldSchema).length(8), + /** Bytes this reader treats as header before the payload begins. */ + header_bytes: uint, + /** Source length in UTF-16 code units, as recorded by the producer, if determinable. */ + source_length: uint.nullable(), + /** + * Producer family observed from the source-length word. + * + * V8 sets bit 29 for `vm.compileFunction` and bit 30 for `vm.SourceTextModule`, the two + * upper bits the source-length mask leaves out. Measured on Node 24.22.1: a module cache + * reads `0x40000013` for a 19-unit source, so a producer check that only looks at the + * function bit reports a module cache as a plain script. + * + * `unknown` is the unflagged case, and it is not `vm.Script` for certain: whether V8 + * marks a function cache at all is a build detail. Measured on Node 24.22.1 a + * `vm.compileFunction` cache reads `0x20000004` for a 4-unit source while Node 22.23.4 + * writes `0x00000004` for the same call. An unflagged word is therefore either + * `vm.Script` or a `vm.compileFunction` cache from a build that does not mark it, and + * the file carries nothing that separates the two. + */ + producer: z.enum(["script", "compile_function", "module", "unknown"]), +}); + +/** One printable run found in the payload. An observation, not a decoded heap object. */ +const embeddedStringSchema = z.strictObject({ + /** Absolute byte offset in the selected file. */ + offset, + bytes: uint, + /** How many bytes were retained; shorter than `bytes` when the run was truncated. */ + retained_bytes: uint, + truncated: z.boolean(), + text: z.string(), +}); + +export const v8CodeCacheSchema = z + .strictObject({ + artifact: z.strictObject({ + path: absoluteLocalPathStringSchema, + sha256: digestSchema, + bytes: offset, + }), + header: headerSchema, + payload: z.strictObject({ + offset, + bytes: offset, + sha256: digestSchema, + /** Header bytes after the container header that are not part of the declared payload. */ + trailing_bytes: offset, + }), + /** + * Byte-pattern observations inside the payload. Presence of a literal here is not + * proof V8 retained it as a heap string, and absence is not proof it did not. + */ + embedded_strings: z.array(embeddedStringSchema), + coverage: z.strictObject({ + status: z.literal("complete"), + /** Payload bytes examined for printable runs. */ + scanned_bytes: offset, + strings: uint, + }), + limitations: z.array(z.string().min(1)).min(1), + }) + .superRefine((report, context) => { + if (report.header.header_bytes !== V8_CODE_CACHE_HEADER_BYTES) + context.addIssue({ + code: "custom", + path: ["header", "header_bytes"], + message: "This reader only accepts its pinned container header size.", + }); + if (report.header.magic_build_stable !== V8_CODE_CACHE_MAGIC_HIGH) + context.addIssue({ + code: "custom", + path: ["header", "magic_build_stable"], + message: + "The admitted magic must carry the stable V8 code cache high half.", + }); + if (report.payload.offset + report.payload.bytes !== report.artifact.bytes) + context.addIssue({ + code: "custom", + path: ["payload", "bytes"], + message: + "The declared payload length must reach the end of the selected file.", + }); + if (report.coverage.strings !== report.embedded_strings.length) + context.addIssue({ + code: "custom", + path: ["coverage", "strings"], + message: + "Observation coverage disagrees with the listed embedded strings.", + }); + report.embedded_strings.forEach((observation, index) => { + if (observation.retained_bytes > observation.bytes) + context.addIssue({ + code: "custom", + path: ["embedded_strings", index, "retained_bytes"], + message: "A retained prefix cannot exceed the observed run length.", + }); + if ( + observation.truncated !== + observation.retained_bytes < observation.bytes + ) + context.addIssue({ + code: "custom", + path: ["embedded_strings", index, "truncated"], + message: "Truncation must agree with the retained byte count.", + }); + if ( + observation.offset < report.payload.offset || + observation.offset + observation.bytes > report.artifact.bytes + ) + context.addIssue({ + code: "custom", + path: ["embedded_strings", index, "offset"], + message: "An observation must lie inside the declared payload.", + }); + }); + }); + +export type V8CodeCache = z.infer; diff --git a/src/domain/v8/v8CodeCacheBudget.ts b/src/domain/v8/v8CodeCacheBudget.ts new file mode 100644 index 0000000000..3a3823633a --- /dev/null +++ b/src/domain/v8/v8CodeCacheBudget.ts @@ -0,0 +1,5 @@ +/** Caller-selected budgets the parser enforces while reading one container. */ +export interface V8CodeCacheInputBudget { + readonly max_strings: number; + readonly min_string_bytes: number; +} diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts index a68d45b004..76419eec11 100644 --- a/src/erlang/BeamProvider.ts +++ b/src/erlang/BeamProvider.ts @@ -5,7 +5,10 @@ import { projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -52,9 +55,10 @@ export class BeamProvider implements BeamPort { } async inspect(input: InspectErlangBeamInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -74,7 +78,7 @@ export class BeamProvider implements BeamPort { ); }); } catch (cause) { - return err(beamFailure(cause, input)); + return err(beamFailure(cause, input, snapshot)); } } } @@ -193,7 +197,11 @@ const beamFailurePrimary = ( ); }; -const beamFailure = (cause: unknown, input: InspectErlangBeamInput) => { +const beamFailure = ( + cause: unknown, + input: InspectErlangBeamInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = beamFailurePrimary(artifactFilePrimaryFacts(cause), input); return projectStableArtifactFailure( cause, @@ -201,5 +209,6 @@ const beamFailure = (cause: unknown, input: InspectErlangBeamInput) => { input.path, ERLANG_BEAM_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; diff --git a/src/firmware/devicetree/DevicetreeProvider.ts b/src/firmware/devicetree/DevicetreeProvider.ts index 26ba90e132..1756ff4cb7 100644 --- a/src/firmware/devicetree/DevicetreeProvider.ts +++ b/src/firmware/devicetree/DevicetreeProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -52,9 +55,10 @@ export class DevicetreeProvider implements DevicetreePort { } async inspect(input: InspectDevicetreeInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -74,7 +78,7 @@ export class DevicetreeProvider implements DevicetreePort { ); }); } catch (cause) { - return err(devicetreeFailure(cause, input)); + return err(devicetreeFailure(cause, input, snapshot)); } } } @@ -191,7 +195,11 @@ const devicetreeFailurePrimary = ( ); }; -const devicetreeFailure = (cause: unknown, input: InspectDevicetreeInput) => { +const devicetreeFailure = ( + cause: unknown, + input: InspectDevicetreeInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = devicetreeFailurePrimary( artifactFilePrimaryFacts(cause), input, @@ -202,5 +210,6 @@ const devicetreeFailure = (cause: unknown, input: InspectDevicetreeInput) => { input.path, DEVICETREE_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 930d6d6f36..9404a04a21 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -5,7 +5,10 @@ import { stableArtifactInputRecovery, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -144,7 +147,11 @@ const failureForPrimary = ( }); }; -const failureFor = (cause: unknown, path: string): AnalysisError => { +const failureFor = ( + cause: unknown, + path: string, + snapshot?: StableArtifactSnapshot, +): AnalysisError => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); return projectStableArtifactFailure( cause, @@ -152,6 +159,7 @@ const failureFor = (cause: unknown, path: string): AnalysisError => { path, GO_BINARY_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; @@ -276,9 +284,10 @@ export class GoBinaryProvider implements GoBinaryPort { input: Omit, options?: ExecutionOptions, ): Promise> { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, INPUT_BYTES, options?.signal, @@ -342,7 +351,7 @@ export class GoBinaryProvider implements GoBinaryPort { return ok(parsed.data); }); } catch (cause: unknown) { - return err(failureFor(cause, input.path)); + return err(failureFor(cause, input.path, snapshot)); } } } diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index 913186c749..e8c7574f4d 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -65,9 +68,10 @@ export class AppleIpsProvider implements AppleIpsPort { } async inspect(input: InspectAppleIpsInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -88,7 +92,7 @@ export class AppleIpsProvider implements AppleIpsPort { return ok(report); }); } catch (cause) { - return err(appleIpsFailure(cause, input)); + return err(appleIpsFailure(cause, input, snapshot)); } } } @@ -269,6 +273,7 @@ const appleIpsFailurePrimary = ( const appleIpsFailure = ( cause: unknown, input: InspectAppleIpsInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = appleIpsFailurePrimary( artifactFilePrimaryFacts(cause), @@ -280,5 +285,6 @@ const appleIpsFailure = ( input.path, APPLE_IPS_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index e96de9954f..1aeaeed590 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -57,9 +60,10 @@ export class ElfNotesProvider implements ElfNotesPort { } async inspect(input: InspectElfNotesInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -79,7 +83,7 @@ export class ElfNotesProvider implements ElfNotesPort { ); }); } catch (cause) { - return err(elfNotesFailure(cause, input)); + return err(elfNotesFailure(cause, input, snapshot)); } } } @@ -233,7 +237,11 @@ const elfNotesFailurePrimary = ( ); }; -const elfNotesFailure = (cause: unknown, input: InspectElfNotesInput) => { +const elfNotesFailure = ( + cause: unknown, + input: InspectElfNotesInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = elfNotesFailurePrimary( artifactFilePrimaryFacts(cause), input, @@ -244,5 +252,6 @@ const elfNotesFailure = (cause: unknown, input: InspectElfNotesInput) => { input.path, ELF_NOTES_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/elf/KernelModuleProvider.ts b/src/native/elf/KernelModuleProvider.ts index 1c83e695cb..89a1ce53f7 100644 --- a/src/native/elf/KernelModuleProvider.ts +++ b/src/native/elf/KernelModuleProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -57,9 +60,10 @@ export class KernelModuleProvider implements KernelModulePort { } async inspect(input: InspectKernelModuleInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -79,7 +83,7 @@ export class KernelModuleProvider implements KernelModulePort { ); }); } catch (cause) { - return err(kernelModuleFailure(cause, input)); + return err(kernelModuleFailure(cause, input, snapshot)); } } } @@ -228,6 +232,7 @@ const kernelModuleFailurePrimary = ( const kernelModuleFailure = ( cause: unknown, input: InspectKernelModuleInput, + snapshot?: StableArtifactSnapshot, ) => { const primary = kernelModuleFailurePrimary( artifactFilePrimaryFacts(cause), @@ -239,5 +244,6 @@ const kernelModuleFailure = ( input.path, KERNEL_MODULE_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index d564ed8826..9e47ec9f21 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -58,9 +61,10 @@ export class NativeExportsProvider implements NativeExportsPort { } async inspect(input: InspectNativeExportsInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -77,7 +81,7 @@ export class NativeExportsProvider implements NativeExportsPort { return ok(report); }); } catch (cause) { - return err(nativeExportsFailure(cause, input)); + return err(nativeExportsFailure(cause, input, snapshot)); } } } @@ -193,6 +197,7 @@ const nativeExportsFailurePrimary = ( const nativeExportsFailure = ( cause: unknown, input: InspectNativeExportsInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = nativeExportsFailurePrimary( artifactFilePrimaryFacts(cause), @@ -204,5 +209,6 @@ const nativeExportsFailure = ( input.path, NATIVE_EXPORTS_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/hardening/BinaryHardeningProvider.ts b/src/native/hardening/BinaryHardeningProvider.ts index 707af54a82..63acc5a161 100644 --- a/src/native/hardening/BinaryHardeningProvider.ts +++ b/src/native/hardening/BinaryHardeningProvider.ts @@ -4,7 +4,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -64,9 +67,10 @@ export class BinaryHardeningProvider implements BinaryHardeningPort { input: InspectBinaryHardeningInput, options?: ExecutionOptions, ) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -87,7 +91,7 @@ export class BinaryHardeningProvider implements BinaryHardeningPort { return ok(report); }); } catch (cause) { - return err(binaryHardeningFailure(cause, input)); + return err(binaryHardeningFailure(cause, input, snapshot)); } } } @@ -234,6 +238,7 @@ const binaryHardeningFailurePrimary = ( const binaryHardeningFailure = ( cause: unknown, input: InspectBinaryHardeningInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => projectStableArtifactFailure( cause, @@ -241,4 +246,5 @@ const binaryHardeningFailure = ( input.path, BINARY_HARDENING_PROVIDER.id, OPERATION, + snapshot, ); diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 0e016d4e87..580c753dbb 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -6,7 +6,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -58,9 +61,10 @@ export class PeCertificatesProvider implements PeCertificatesPort { } async inspect(input: InspectPeCertificatesInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -80,7 +84,7 @@ export class PeCertificatesProvider implements PeCertificatesPort { ); }); } catch (cause) { - return err(peCertificatesFailure(cause, input)); + return err(peCertificatesFailure(cause, input, snapshot)); } } } @@ -229,6 +233,7 @@ const peCertificatesFailurePrimary = ( const peCertificatesFailure = ( cause: unknown, input: InspectPeCertificatesInput, + snapshot?: StableArtifactSnapshot, ) => { const primary = peCertificatesFailurePrimary( artifactFilePrimaryFacts(cause), @@ -240,5 +245,6 @@ const peCertificatesFailure = ( input.path, PE_CERTIFICATES_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 7324ff68e6..4fb628a28a 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -66,9 +69,10 @@ export class PeImportsProvider implements PeImportsPort { } async inspect(input: InspectPeImportsInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -90,7 +94,7 @@ export class PeImportsProvider implements PeImportsPort { return ok(report); }); } catch (cause) { - return err(peImportsFailure(cause, input)); + return err(peImportsFailure(cause, input, snapshot)); } } } @@ -236,6 +240,7 @@ const peImportsFailurePrimary = ( const peImportsFailure = ( cause: unknown, input: InspectPeImportsInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = peImportsFailurePrimary( artifactFilePrimaryFacts(cause), @@ -247,5 +252,6 @@ const peImportsFailure = ( input.path, PE_IMPORTS_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index 9131c763d0..00b5841ba9 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -64,9 +67,10 @@ export class PeLoadConfigProvider implements PeLoadConfigPort { } async inspect(input: InspectPeLoadConfigInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -88,7 +92,7 @@ export class PeLoadConfigProvider implements PeLoadConfigPort { return ok(report); }); } catch (cause) { - return err(peLoadConfigFailure(cause, input)); + return err(peLoadConfigFailure(cause, input, snapshot)); } } } @@ -213,6 +217,7 @@ const peLoadConfigFailurePrimary = ( const peLoadConfigFailure = ( cause: unknown, input: InspectPeLoadConfigInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = peLoadConfigFailurePrimary( artifactFilePrimaryFacts(cause), @@ -224,5 +229,6 @@ const peLoadConfigFailure = ( input.path, PE_LOAD_CONFIG_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index e44c3a3cc8..b8504ccfae 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -7,7 +7,10 @@ import { import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -55,9 +58,10 @@ export class PeResourcesProvider implements PeResourcesPort { } async inspect(input: InspectPeResourcesInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -78,7 +82,7 @@ export class PeResourcesProvider implements PeResourcesPort { ); }); } catch (cause) { - return err(peResourcesFailure(cause, input)); + return err(peResourcesFailure(cause, input, snapshot)); } } } @@ -215,7 +219,11 @@ const peResourcesFailurePrimary = ( ); }; -const peResourcesFailure = (cause: unknown, input: InspectPeResourcesInput) => { +const peResourcesFailure = ( + cause: unknown, + input: InspectPeResourcesInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = peResourcesFailurePrimary( artifactFilePrimaryFacts(cause), input, @@ -226,5 +234,6 @@ const peResourcesFailure = (cause: unknown, input: InspectPeResourcesInput) => { input.path, PE_RESOURCES_PROVIDER.id, "inspect_pe_resources", + snapshot, ); }; diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index 488e44f625..706176c8e2 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -11,7 +11,10 @@ import { type ArtifactFilePrimaryFacts, } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { PythonPycPort } from "../application/python/PythonPycPort.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; @@ -162,7 +165,11 @@ const failureForPrimary = ( }); }; -const failureFor = (cause: unknown, input: InspectPythonPycInput) => { +const failureFor = ( + cause: unknown, + input: InspectPythonPycInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), input); return projectStableArtifactFailure( cause, @@ -170,6 +177,7 @@ const failureFor = (cause: unknown, input: InspectPythonPycInput) => { input.path, PYTHON_PYC_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; @@ -191,8 +199,9 @@ export class PythonPycProvider implements PythonPycPort { } async inspect(input: InspectPythonPycInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { - const snapshot = await this.#resources.run(() => + snapshot = await this.#resources.run(() => readStableArtifact( input.path, input.max_file_bytes, @@ -213,7 +222,7 @@ export class PythonPycProvider implements PythonPycPort { ), ); } catch (cause) { - return err(failureFor(cause, input)); + return err(failureFor(cause, input, snapshot)); } } } diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 69d48477e3..c993d5b003 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -5,7 +5,10 @@ import { projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -71,9 +74,10 @@ export class RustBinaryProvider implements RustBinaryPort { input: InspectRustBinaryInput, options?: ExecutionOptions, ): Promise> { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, RUST_BINARY_INPUT_BYTES, options?.signal, @@ -123,7 +127,7 @@ export class RustBinaryProvider implements RustBinaryPort { return ok(parsed.data); }); } catch (cause: unknown) { - return err(failureFor(cause, input.path)); + return err(failureFor(cause, input.path, snapshot)); } } } @@ -276,7 +280,11 @@ const failureForPrimary = ( return artifactFailure(facts, path); }; -const failureFor = (cause: unknown, path: string): AnalysisError => { +const failureFor = ( + cause: unknown, + path: string, + snapshot?: StableArtifactSnapshot, +): AnalysisError => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); return projectStableArtifactFailure( cause, @@ -284,6 +292,7 @@ const failureFor = (cause: unknown, path: string): AnalysisError => { path, RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 89aef18a95..b2da447671 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -38,6 +38,7 @@ import { registerIl2cppTools } from "./registerIl2cppTools.js"; import { registerRustTools } from "./registerRustTools.js"; import { registerPythonPycTool } from "./registerPythonPycTool.js"; import { registerErlangTools } from "./registerErlangTools.js"; +import { registerV8CodeCacheTool } from "./registerV8CodeCacheTool.js"; import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; import { registerFridaTools } from "./registerFridaTools.js"; @@ -209,6 +210,7 @@ export const createServer = ( goBinary, rustBinary, erlangBeam, + v8CodeCache, modelArtifact, gpuCodeObjects, il2cppMetadata, @@ -619,6 +621,13 @@ export const createServer = ( analysisAdmission, evidenceById, ); + registerV8CodeCacheTool( + server, + v8CodeCache, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerWasmTools( server, wasmArtifact, diff --git a/src/server/registerV8CodeCacheTool.ts b/src/server/registerV8CodeCacheTool.ts new file mode 100644 index 0000000000..893f8c5c46 --- /dev/null +++ b/src/server/registerV8CodeCacheTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { V8CodeCacheService } from "../application/v8CodeCache/V8CodeCacheService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file V8 code cache workflow and retain its complete Evidence. */ +export const registerV8CodeCacheTool = ( + server: EvidenceMcpServer, + service: V8CodeCacheService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_v8_code_cache"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/src/v8/V8CodeCache.errors.ts b/src/v8/V8CodeCache.errors.ts new file mode 100644 index 0000000000..8ecd42fefc --- /dev/null +++ b/src/v8/V8CodeCache.errors.ts @@ -0,0 +1,24 @@ +import type { V8CodeCacheInputBudget } from "../domain/v8/v8CodeCacheBudget.js"; + +/** Distinguish corrupt containers from unrecognized formats. */ +export class V8CodeCacheFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + ) { + super(message); + this.name = "V8CodeCacheFormatFailure"; + } +} + +/** A caller-selected budget was exceeded while scanning the payload. */ +export class V8CodeCacheBudgetFailure extends Error { + constructor( + readonly boundary: keyof V8CodeCacheInputBudget, + readonly limit: number, + message: string, + ) { + super(message); + this.name = "V8CodeCacheBudgetFailure"; + } +} diff --git a/src/v8/V8CodeCache.fixture.ts b/src/v8/V8CodeCache.fixture.ts new file mode 100644 index 0000000000..be81df68ca --- /dev/null +++ b/src/v8/V8CodeCache.fixture.ts @@ -0,0 +1,26 @@ +import vm from "node:vm"; + +/** + * Generate a real V8 code cache instead of committing a version-pinned binary. Node + * embeds V8, so the fixture producer is the same code path that writes a bytenode `.jsc`. + */ +export const v8CodeCacheFixture = ( + source = "function add(a, b) { return a + b; } add(1, 2);", +): Buffer => + new vm.Script(source, { produceCachedData: true }).createCachedData(); + +/** `vm.compileFunction` sets the producer bit in the source-length word. */ +export const v8FunctionCodeCacheFixture = (source = "return 1 + 2;"): Buffer => + vm.compileFunction(source, [], { produceCachedData: true }).cachedData ?? + Buffer.alloc(0); + +/** Rewrite one header word of a real fixture to exercise a specific header value. */ +export const withHeaderWord = ( + cache: Buffer, + index: number, + value: number, +): Buffer => { + const copy = Buffer.from(cache); + copy.writeUInt32LE(value >>> 0, index * 4); + return copy; +}; diff --git a/src/v8/V8CodeCacheParser.test.ts b/src/v8/V8CodeCacheParser.test.ts new file mode 100644 index 0000000000..f1ec862b06 --- /dev/null +++ b/src/v8/V8CodeCacheParser.test.ts @@ -0,0 +1,257 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + inspectV8CodeCacheInputSchema, + v8CodeCacheSchema, + V8_CODE_CACHE_HEADER_BYTES, + type V8CodeCache, +} from "../domain/v8/v8CodeCache.js"; +import { parseV8CodeCache } from "./V8CodeCacheParser.js"; +import { + v8CodeCacheFixture, + v8FunctionCodeCacheFixture, + withHeaderWord, +} from "./V8CodeCache.fixture.js"; + +const defaults = inspectV8CodeCacheInputSchema.parse({ + path: "/artifacts/module.jsc", +}); + +const identity = (bytes: Buffer) => ({ + path: "/artifacts/module.jsc", + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, +}); + +const parse = (bytes: Buffer): V8CodeCache => { + const report = parseV8CodeCache(bytes, identity(bytes), defaults); + const validated = v8CodeCacheSchema.safeParse(report); + if (!validated.success) throw new Error(validated.error.message); + return report; +}; + +const real = v8CodeCacheFixture(); + +it("reports the observed header of a real V8 code cache", () => { + const report = parse(real); + expect(report.header.header_bytes).toBe(32); + expect(report.header.magic_build_stable).toBe(0xc0de); + expect(report.header.magic).toBe(real.readUInt32LE(0)); + // The payload-length word reaches exactly the end of the container. + expect(report.payload.offset).toBe(32); + expect(report.payload.bytes).toBe(real.length - 32); + expect(report.payload.bytes).toBe(real.readUInt32LE(20)); + expect(report.payload.offset + report.payload.bytes).toBe( + report.artifact.bytes, + ); + expect(report.coverage.status).toBe("complete"); +}); + +it("names every header word with its role and confidence", () => { + const report = parse(real); + expect(report.header.fields).toHaveLength(8); + expect(report.header.fields.map((field) => field.offset)).toEqual([ + 0, 4, 8, 12, 16, 20, 24, 28, + ]); + expect(report.header.fields.map((field) => field.role)).toEqual([ + "magic", + "version_hash", + "source_length", + "flag_hash", + "build_constant", + "payload_length", + "reserved", + "reserved", + ]); + // Every reported value is the byte-level truth, independent of the inferred role. + report.header.fields.forEach((field) => { + expect(field.value).toBe(real.readUInt32LE(field.offset)); + }); + // Confidence is stated per field rather than implied. + expect( + report.header.fields.filter((field) => field.status === "unconfirmed"), + ).toHaveLength(2); + expect( + report.header.fields + .filter((field) => field.status === "confirmed") + .map((f) => f.role), + ).toEqual(["magic", "source_length", "payload_length"]); +}); + +it("reports the source length the producer recorded", () => { + const source = "function longer(a, b) { return a * b + 1; } longer(3, 4);"; + const cache = v8CodeCacheFixture(source); + const report = parse(cache); + expect(report.header.source_length).toBe(source.length); + // An unflagged word is `unknown`, not `script`: a `vm.compileFunction` cache from a + // build that does not mark bit 29 reads the same as a script, so the container alone + // cannot separate them. The observed word stays available in `header.fields`. + expect(report.header.producer).toBe("unknown"); + expect(report.header.fields[2]?.value).toBe(cache.readUInt32LE(8)); +}); + +it("reports a module cache as a module rather than a script", () => { + // Bit 30 of the source-length word marks `vm.SourceTextModule` output. Measured on + // Node 24.22.1: a real module cache reads `0x40000013` for a 19-unit source while a + // script of the same length reads `0x00000013`, so a producer check that only tests + // the function bit reports the module cache as a plain script. + const script = v8CodeCacheFixture(); + const word = script.readUInt32LE(8); + const length = word & 0x1fffffff; + expect(word & 0x40000000).toBe(0); + expect(word & 0x20000000).toBe(0); + expect(parse(script).header.producer).toBe("unknown"); + + const moduleCache = withHeaderWord(script, 2, word | 0x40000000); + const report = parse(moduleCache); + expect(report.header.producer).toBe("module"); + // The module bit sits outside the source-length mask, so the length is unchanged. + expect(report.header.source_length).toBe(length); +}); + +it("keeps the function and module flags distinct", () => { + const script = v8CodeCacheFixture("var y = 1;"); + const length = script.readUInt32LE(8) & 0x1fffffff; + expect( + parse(withHeaderWord(script, 2, 0x20000000 | length)).header.producer, + ).toBe("compile_function"); + expect( + parse(withHeaderWord(script, 2, 0x40000000 | length)).header.producer, + ).toBe("module"); + // Both bits set is not a shape V8 produces; the module flag wins so the reported + // family stays one of the three observed kinds. + expect( + parse(withHeaderWord(script, 2, 0x60000000 | length)).header.producer, + ).toBe("module"); +}); + +it("separates the compileFunction producer bit from the source length", () => { + const source = "return 7;"; + const cache = v8FunctionCodeCacheFixture(source); + const word = cache.readUInt32LE(8); + const report = parse(cache); + // Whether `vm.compileFunction` sets bit 29 in the source-length word is a V8 build + // detail, not a contract: Node 24.22.1 writes `0x20000009` for this source while Node + // 22.23.4 does not set the bit at all. Assert against the word the runtime produced + // rather than against one build's spelling, so the check is about the parser reading + // that word correctly; the flag meanings themselves are pinned by the synthetic + // `withHeaderWord` cases above. + expect(report.header.producer).toBe( + (word & 0x20000000) === 0 ? "unknown" : "compile_function", + ); + // The source length is the low bits either way, so it is the part that must hold. + expect(report.header.source_length).toBe(word & 0x1fffffff); + expect(report.header.source_length).toBe(source.length); +}); + +it("reports an embedded run at its absolute file offset", () => { + const marker = "UNIQUE_CONFIG_KEY_9f3a"; + // An object-literal value survives in the serialized payload as plain bytes. + const cache = v8CodeCacheFixture( + `var settings = { key: "${marker}" }; settings.key;`, + ); + const report = parse(cache); + const observation = report.embedded_strings.find((entry) => + entry.text.includes(marker), + ); + expect(observation).toBeDefined(); + // The reported range points at the marker inside the selected file. + expect(realSlice(cache, observation!.offset, observation!.bytes)).toContain( + marker, + ); + expect(observation!.offset).toBeGreaterThanOrEqual(32); + expect(report.coverage.strings).toBe(report.embedded_strings.length); +}); + +it("labels observations as byte patterns and never claims recovered source", () => { + const report = parse(real); + expect(report.limitations.join(" ")).toContain("byte-pattern observations"); + expect(report.limitations.join(" ")).toContain("not source"); + expect(report.limitations.join(" ")).toContain("View8"); + // No field can be mistaken for recovered source text. + expect(Object.keys(report)).not.toContain("source"); +}); + +it("fails closed on a foreign magic", () => { + const foreign = withHeaderWord(real, 0, 0xdeadbeef); + expect(() => parse(foreign)).toThrow( + expect.objectContaining({ + name: "V8CodeCacheFormatFailure", + kind: "unsupported", + message: expect.stringContaining("not a V8 code cache"), + }), + ); +}); + +it("fails closed on a payload-length mismatch", () => { + const corrupt = withHeaderWord(real, 5, real.readUInt32LE(20) + 8); + expect(() => parse(corrupt)).toThrow( + expect.objectContaining({ + kind: "malformed", + message: expect.stringContaining("truncated or corrupt"), + }), + ); +}); + +it("fails closed on a truncated header", () => { + expect(() => parse(real.subarray(0, V8_CODE_CACHE_HEADER_BYTES - 1))).toThrow( + expect.objectContaining({ kind: "malformed" }), + ); +}); + +it("fails closed when fewer payload bytes follow the header than it declares", () => { + // Cut bytes off the end without rewriting the declared payload length. + expect(() => parse(real.subarray(0, real.length - 16))).toThrow( + expect.objectContaining({ + kind: "malformed", + message: expect.stringContaining("truncated or corrupt"), + }), + ); +}); + +it("treats the observed header as version-dependent rather than a fixed ABI", () => { + // A producer with a different build constant and version hash is still admitted: only + // the magic high half and the payload-length relation gate admission. + const otherBuild = withHeaderWord( + withHeaderWord(real, 4, 0x4be86e94), + 1, + 0x11111111, + ); + const report = parse(otherBuild); + expect(report.header.fields[4]?.value).toBe(0x4be86e94); + expect(report.payload.bytes).toBe(real.readUInt32LE(20)); +}); + +it("enforces the caller-selected observation budget", () => { + const crowded = v8CodeCacheFixture( + `var a = { a: "AAAAAAAAAAAA", b: "BBBBBBBBBBBB", c: "CCCCCCCCCCCC" }; a.a;`, + ); + expect(() => + parseV8CodeCache(crowded, identity(crowded), { + ...defaults, + max_strings: 1, + }), + ).toThrow( + expect.objectContaining({ + name: "V8CodeCacheBudgetFailure", + message: expect.stringContaining("max_strings"), + }), + ); +}); + +it("raises min_string_bytes and reports only runs that long", () => { + const bytes = v8CodeCacheFixture( + `var a = { k: "SHORT", long: "${"L".repeat(80)}" }; a.k;`, + ); + const few = parseV8CodeCache(bytes, identity(bytes), { + ...defaults, + min_string_bytes: 64, + }); + expect(few.embedded_strings.length).toBeGreaterThan(0); + few.embedded_strings.forEach((entry) => { + expect(entry.bytes).toBeGreaterThanOrEqual(64); + }); +}); + +const realSlice = (bytes: Buffer, offset: number, length: number): string => + bytes.subarray(offset, offset + length).toString("latin1"); diff --git a/src/v8/V8CodeCacheParser.ts b/src/v8/V8CodeCacheParser.ts new file mode 100644 index 0000000000..8a69024590 --- /dev/null +++ b/src/v8/V8CodeCacheParser.ts @@ -0,0 +1,187 @@ +import { createHash } from "node:crypto"; +import { + MAX_INSPECT_V8_CODE_CACHE_STRING_BYTES, + V8_CODE_CACHE_COMPILE_FUNCTION_BIT, + V8_CODE_CACHE_MODULE_BIT, + V8_CODE_CACHE_HEADER_BYTES, + V8_CODE_CACHE_MAGIC_HIGH, + V8_CODE_CACHE_SOURCE_LENGTH_MASK, + type V8CodeCache, +} from "../domain/v8/v8CodeCache.js"; +import type { V8CodeCacheInputBudget } from "../domain/v8/v8CodeCacheBudget.js"; +import { + V8CodeCacheBudgetFailure, + V8CodeCacheFormatFailure, +} from "../v8/V8CodeCache.errors.js"; + +const unsupported = (message: string): never => { + throw new V8CodeCacheFormatFailure("unsupported", message); +}; +const malformed = (message: string): never => { + throw new V8CodeCacheFormatFailure("malformed", message); +}; + +/** + * Header word roles. Reproduced from observation against the producing V8, not from an + * assumed ABI: the container is version-dependent and only the magic high half and the + * payload-length relation are relied on as admission conditions. + */ +const HEADER_ROLES: ReadonlyArray<{ + role: string; + status: "confirmed" | "inferred" | "unconfirmed"; +}> = [ + { role: "magic", status: "confirmed" }, + { role: "version_hash", status: "inferred" }, + { role: "source_length", status: "confirmed" }, + { role: "flag_hash", status: "inferred" }, + { role: "build_constant", status: "inferred" }, + { role: "payload_length", status: "confirmed" }, + { role: "reserved", status: "unconfirmed" }, + { role: "reserved", status: "unconfirmed" }, +]; + +const LIMITATIONS = [ + "The payload is serialized V8 heap data, not source. No logic is recovered and no bytecode is decompiled.", + "Recovering logic requires a version-pinned V8 decompiler (View8-style) that this tool neither installs nor runs.", + "Embedded strings are byte-pattern observations inside the payload, not parsed heap objects: a reported sequence is not proof V8 retained it as a string, and a missing literal is not proof it is absent.", + "Header field roles beyond magic, source length, and payload length are inferred from one producer and are version-dependent; treat them as observations, not a fixed ABI.", + "An unflagged producer word is reported as unknown: whether V8 marks a vm.compileFunction cache at all is a build detail, so the container cannot separate a vm.Script from an unmarked function cache on its own.", +]; + +/** Printable ASCII and horizontal tab, the run alphabet of one observation. */ +const isPrintable = (byte: number): boolean => + (byte >= 0x20 && byte <= 0x7e) || byte === 0x09; + +const payloadSha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +/** + * Read one V8 code cache container without executing it. + * + * Admission is deliberately narrow: the header size this reader pins, the V8 code cache + * magic high half, and payload length reaching exactly the end of the file. Everything + * else is reported as an observation so a producer this reader has never seen still + * yields its facts instead of a false parse. + */ +export const parseV8CodeCache = ( + bytes: Buffer, + artifact: { path: string; sha256: string; bytes: number }, + input: V8CodeCacheInputBudget, +): V8CodeCache => { + if (bytes.length !== artifact.bytes) + malformed("Selected artifact length disagrees with the retained bytes."); + if (bytes.length < V8_CODE_CACHE_HEADER_BYTES) + malformed( + `Selected file is shorter than the ${String(V8_CODE_CACHE_HEADER_BYTES)}-byte V8 code cache header.`, + ); + + const field = (index: number): number => bytes.readUInt32LE(index * 4); + const magic = field(0); + const magicHigh = magic >>> 16; + if (magicHigh !== V8_CODE_CACHE_MAGIC_HIGH) + unsupported( + `Container magic 0x${magic.toString(16).padStart(8, "0")} does not carry the V8 code cache high half 0x${V8_CODE_CACHE_MAGIC_HIGH.toString(16)}; this file is not a V8 code cache.`, + ); + + const declaredPayload = field(5); + const payloadOffset = V8_CODE_CACHE_HEADER_BYTES; + const expectedPayload = bytes.length - payloadOffset; + if (declaredPayload !== expectedPayload) + malformed( + `Header declares a ${String(declaredPayload)}-byte payload but ${String(expectedPayload)} bytes follow the ${String(V8_CODE_CACHE_HEADER_BYTES)}-byte header; the container is truncated or corrupt.`, + ); + + const sourceWord = field(2); + const sourceLength = sourceWord & V8_CODE_CACHE_SOURCE_LENGTH_MASK; + // Bit 30 marks a `vm.SourceTextModule` cache and bit 29 a `vm.compileFunction` one. + // Measured on Node 24.22.1, a 19-unit module source reads `0x40000013` while a script of + // the same length reads `0x00000013`, so the function bit alone reports a module cache as + // a script. Both bits sit outside the source-length mask. + // + // The unflagged case is `unknown`, not `script`: whether V8 marks a function cache is a + // build detail. A `vm.compileFunction` cache reads `0x20000004` on Node 24.22.1 and + // `0x00000004` on Node 22.23.4 for a 4-unit source, so an unflagged word is either + // `vm.Script` or an unmarked function cache, and the container carries nothing that + // separates them. `fields` keeps the observed word so a caller can resolve it against a + // build fingerprint it trusts. + const producer = + (sourceWord & V8_CODE_CACHE_MODULE_BIT) !== 0 + ? ("module" as const) + : (sourceWord & V8_CODE_CACHE_COMPILE_FUNCTION_BIT) !== 0 + ? ("compile_function" as const) + : ("unknown" as const); + + const payload = bytes.subarray(payloadOffset); + const observations: V8CodeCache["embedded_strings"] = []; + let runStart = -1; + const flush = (end: number): void => { + if (runStart < 0) return; + const runBytes = end - runStart; + if (runBytes >= input.min_string_bytes) { + if (observations.length >= input.max_strings) + // The message states the fact. Recovery advice belongs to the provider, which + // can see whether either budget still has headroom before recommending a retry. + throw new V8CodeCacheBudgetFailure( + "max_strings", + input.max_strings, + `Payload contains more than ${String(input.max_strings)} printable runs of at least ${String(input.min_string_bytes)} bytes, so the max_strings observation budget was exhausted before the scan finished.`, + ); + const retained = Math.min( + runBytes, + MAX_INSPECT_V8_CODE_CACHE_STRING_BYTES, + ); + const absolute = payloadOffset + runStart; + observations.push({ + offset: absolute, + bytes: runBytes, + retained_bytes: retained, + truncated: retained < runBytes, + text: payload + .subarray(runStart, runStart + retained) + .toString("latin1"), + }); + } + runStart = -1; + }; + // Indexed reads over the payload view: `payload.entries()` allocates a two-element + // array per byte, which dominated this scan on large caches. + const payloadLength = payload.length; + for (let index = 0; index < payloadLength; index += 1) { + if (isPrintable(payload[index]!)) { + if (runStart < 0) runStart = index; + } else { + flush(index); + } + } + flush(payloadLength); + + return { + artifact, + header: { + magic, + magic_build_stable: magicHigh, + fields: HEADER_ROLES.map((entry, index) => ({ + offset: index * 4, + value: field(index), + role: entry.role, + status: entry.status, + })), + header_bytes: V8_CODE_CACHE_HEADER_BYTES, + source_length: sourceLength, + producer, + }, + payload: { + offset: payloadOffset, + bytes: payload.length, + sha256: payloadSha256(payload), + trailing_bytes: 0, + }, + embedded_strings: observations, + coverage: { + status: "complete", + scanned_bytes: payload.length, + strings: observations.length, + }, + limitations: LIMITATIONS, + }; +}; diff --git a/src/v8/V8CodeCacheProvider.test.ts b/src/v8/V8CodeCacheProvider.test.ts new file mode 100644 index 0000000000..3a19297ac6 --- /dev/null +++ b/src/v8/V8CodeCacheProvider.test.ts @@ -0,0 +1,143 @@ +import { expect, it } from "vitest"; + +import { + MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + MAX_INSPECT_V8_CODE_CACHE_STRINGS, + MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES, + inspectV8CodeCacheInputSchema, +} from "../domain/v8/v8CodeCache.js"; +import { V8CodeCacheBudgetFailure } from "./V8CodeCache.errors.js"; +import { + observationBudgetRecovery, + observationBudgetRemediation, +} from "./V8CodeCacheProvider.js"; + +/** + * The observation budget has two caller-selected limits, each with a ceiling: + * `max_strings` tops out at `MAX_INSPECT_V8_CODE_CACHE_STRINGS` and `min_string_bytes` + * at `MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES`. An admitted file can exceed the + * budget with both already at their maximum, and then no input change is a legal retry. + * + * The recovery is asserted as the decision it is — which retries remain, and whether a + * smaller artifact is the only option — rather than by matching the advice sentence, so + * a reworded message cannot break these and wrong advice cannot pass them. + */ +const input = (overrides: Record = {}) => + inspectV8CodeCacheInputSchema.parse({ + path: "/artifacts/module.jsc", + ...overrides, + }); + +const exhausted = (limit: number) => + new V8CodeCacheBudgetFailure("max_strings", limit, "budget exhausted"); + +it("leaves both retries open while both budgets have headroom", () => { + // The default `min_string_bytes` is the schema minimum, so lowering it is not an + // option here; raising either bound is. + expect(observationBudgetRecovery(exhausted(16), input())).toEqual({ + boundary: "max_strings", + canRaiseMaxStrings: true, + canRaiseMinStringBytes: true, + canLowerMinStringBytes: false, + requiresSmallerArtifact: false, + }); +}); + +it("offers no retry when both budgets are already at their ceilings", () => { + // This is the case the review named: an admitted file with more than 65536 runs of at + // least 256 bytes reaches the branch, and neither suggested retry is admissible. + expect( + observationBudgetRecovery( + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ + max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS, + min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + }), + ), + ).toEqual({ + boundary: "max_strings", + canRaiseMaxStrings: false, + canRaiseMinStringBytes: false, + canLowerMinStringBytes: true, + requiresSmallerArtifact: true, + }); +}); + +it("drops only the run-length retry when min_string_bytes is at its ceiling", () => { + expect( + observationBudgetRecovery( + exhausted(16), + input({ min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES }), + ), + ).toMatchObject({ + canRaiseMaxStrings: true, + canRaiseMinStringBytes: false, + requiresSmallerArtifact: false, + }); +}); + +it("drops only the count retry when max_strings is at its ceiling", () => { + expect( + observationBudgetRecovery( + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS }), + ), + ).toMatchObject({ + canRaiseMaxStrings: false, + canRaiseMinStringBytes: true, + requiresSmallerArtifact: false, + }); +}); + +it("requires a smaller artifact only when the run length cannot drop", () => { + expect( + observationBudgetRecovery( + new V8CodeCacheBudgetFailure( + "min_string_bytes", + 4096, + "budget exhausted", + ), + input({ min_string_bytes: MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES }), + ), + ).toMatchObject({ + boundary: "min_string_bytes", + canLowerMinStringBytes: false, + requiresSmallerArtifact: true, + }); +}); + +/** + * The sentence is rendered from the recovery struct, so what it must be held to is that + * it does not advise a knob the struct closed. Matching the wording would break on a + * reword without catching wrong advice; asserting the branches catches the advice. + */ +it.each([ + [ + "no retry left", + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ + max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS, + min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + }), + ], + [ + "only the run length closed", + exhausted(16), + input({ min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES }), + ], + [ + "only the count closed", + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS }), + ], + ["both open", exhausted(16), input()], +] as const)("never advises a closed budget when %s", (_label, cause, input) => { + const recovery = observationBudgetRecovery(cause, input); + const advice = observationBudgetRemediation(cause, input); + if (!recovery.canRaiseMaxStrings) + expect(advice).not.toMatch(/Raise max_strings/u); + if (!recovery.canRaiseMinStringBytes) + expect(advice).not.toMatch(/Raise min_string_bytes/u); + if (recovery.requiresSmallerArtifact) + expect(advice).toMatch(/smaller artifact/u); +}); diff --git a/src/v8/V8CodeCacheProvider.ts b/src/v8/V8CodeCacheProvider.ts new file mode 100644 index 0000000000..37c60c8551 --- /dev/null +++ b/src/v8/V8CodeCacheProvider.ts @@ -0,0 +1,302 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; +import { + artifactFileCancellationCause, + artifactFilePrimaryFacts, + artifactSelectedPathErrno, + type ArtifactFilePrimaryFacts, +} from "../artifacts/ArtifactFileOperation.js"; +import { + projectArtifactCloseFailure, + projectStableArtifactFailure, + stableArtifactInputRecovery, +} from "../artifacts/StableArtifactFailure.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { V8CodeCachePort } from "../application/v8CodeCache/V8CodeCachePort.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../domain/artifactOperationError.js"; +import { + MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES, + MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + MAX_INSPECT_V8_CODE_CACHE_STRINGS, + MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES, + type InspectV8CodeCacheInput, +} from "../domain/v8/v8CodeCache.js"; +import { err, ok } from "../domain/result.js"; +import { + V8CodeCacheBudgetFailure, + V8CodeCacheFormatFailure, +} from "./V8CodeCache.errors.js"; +import { V8_CODE_CACHE_PROVIDER_IDENTITY } from "./V8CodeCacheProviderIdentity.js"; +import { parseV8CodeCache } from "./V8CodeCacheParser.js"; + +const OPERATION = "inspect_v8_code_cache"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class V8CodeCacheProvider implements V8CodeCachePort { + readonly identity = V8_CODE_CACHE_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + V8_CODE_CACHE_PROVIDER_IDENTITY.id, + OPERATION, + ); + } + } + + async inspect(input: InspectV8CodeCacheInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; + try { + return await this.#resources.run(async () => { + snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + parseV8CodeCache( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + ), + ); + }); + } catch (cause) { + return err(v8CodeCacheFailure(cause, input, snapshot)); + } + } +} + +const inputBudgetRemediation = ( + cause: ArtifactReaderFailure, + input: InspectV8CodeCacheInput, +): string | undefined => { + if (cause.boundary === "file-size") + return input.max_file_bytes >= MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES + ? `The file exceeds the ${String(MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + return undefined; +}; + +/** + * Which retries remain after an exhausted observation budget. + * + * `max_strings` tops out at `MAX_INSPECT_V8_CODE_CACHE_STRINGS` and `min_string_bytes` + * between `MIN_…` and `MAX_…`, so an admitted file with more than 65536 runs of 256 + * bytes can exhaust the budget with no admissible input change left. The recovery is + * decided as data, and the advice sentence is rendered from it, so a caller and a test + * can read the choice rather than the wording. + */ +export interface ObservationBudgetRecovery { + readonly boundary: V8CodeCacheBudgetFailure["boundary"]; + /** Whether a higher `max_strings` is still within the schema's admitted range. */ + readonly canRaiseMaxStrings: boolean; + /** Whether a higher `min_string_bytes` is still within the schema's admitted range. */ + readonly canRaiseMinStringBytes: boolean; + /** Whether a lower `min_string_bytes` is still within the schema's admitted range. */ + readonly canLowerMinStringBytes: boolean; + /** Whether the only remaining option is a smaller artifact. */ + readonly requiresSmallerArtifact: boolean; +} + +export const observationBudgetRecovery = ( + cause: V8CodeCacheBudgetFailure, + input: InspectV8CodeCacheInput, +): ObservationBudgetRecovery => { + const canRaiseMaxStrings = + input.max_strings < MAX_INSPECT_V8_CODE_CACHE_STRINGS; + const canRaiseMinStringBytes = + input.min_string_bytes < MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES; + const canLowerMinStringBytes = + input.min_string_bytes > MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES; + if (cause.boundary !== "max_strings") + return { + boundary: cause.boundary, + canRaiseMaxStrings, + canRaiseMinStringBytes, + canLowerMinStringBytes, + requiresSmallerArtifact: !canLowerMinStringBytes, + }; + return { + boundary: cause.boundary, + canRaiseMaxStrings, + canRaiseMinStringBytes, + canLowerMinStringBytes, + requiresSmallerArtifact: !canRaiseMaxStrings && !canRaiseMinStringBytes, + }; +}; + +/** Render the recovery choice for one exhausted observation budget. */ +export const observationBudgetRemediation = ( + cause: V8CodeCacheBudgetFailure, + input: InspectV8CodeCacheInput, +): string => { + const recovery = observationBudgetRecovery(cause, input); + if (recovery.boundary !== "max_strings") + return `Lower min_string_bytes below ${String(cause.limit)} or select a smaller artifact.`; + if (recovery.requiresSmallerArtifact) + return `Both max_strings and min_string_bytes are already at their ceilings (${String(MAX_INSPECT_V8_CODE_CACHE_STRINGS)} observations, ${String(MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES)}-byte runs), so no option change clears this budget; select a smaller artifact.`; + if (!recovery.canRaiseMaxStrings) + return `Raise min_string_bytes above ${String(input.min_string_bytes)}; max_strings is already at its ${String(MAX_INSPECT_V8_CODE_CACHE_STRINGS)}-observation ceiling, so a shorter run length cannot help.`; + if (!recovery.canRaiseMinStringBytes) + return `Raise max_strings above ${String(cause.limit)}; min_string_bytes is already at its ${String(MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES)}-byte ceiling, so a larger artifact must be reduced or split.`; + return `Raise max_strings above ${String(cause.limit)} or raise min_string_bytes above ${String(input.min_string_bytes)}; both are caller-selected observation budgets.`; +}; + +const v8CodeCacheFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectV8CodeCacheInput, +) => { + const { cause, origin } = facts; + if (cause instanceof V8CodeCacheBudgetFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + cause.message, + { + max_strings: input.max_strings, + min_string_bytes: input.min_string_bytes, + }, + { + cause, + retryable: false, + remediationAction: observationBudgetRemediation(cause, input), + }, + ); + if (cause instanceof V8CodeCacheFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { + cause, + }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") { + const remediationAction = inputBudgetRemediation(cause, input); + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { + max_file_bytes: input.max_file_bytes, + max_strings: input.max_strings, + }, + { + cause, + ...(remediationAction === undefined + ? {} + : { retryable: false, remediationAction }), + }, + ); + } + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + const pathErrno = artifactSelectedPathErrno(cause); + if (pathErrno !== undefined) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected V8 code cache could not be read (${pathErrno}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", + undefined, + cause instanceof Error ? cause.message : "V8 code cache inspection failed.", + { cause }, + ); +}; + +const v8CodeCacheFailure = ( + cause: unknown, + input: InspectV8CodeCacheInput, + snapshot?: StableArtifactSnapshot, +) => { + const primary = v8CodeCacheFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + return projectStableArtifactFailure( + cause, + primary, + input.path, + V8_CODE_CACHE_PROVIDER_IDENTITY.id, + OPERATION, + snapshot, + ); +}; diff --git a/src/v8/V8CodeCacheProviderIdentity.ts b/src/v8/V8CodeCacheProviderIdentity.ts new file mode 100644 index 0000000000..56b2f5cd35 --- /dev/null +++ b/src/v8/V8CodeCacheProviderIdentity.ts @@ -0,0 +1,6 @@ +/** Bundled format reader identity; container field roles are separate observed facts. */ +export const V8_CODE_CACHE_PROVIDER_IDENTITY = { + id: "rea-v8-code-cache", + name: "REA V8 code cache reader", + version: "1", +} as const; diff --git a/tests/acceptance/stableArtifactParserFailureCli.test.ts b/tests/acceptance/stableArtifactParserFailureCli.test.ts new file mode 100644 index 0000000000..741a8893d0 --- /dev/null +++ b/tests/acceptance/stableArtifactParserFailureCli.test.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { 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"; + +for (const command of [ + "inspect-python-pyc", + "inspect-erlang-beam", + "inspect-rust-binary", + "inspect-go-binary", + "inspect-elf-notes", + "inspect-devicetree", + "inspect-pe-imports", + "inspect-kernel-module", + "inspect-apple-ips", + "inspect-pe-load-config", + "inspect-pe-certificates", + "inspect-pe-resources", + "inspect-native-exports", + "inspect-binary-hardening", +]) { + cliTest( + `${command} retains the completed file identity after format refusal`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-format-refusal-"); + const path = join(root, "foreign.bin"); + const bytes = Buffer.alloc(64, 0x7f); + await writeFile(path, bytes); + const result = await cli.run({ arguments: [command, path, "--json"] }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + details: { + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }, + }); + }, + ); +} diff --git a/tests/acceptance/v8CodeCacheCli.test.ts b/tests/acceptance/v8CodeCacheCli.test.ts new file mode 100644 index 0000000000..36e7ca38fa --- /dev/null +++ b/tests/acceptance/v8CodeCacheCli.test.ts @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { v8CodeCacheFixture } from "../../src/v8/V8CodeCache.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const outputSchema = toolContract("inspect_v8_code_cache").outputSchema; + +cliTest( + "reports the observed header and payload through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-"); + const path = join(root, "module.jsc"); + const bytes = v8CodeCacheFixture( + "function add(a, b) { return a + b; } add(1, 2);", + ); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-v8-code-cache", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = outputSchema.parse(result.json); + const report = evidence.normalized_result; + expect(report.header.magic).toBe(bytes.readUInt32LE(0)); + expect(report.header.magic_build_stable).toBe(0xc0de); + expect(report.header.fields).toHaveLength(8); + expect(report.payload.offset).toBe(32); + expect(report.payload.bytes).toBe(bytes.length - 32); + expect(report.payload.sha256).toMatch(/^[0-9a-f]{64}$/u); + expect(report.coverage.status).toBe("complete"); + }, +); + +cliTest( + "resolves a relative code cache path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-rel-"); + await writeFile(join(root, "module.jsc"), v8CodeCacheFixture()); + const result = await cli.run({ + arguments: ["inspect-v8-code-cache", "module.jsc", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect( + outputSchema.parse(result.json).normalized_result.artifact.path, + ).toBe(join(root, "module.jsc")); + }, +); + +cliTest( + "locates retained printable sequences with byte ranges", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-strings-"); + const path = join(root, "module.jsc"); + const marker = "EMBEDDED_CONFIG_KEY_7c1d"; + const bytes = v8CodeCacheFixture( + `var settings = { key: "${marker}" }; settings.key;`, + ); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: [ + "inspect-v8-code-cache", + path, + "--minStringBytes", + "8", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = outputSchema.parse(result.json).normalized_result; + const observation = report.embedded_strings.find((entry) => + entry.text.includes(marker), + ); + expect(observation).toBeDefined(); + expect(observation!.offset).toBeGreaterThanOrEqual(report.payload.offset); + expect(observation!.offset + observation!.bytes).toBeLessThanOrEqual( + report.artifact.bytes, + ); + }, +); + +cliTest( + "fails closed on a file that is not a V8 code cache", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-foreign-"); + const path = join(root, "random.bin"); + const foreign = Buffer.from(v8CodeCacheFixture()); + foreign.writeUInt32LE(0xdeadbeef, 0); + await writeFile(path, foreign); + const result = await cli.run({ + arguments: ["inspect-v8-code-cache", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "unsupported_target", + details: { + operation: "inspect_v8_code_cache", + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: foreign.length, + sha256: createHash("sha256").update(foreign).digest("hex"), + }, + }, + }); + }, +); + +cliTest( + "retains file identity when a synthetic cache exceeds the string budget", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-budget-"); + const path = join(root, "synthetic.jsc"); + const payload = Buffer.from("aaaa\0bbbb"); + const bytes = Buffer.concat([ + v8CodeCacheFixture().subarray(0, 32), + payload, + ]); + bytes.writeUInt32LE(payload.length, 20); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: [ + "inspect-v8-code-cache", + path, + "--max-strings", + "1", + "--min-string-bytes", + "4", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "resource_constraint", + details: { + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }, + }); + }, +); diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts index d0f9cfee2b..e3011462bc 100644 --- a/tests/boundary/mcp/absoluteLocalPathTools.test.ts +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -5,6 +5,7 @@ import { expect, it } from "vitest"; import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "../../../src/contracts/javascript/javascriptRecoveryToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { V8_CODE_CACHE_TOOL_CONTRACTS } from "../../../src/contracts/v8/v8CodeCacheToolContracts.js"; import { WEB_SCRIPT_TOOL_CONTRACTS } from "../../../src/contracts/webScriptToolContracts.js"; import { API_CONTRACT_TOOL_CONTRACTS } from "../../../src/contracts/apiContractToolContracts.js"; import { PLAYWRIGHT_TRACE_TOOL_CONTRACTS } from "../../../src/contracts/playwrightTraceToolContracts.js"; @@ -19,6 +20,7 @@ const CONTRACTS = [ ...API_CONTRACT_TOOL_CONTRACTS, ...PLAYWRIGHT_TRACE_TOOL_CONTRACTS, ...PYTHON_PYC_TOOL_CONTRACTS, + ...V8_CODE_CACHE_TOOL_CONTRACTS, ]; const PATH_FIELDS = { @@ -30,6 +32,7 @@ const PATH_FIELDS = { inspect_devicetree: ["path"], inspect_playwright_trace: ["path"], inspect_python_pyc: ["path"], + inspect_v8_code_cache: ["path"], export_web_scripts: ["capture_path", "output_directory"], recover_javascript_sources: ["path", "output_directory", "additional_paths"], } as const; @@ -129,6 +132,7 @@ it("rejects one relative path field at a time on tools/list", async () => { "inspect_devicetree.path", "inspect_playwright_trace.path", "inspect_python_pyc.path", + "inspect_v8_code_cache.path", "export_web_scripts.capture_path", "export_web_scripts.output_directory", "recover_javascript_sources.path", diff --git a/tests/boundary/mcp/v8CodeCacheMcp.test.ts b/tests/boundary/mcp/v8CodeCacheMcp.test.ts new file mode 100644 index 0000000000..2868a72307 --- /dev/null +++ b/tests/boundary/mcp/v8CodeCacheMcp.test.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createServer } from "../../../src/server/createServer.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { v8CodeCacheSchema } from "../../../src/domain/v8/v8CodeCache.js"; +import { v8CodeCacheFixture } from "../../../src/v8/V8CodeCache.fixture.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const completeEvidenceSchema = toolContract( + "inspect_v8_code_cache", +).outputSchema.safeExtend({ normalized_result: v8CodeCacheSchema }); + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error("V8 code cache inspection must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_v8_code_cache and reports a real container header", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_v8_code_cache", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-v8-mcp-"); + const path = join(root, "module.jsc"); + await writeFile( + path, + v8CodeCacheFixture("function m(){ return 1; } m();"), + ); + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = completeEvidenceSchema.parse(response.structuredContent); + const report = evidence.normalized_result; + expect(report.header.magic_build_stable).toBe(0xc0de); + expect(report.header.header_bytes).toBe(32); + expect(report.payload.offset).toBe(32); + expect(report.payload.offset + report.payload.bytes).toBe( + report.artifact.bytes, + ); + expect(report.limitations.join(" ")).toContain( + "byte-pattern observations", + ); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_v8_code_cache", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path: "module.jsc" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_v8_code_cache" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "reports a foreign magic as an unsupported target rather than a parse", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-v8-unsupported-"); + const path = join(root, "not-a-cache.jsc"); + const foreign = Buffer.from(v8CodeCacheFixture()); + foreign.writeUInt32LE(0xdeadbeef, 0); + await writeFile(path, foreign); + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "unsupported_target", + details: { operation: "inspect_v8_code_cache" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "reports a payload-length disagreement as invalid input", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-v8-malformed-"); + const path = join(root, "truncated.jsc"); + const corrupt = Buffer.from(v8CodeCacheFixture()); + corrupt.writeUInt32LE(corrupt.readUInt32LE(20) + 8, 20); + await writeFile(path, corrupt); + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_v8_code_cache", + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: corrupt.length, + sha256: createHash("sha256").update(corrupt).digest("hex"), + }, + }, + }); + } finally { + await session.close(); + } + }, +);