Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb1be90c4 | ||
|
|
44bac35c50 | ||
|
|
23e9e706a7 | ||
|
|
60bb5d92e2 | ||
|
|
35c6e25fa6 | ||
|
|
9c2b85ff0c | ||
|
|
add24bc3ad | ||
|
|
1e238f271c | ||
|
|
117d3f9036 | ||
|
|
c537c52c96 | ||
|
|
6f96d7a3fa | ||
|
|
7590b50801 | ||
|
|
bd5d189a8f | ||
|
|
89268f4a45 | ||
|
|
3637205622 | ||
|
|
b4f9f0deac | ||
|
|
ec0e663cca | ||
|
|
26356dc34a | ||
|
|
b928963675 | ||
|
|
8451b961ad | ||
|
|
4a4bbab3c3 | ||
|
|
e5d2805b42 | ||
|
|
6f28b3633b | ||
|
|
c0eb612780 | ||
|
|
27dc9735e4 | ||
|
|
51e0df97e9 | ||
|
|
292fd532c9 | ||
|
|
aa7d960ef5 | ||
|
|
0b7213c890 | ||
|
|
63fe0eb5db | ||
|
|
ddce4bb446 | ||
|
|
dd3497e133 | ||
|
|
4f54379831 | ||
|
|
d77bc0e8a0 | ||
|
|
527ade245a | ||
|
|
dbcec4cf56 | ||
|
|
34fd7bdf7d | ||
|
|
81ab0ddc95 | ||
|
|
306ca984bc | ||
|
|
dd46a13dec | ||
|
|
ce5bc8cb01 | ||
|
|
b88bdfccd2 | ||
|
|
34aa22d0aa |
No files matched your search
@@ -219,6 +219,12 @@ explicit path or endpoint and do not need it.
|
||||
delivery requires `--evidence_output`.
|
||||
See the
|
||||
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
- Erlang BEAM IFF container: `inspect_erlang_beam` with the explicit local
|
||||
`.beam` file. This built-in target-free inspection requires no Erlang
|
||||
installation. Preserve chunk names, atom order, export/import/lambda tables,
|
||||
and the Code header. Missing optional tables stay null. Bytecode is not
|
||||
executed. See the
|
||||
[Erlang BEAM guide](https://github.com/morluto/rea/blob/main/docs/erlang-beam.md).
|
||||
|
||||
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
|
||||
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
|
||||
|
||||
@@ -22,10 +22,10 @@ requires complete application analysis; `source_paths: []` is invalid, not an
|
||||
unfiltered request. `detail: "summary"` reduces delivery
|
||||
and does not by itself reduce analysis work.
|
||||
|
||||
ASAR header/index admission is fixed at 16 MiB / 16777216 bytes for this
|
||||
release and has no runtime override. `source_paths`, `detail`, and
|
||||
`max_heap_mb` do not bypass or raise it. Analyze an extracted directory, or
|
||||
supply an archive whose header fits.
|
||||
ASAR header/index admission defaults to 16 MiB / 16777216 bytes;
|
||||
`REA_ASAR_MAX_HEADER_BYTES` (at most 256 MiB, set where REA starts) changes it.
|
||||
`source_paths`, `detail`, and `max_heap_mb` do not bypass or raise it. Or
|
||||
analyze an extracted directory.
|
||||
|
||||
Exact `#alias` specifiers resolve through the importer's
|
||||
enclosing package `imports` map to package-local files; nested `node_modules`
|
||||
|
||||
@@ -45,7 +45,8 @@ See [desktop installations](https://github.com/morluto/rea/blob/main/docs/deskto
|
||||
|
||||
## Go build metadata
|
||||
|
||||
Use `inspect_go_binary` for an explicit local ELF, PE or thin Mach-O when the
|
||||
Use `inspect_go_binary` for an explicit local ELF, PE or Mach-O (universal
|
||||
files need `architecture`) when the
|
||||
question concerns the embedded compiler version, module/dependency versions,
|
||||
local replacements or build settings. No `open_binary`, Go installation or
|
||||
target execution is required. The CLI equivalent is `inspect-go-binary PATH`.
|
||||
@@ -84,7 +85,8 @@ See [IL2CPP metadata](https://github.com/morluto/rea/blob/main/docs/il2cpp-metad
|
||||
|
||||
## Rust dependency metadata
|
||||
|
||||
Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when
|
||||
Use `inspect_rust_binary` for an explicit local ELF, PE, or Mach-O (universal
|
||||
files need `architecture`) when
|
||||
the question concerns the cargo-auditable crate list embedded in `.dep-v0`
|
||||
(Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target
|
||||
execution is required. The CLI equivalent is `inspect-rust-binary PATH`.
|
||||
@@ -94,6 +96,16 @@ dependencies. An omitted package kind stays null. A rustc commit hash is
|
||||
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
|
||||
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
|
||||
## Erlang BEAM files
|
||||
|
||||
Use `inspect_erlang_beam` for an explicit local `FOR1`/`BEAM` file when the
|
||||
question concerns IFF chunks, atom names, exports, imports, lambdas, or the
|
||||
Code header. No `open_binary`, Erlang installation, or bytecode execution is
|
||||
required. The CLI equivalent is `inspect-erlang-beam PATH`. Preserve artifact
|
||||
SHA-256, chunk file ranges, 1-based atom indices, and original atom spellings.
|
||||
Missing optional tables stay null. See
|
||||
[Erlang BEAM files](https://github.com/morluto/rea/blob/main/docs/erlang-beam.md).
|
||||
|
||||
## SQLite snapshots
|
||||
|
||||
Use `inspect_sqlite_database` with an explicit local database snapshot for its
|
||||
|
||||
@@ -191,6 +191,7 @@ Additional tools and host support depend on the target:
|
||||
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
|
||||
| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
|
||||
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas and the Code header | Built-in static BEAM inspection; [Erlang BEAM](docs/erlang-beam.md) |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
|
||||
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
{ text: "IL2CPP metadata", link: "/il2cpp-metadata" },
|
||||
{ text: "Model artifacts", link: "/model-artifacts" },
|
||||
{ text: "Rust dependencies", link: "/rust-binaries" },
|
||||
{ text: "Erlang BEAM", link: "/erlang-beam" },
|
||||
{
|
||||
text: "JavaScript and Electron",
|
||||
link: "/javascript-artifact-reconstruction",
|
||||
|
||||
+18
@@ -155,6 +155,16 @@ dependency edges, and the section's file range. A missing `.dep-v0` section is
|
||||
null metadata, not proof that the binary has no dependencies. See
|
||||
[Rust binaries](rust-binaries.md).
|
||||
|
||||
Inspect an Erlang BEAM file without executing bytecode:
|
||||
|
||||
```bash
|
||||
rea inspect-erlang-beam /absolute/path/to/app.beam --json
|
||||
```
|
||||
|
||||
The workflow reports IFF chunk names, atom tables, exports, imports, lambdas,
|
||||
and the Code chunk header. Missing optional tables stay null. See
|
||||
[Erlang BEAM files](erlang-beam.md).
|
||||
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), the
|
||||
[IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a
|
||||
native target. Substitute
|
||||
@@ -551,6 +561,14 @@ command, objdump, Rizin and GDB, and the agent environment server
|
||||
signal-derived status. This describes the current source routing; it is not a
|
||||
claim that every command or provider runtime has been independently exercised.
|
||||
|
||||
Commands with `--evidence_output` select their delivered view, then publish the
|
||||
complete Evidence bundle atomically. Cancellation before publication leaves no
|
||||
new file. A publication that has committed can win a later cancellation; the
|
||||
`cancelled` result then keeps the written path and byte count under
|
||||
`details.diagnostics.evidence_bundle`. If staging cleanup stays incomplete
|
||||
after commit, the `cleanup_incomplete` error reports the same file as
|
||||
`details.published_path` and `details.bytes`.
|
||||
|
||||
When the reader of piped output closes early, as `rea ... | head` does, the
|
||||
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
|
||||
SIGPIPE-terminated writer). The operation itself may have completed; only its
|
||||
|
||||
@@ -247,8 +247,8 @@ capture provides them.
|
||||
|
||||
ASAR observation reads the header through one open file handle and validates its
|
||||
declared lengths against the archive size. Header/index admission is the same
|
||||
fixed 16 MiB / 16777216 byte budget used by static ASAR reads. This release has
|
||||
no runtime override. An oversized header is not parsed; selecting a member path
|
||||
does not bypass admission, and the page stays unauthorized. The header cache
|
||||
separately retains at most 16 MiB of already-admitted encoded headers across
|
||||
eight archives. Evicting that cache does not raise admission.
|
||||
budget used by static ASAR reads: 16 MiB / 16777216 bytes by default, or
|
||||
`REA_ASAR_MAX_HEADER_BYTES` (at most 256 MiB) when set. An oversized header is
|
||||
not parsed; selecting a member path does not bypass admission, and the page
|
||||
stays unauthorized. The header cache separately retains at most that many bytes
|
||||
of already-admitted encoded headers across eight archives. Evicting that cache does not raise admission.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Erlang BEAM files
|
||||
|
||||
`inspect_erlang_beam` / `inspect-erlang-beam` answers whether one selected
|
||||
local file is a `FOR1`/`BEAM` IFF container, which chunks that form stores, and
|
||||
which atom, export, import, lambda, and Code-header fields those chunks carry.
|
||||
It reads the file without executing bytecode and does not require an Erlang
|
||||
installation or BEAM emulator.
|
||||
|
||||
```bash
|
||||
rea inspect-erlang-beam /absolute/path/to/app.beam --json
|
||||
```
|
||||
|
||||
The CLI resolves relative paths against the caller's working directory. MCP
|
||||
requires an absolute filesystem path and preserves that exact selection:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_erlang_beam",
|
||||
"arguments": { "path": "/artifacts/app.beam" }
|
||||
}
|
||||
```
|
||||
|
||||
## Observations
|
||||
|
||||
The inline Evidence result contains the artifact path, SHA-256, and byte length.
|
||||
`chunks` lists every IFF chunk name, file range, and payload SHA-256.
|
||||
|
||||
When `AtU8` or `Atom` is present, `atoms` is the 1-based name table and
|
||||
`module` is atom 1. Both the pre-OTP 28 table and the OTP 28+ compact table
|
||||
(negated count, compact-encoded name lengths) are read. `exports`, `imports`, and `lambdas` resolve atom indices to
|
||||
those names when the table is present; unresolved indices keep `function` or
|
||||
`module` null.
|
||||
|
||||
`code` reports the Code chunk instruction-set, opcode maximum, label count,
|
||||
function count, and the instruction stream's file range. Missing optional
|
||||
tables are null, not empty-everywhere.
|
||||
|
||||
## Limits and failures
|
||||
|
||||
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
|
||||
64 chunks and 4096 atoms are examined by default, and at most 65536 export,
|
||||
import, and lambda rows together. Malformed `FOR1`/`BEAM` headers, truncated
|
||||
chunks, and `AtU8` names that are not valid UTF-8 fail without partial success.
|
||||
|
||||
This workflow does not execute bytecode, inflate `LitT` literals, or decode
|
||||
`Attr`/`CInf` external-term payloads.
|
||||
+15
-5
@@ -109,11 +109,21 @@ module record and no dependency records has an observed empty dependency list.
|
||||
|
||||
## Supported boundary
|
||||
|
||||
The built-in TypeScript reader supports ELF, PE and thin Mach-O images with Go's
|
||||
inline or historical pointer build-info encoding. Source ranges refer to the
|
||||
original stable file snapshot, including pointer-backed strings. It does not
|
||||
scan arbitrary byte strings as compiler metadata or interpret a fat Mach-O
|
||||
container as one selected architecture.
|
||||
The built-in TypeScript reader supports ELF, PE and thin Mach-O images, and one
|
||||
selected slice of a universal Mach-O, with Go's inline or historical pointer
|
||||
build-info encoding. Source ranges refer to the original stable file snapshot,
|
||||
including pointer-backed strings. It does not scan arbitrary byte strings as
|
||||
compiler metadata.
|
||||
|
||||
A universal (FAT) Mach-O needs `architecture` (`x86`, `x86_64`, `arm`
|
||||
or `arm64`; CLI `--architecture`). REA reads its own FAT table, without `lipo`,
|
||||
and validates the selected slice's range, alignment and Mach-O header CPU
|
||||
against the table. The result keeps the universal file as `artifact` (its
|
||||
SHA-256 and size), adds `selected_slice` with the slice offset, length, raw CPU
|
||||
type/subtype and the FAT table digest, and reports every source range as a
|
||||
file offset inside that slice. Without `architecture`, or when the file has no
|
||||
such slice or several, the request fails on `architecture` and lists the slices
|
||||
present. `architecture` on any other image is rejected rather than ignored.
|
||||
|
||||
For ELF images without a named build-info section, discovery checks each
|
||||
nonempty writable, non-executable load segment in program-header order. This
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
|
||||
| Task | Guides |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
|
||||
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
|
||||
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
|
||||
|
||||
@@ -106,13 +106,16 @@ only reduces delivery; source selection reduces text acquisition and analysis.
|
||||
Explicit native provider or snapshot routing rejects `--source-path` rather
|
||||
than ignoring it.
|
||||
|
||||
ASAR header/index admission is a fixed 16 MiB / 16777216 bytes for this
|
||||
release. There is no runtime override. The archive index is read before any
|
||||
member can be selected, so `source_paths`, `--source-path`, `detail:
|
||||
"summary"`, `max_heap_mb`, and available host memory do not bypass or raise
|
||||
it. When the header exceeds that allowance, the failure names this budget.
|
||||
Analyze an extracted application directory, which does not admit an ASAR
|
||||
header, or supply an archive whose header fits. Repeating the unchanged
|
||||
ASAR header/index admission defaults to 16 MiB / 16777216 bytes. Set
|
||||
`REA_ASAR_MAX_HEADER_BYTES` in the environment that starts REA to a decimal
|
||||
byte count of at most 268435456 (256 MiB) to raise or lower it; restart REA
|
||||
after changing it. Invalid values are configuration errors, never a silent
|
||||
fallback. The archive index is read before any member can be selected, so
|
||||
`source_paths`, `--source-path`, `detail: "summary"`, `max_heap_mb`, and
|
||||
available host memory do not bypass or raise it. When the header exceeds the
|
||||
effective allowance, the failure names it, whether it came from the default or
|
||||
the setting, and the setting's limits. Alternatively, analyze an extracted
|
||||
application directory, which does not admit an ASAR header. Repeating the unchanged
|
||||
request fails the same way.
|
||||
|
||||
## Read the analysis
|
||||
|
||||
@@ -106,7 +106,12 @@ nodes. REA evaluates compatible static candidates conservatively:
|
||||
that disagreement. If a mapped candidate's digest is unavailable and no
|
||||
mapped content match is known, the comparison remains `unknown`.
|
||||
6. Missing authorization mappings and incomplete or truncated input coverage
|
||||
remain unknown or truncated rather than becoming absence claims.
|
||||
remain unknown or truncated rather than becoming absence claims. A runtime
|
||||
location with no automatic artifact-root or caller-declared mapping into
|
||||
any layer, and no content match, is `unknown` with
|
||||
`no-authorized-location-mapping`, with or without captured bytes; several
|
||||
content matches remain `ambiguous` under rule 4. Only a mapped location can
|
||||
be `unmatched` with `no-static-content-match`.
|
||||
|
||||
Every successful cross-layer `observed_as` edge has
|
||||
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
|
||||
|
||||
@@ -467,6 +467,14 @@ complete observation.
|
||||
vtable descriptors. Records include exact virtual addresses/file offsets and
|
||||
artifact evidence. Vtable indexes are metadata word offsets; witness indexes
|
||||
start after the conformance header. Names unavailable in metadata remain null.
|
||||
The byte reader holds the whole selected file in memory, so it admits files up
|
||||
to 64 MiB / 67108864 bytes by default. Select a larger budget with
|
||||
`max_input_bytes` (CLI `--max-input-bytes`), at most 1 GiB / 1073741824
|
||||
bytes. The whole file counts, including every slice of a universal Mach-O.
|
||||
Results record `input_admission` with the effective budget, its source
|
||||
(`default` or `caller`) and the bytes retained. A refusal names the observed
|
||||
file size, the effective budget and its source, and either the budget to
|
||||
select or, above 1 GiB, a deep analysis provider.
|
||||
The `swift_class_vtable_descriptors` coverage counters count examined vtable
|
||||
slots and successfully decoded slots, rather than type-section entries.
|
||||
The `objc_class_method_ivar_metadata` counters count admitted class and
|
||||
|
||||
@@ -145,6 +145,7 @@ npx rea-agents@latest setup
|
||||
| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) |
|
||||
| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) |
|
||||
| تثبيتات سطح المكتب | عائلات وقت التشغيل، والملفات الداعمة، وعلامات المكونات، والخطوات التالية | فحص محدود للملفات والترويسات;[تطبيقات سطح المكتب](../desktop-applications.md) |
|
||||
| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
|
||||
| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) |
|
||||
| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) |
|
||||
| Desktop-Installationen | Laufzeitkandidaten, Quelldateien, Komponenten und nächste Schritte | Begrenzte Verzeichnis- und Headerprüfung;[Desktop-Anwendungen](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ npx rea-agents@latest setup
|
||||
| Δομή ELF χωρίς εκτέλεση | Ενότητες, τμήματα, αρχικά σύμβολα/εγγραφές μετεγκατάστασης και ενδείξεις πιθανών μέτρων προστασίας από στατική ανάλυση | pwntools που παρέχει ο χρήστης σε Linux x64· [διαγνωστικά δυαδικών αρχείων](../binary-diagnostics.md) |
|
||||
| Δυαδικά αρχεία Go | Ενσωματωμένη έκδοση μεταγλωττιστή, modules, αντικαταστάσεις και ρυθμίσεις μεταγλώττισης | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· δεν απαιτείται εγκατάσταση του Go |
|
||||
| Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) |
|
||||
| Βάσεις δεδομένων SQLite | Πίνακες, στήλες, ευρετήρια, όψεις, triggers και επιλεγμένες γραμμές κανονικών πινάκων | Τοπικό στιγμιότυπο βάσης δεδομένων/WAL· Node.js 24.x >=24.15 ή 26+ με εγγενή όρια SQLite |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
|
||||
| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) |
|
||||
| Instalaciones de escritorio | Posibles entornos de ejecución, archivos de evidencia, componentes y siguientes pasos | Inspección acotada de directorios y cabeceras;[Aplicaciones de escritorio](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -137,6 +137,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
|
||||
| ساختار ELF آفلاین | بخشها، سگمنتها، نمادها و relocationهای اصلی و گزینههای احتمالی کاهش آسیبپذیری ایستا | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) |
|
||||
| بایتکد EVM | selectorهای dispatch، offsetهای بایتی، آرگومانهای استنباطشده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| کرشهای ثبتشدهٔ لینوکس | یادداشتهای خام، رجیسترها/سیگنالهای تمام threadهای ثبتشده و گزینههای احتمالی نگاشت | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرشهای ثبتشده](../recorded-crashes.md) |
|
||||
| نصبهای دسکتاپ | نامزدهای زمان اجرا، فایلهای شاهد، نشانههای مؤلفهها و مراحل بعدی | بررسی محدود پوشهها و سرآیندها;[برنامههای دسکتاپ](../desktop-applications.md) |
|
||||
| JavaScript / Electron | ماژولها، importها، source mapها، مسیرها، IPC و ارتباط افزونههای بومی | Node.js و npm؛ [تحلیل برنامه](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
|
||||
| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) |
|
||||
| Installations de bureau | Environnements candidats, fichiers justificatifs, composants et étapes suivantes | Inspection bornée des répertoires et en-têtes;[Applications de bureau](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -152,6 +152,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
|
||||
| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) |
|
||||
| Instalasi desktop | Kandidat runtime, berkas pendukung, penanda komponen, dan langkah berikutnya | Pemeriksaan direktori dan header terbatas;[Aplikasi desktop](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
|
||||
| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) |
|
||||
| Installazioni desktop | Runtime candidati, file di supporto, componenti e passi successivi | Ispezione limitata di directory e intestazioni;[Applicazioni desktop](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
|
||||
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) |
|
||||
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) |
|
||||
| デスクトップのインストール先 | ランタイム候補、根拠ファイル、コンポーネントの目印、次の調査手順 | 上限付きのディレクトリ・ヘッダー検査;[デスクトップアプリ](../desktop-applications.md) |
|
||||
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -153,6 +153,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
|
||||
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
|
||||
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
|
||||
|
||||
@@ -151,6 +151,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ
|
||||
| Offline ELF layout | Sections, segments, യഥാർത്ഥ symbols/relocations, static mitigation candidates | Linux x64-ൽ caller നൽകുന്ന pwntools; [binary diagnostics](../binary-diagnostics.md) |
|
||||
| Go binaries | ഉൾച്ചേർത്ത compiler version, modules, replacements, build settings | Built-in static ELF/PE/thin Mach-O പരിശോധന; Go installation ആവശ്യമില്ല |
|
||||
| Rust binaries | ഉൾച്ചേർത്ത cargo-auditable crate പേരുകൾ, versions, sources, dependency edges | Built-in static ELF/PE/thin Mach-O പരിശോധന; [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, അനുമാനിച്ച arguments, mutability | Linux x64-ൽ local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers, തിരഞ്ഞെടുത്ത സാധാരണ table rows | Local database/WAL snapshot; native SQLite പരിമിതികളോടെ Node.js 24.x >=24.15 അല്ലെങ്കിൽ 26+ |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
|
||||
| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) |
|
||||
| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) |
|
||||
| Instalacje desktopowe | Kandydaci środowisk uruchomieniowych, pliki źródłowe dowodów, komponenty i dalsze kroki | Ograniczona inspekcja katalogów i nagłówków;[Aplikacje desktopowe](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
|
||||
| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) |
|
||||
| Instalações desktop | Ambientes candidatos, arquivos de evidência, componentes e próximos passos | Inspeção limitada de diretórios e cabeçalhos;[Aplicações desktop](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
|
||||
| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) |
|
||||
| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) |
|
||||
| Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
|
||||
| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) |
|
||||
| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) |
|
||||
| ไดเรกทอรีติดตั้งแอปเดสก์ท็อป | รันไทม์ที่เป็นไปได้ ไฟล์หลักฐาน ตัวบ่งชี้ส่วนประกอบ และขั้นตอนถัดไป | ตรวจสอบไดเรกทอรีและส่วนหัวโดยจำกัดขอบเขต;[แอปเดสก์ท็อป](../desktop-applications.md) |
|
||||
| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -147,6 +147,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
|
||||
| Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) |
|
||||
| Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez |
|
||||
| Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) |
|
||||
| SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
|
||||
| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) |
|
||||
| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) |
|
||||
| Встановлені настільні програми | Можливі середовища виконання, файли доказів, компоненти та наступні кроки | Обмежена перевірка каталогів і заголовків;[Настільні програми](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -145,6 +145,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
|
||||
| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) |
|
||||
| Thư mục cài đặt ứng dụng máy tính | Các runtime có khả năng được dùng, tệp bằng chứng, dấu hiệu thành phần và bước tiếp theo | Kiểm tra thư mục và phần đầu tệp có giới hạn;[Ứng dụng máy tính](../desktop-applications.md) |
|
||||
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) |
|
||||
|
||||
@@ -149,6 +149,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) |
|
||||
|
||||
+12
-3
@@ -86,7 +86,7 @@ The inline Evidence result contains the artifact path, SHA-256, byte length,
|
||||
container format, architecture, word size, and byte order. When a `.dep-v0`
|
||||
section is present, `auditable_metadata` contains:
|
||||
|
||||
- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for thin Mach-O;
|
||||
- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for Mach-O;
|
||||
- `file_offset` and `section_bytes`, the exact file range of the compressed section;
|
||||
- `compressed_bytes` and `uncompressed_bytes`;
|
||||
- `format_version`, the embedded integer, or `null` when the field is omitted;
|
||||
@@ -124,8 +124,17 @@ verified. An omitted revision remains `null` and follows revision 0 semantics.
|
||||
Embedded names and versions are file content. They do not authenticate the
|
||||
packages or the compiler.
|
||||
|
||||
Universal Mach-O containers are unsupported. Select a thin slice first. ELF,
|
||||
PE, and thin Mach-O are the supported containers.
|
||||
ELF, PE, thin Mach-O and one selected slice of a universal Mach-O are the
|
||||
supported containers.
|
||||
A universal (FAT) Mach-O needs `architecture` (`x86`, `x86_64`, `arm`
|
||||
or `arm64`; CLI `--architecture`). REA reads its own FAT table, without `lipo`,
|
||||
and validates the selected slice's range, alignment and Mach-O header CPU
|
||||
against the table. The result keeps the universal file as `artifact` (its
|
||||
SHA-256 and size), adds `selected_slice` with the slice offset, length, raw CPU
|
||||
type/subtype and the FAT table digest, and reports the section and rustc-path offsets as a
|
||||
file offset inside that slice. Without `architecture`, or when the file has no
|
||||
such slice or several, the request fails on `architecture` and lists the slices
|
||||
present. `architecture` on any other image is rejected rather than ignored.
|
||||
|
||||
Package and dependency counts are checked against the minimum required JSON
|
||||
representation before graph allocation or schema cloning. Oversized graphs
|
||||
|
||||
@@ -65,6 +65,18 @@ API dependency, and independent fixture compiler oracles keep their existing
|
||||
TypeScript versions.
|
||||
|
||||
`npm run build:cached` compiles the CLI/MCP runtime and its packaged skill.
|
||||
The build also records `dist/mcp-catalog.json` (compact inputs) and
|
||||
`dist/mcp-catalog-full.json` from the canonical SDK registrations and binds
|
||||
each to the runtime with its own SHA-256 digest. These are
|
||||
runtime artifacts, separate from the test catalog below. Default target-free
|
||||
MCP startup serves its schemas without constructing analysis validators;
|
||||
the first tool or guided-workflow request materializes the canonical runtime
|
||||
on that same SDK server. Explicit compact/full input selections preserve
|
||||
their advertised schemas throughout materialization. Initial-target,
|
||||
HTTP and output-schema-off startup keep the eager path; source runs without
|
||||
recorded artifacts also use eager composition. Compare memory both after discovery and after a completed
|
||||
request; report diagnostic GC separately from normal resident memory.
|
||||
|
||||
Turbo's strict task environment preserves caller-selected `GOMAXPROCS`,
|
||||
`GOMEMLIMIT`, and `UV_THREADPOOL_SIZE` for resource-constrained builds and
|
||||
checks. These execution controls do not change artifact content, so changing
|
||||
|
||||
@@ -6,8 +6,9 @@ import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
/** Bind a trial to its source checkout and the compiled CLI actually launched. */
|
||||
export const captureRuntimeBinding = async (runtime) => {
|
||||
/** Bind a trial to its source checkout and compiled runtime, including deferred assets. */
|
||||
export const captureRuntimeBinding = async (runtime, kind = "cli") => {
|
||||
assert.ok(kind === "cli" || kind === "mcp", "Unknown runtime entry");
|
||||
const root = await realpath(runtime);
|
||||
const git = (args) =>
|
||||
execFileSync("git", args, { cwd: root, maxBuffer: 16 * 1024 * 1024 });
|
||||
@@ -50,10 +51,16 @@ export const captureRuntimeBinding = async (runtime) => {
|
||||
files.push(relative(root, path));
|
||||
}
|
||||
};
|
||||
await visit(join(root, "dist/cli-runtime"));
|
||||
// MCP materializes additional workers and reads build-owned JSON after
|
||||
// discovery. Bind every compiled JS/JSON asset, rather than only its entry.
|
||||
await visit(join(root, kind === "mcp" ? "dist" : "dist/cli-runtime"));
|
||||
assert.ok(
|
||||
files.includes(join("dist", "cli-runtime", "cli.js")),
|
||||
"Missing compiled CLI entry",
|
||||
files.includes(
|
||||
kind === "mcp"
|
||||
? join("dist", "mcp", "main.js")
|
||||
: join("dist", "cli-runtime", "cli.js"),
|
||||
),
|
||||
"Missing compiled runtime entry",
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
@@ -63,15 +70,23 @@ export const captureRuntimeBinding = async (runtime) => {
|
||||
hash.update(JSON.stringify([name, value.length, sha(value)]) + "\n");
|
||||
}
|
||||
return {
|
||||
binding_version: 1,
|
||||
binding_version: kind === "mcp" ? 2 : 1,
|
||||
root,
|
||||
node: process.version,
|
||||
git_revision: gitRevision,
|
||||
source_diff_sha256: sha(sourceDiff),
|
||||
untracked_source_sha256: sha(JSON.stringify(untrackedEntries)),
|
||||
compiled_cli_sha256: hash.digest("hex"),
|
||||
compiled_cli_files: files.length,
|
||||
compiled_cli_bytes: bytes,
|
||||
...(kind === "mcp"
|
||||
? {
|
||||
compiled_runtime_sha256: hash.digest("hex"),
|
||||
compiled_runtime_files: files.length,
|
||||
compiled_runtime_bytes: bytes,
|
||||
}
|
||||
: {
|
||||
compiled_cli_sha256: hash.digest("hex"),
|
||||
compiled_cli_files: files.length,
|
||||
compiled_cli_bytes: bytes,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,6 +97,10 @@ export const assertRuntimeBinding = (actual, expected, mode) => {
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
console.log(
|
||||
JSON.stringify(await captureRuntimeBinding(process.argv[2]), null, 2),
|
||||
JSON.stringify(
|
||||
await captureRuntimeBinding(process.argv[2], process.argv[3]),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -226,3 +226,8 @@ keeps default valid calls and typed refusals while checking full producer
|
||||
constraints separately. Five isolated suites passed 14 tests across call paths,
|
||||
ADB start, browser scenarios, source selection and managed analysis. The first
|
||||
browser attempt failed and remains recorded; no performance cohort changed.
|
||||
|
||||
The subsequent [startup audit](mcp-startup.md) extends this harness with
|
||||
source/bundle bindings and a post-materialization directory check. Its new
|
||||
measurements are a separate cohort; reproduce this report at its recorded
|
||||
revision when comparing the original timing and model-session receipts.
|
||||
@@ -7,6 +7,11 @@ import { parseArgs } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { sampleRetainedHeap } from "./mcp-retained-heap.mjs";
|
||||
import {
|
||||
captureRuntimeBinding,
|
||||
assertRuntimeBinding,
|
||||
} from "./managed-cli-runtime.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
@@ -15,54 +20,135 @@ const { values } = parseArgs({
|
||||
oracle: { type: "string" },
|
||||
output: { type: "string" },
|
||||
profile: { type: "string", default: "default" },
|
||||
phase: { type: "string", default: "analysis" },
|
||||
cache: { type: "string", default: "cold" },
|
||||
"cache-root": { type: "string" },
|
||||
bindings: { type: "string" },
|
||||
role: { type: "string" },
|
||||
"diagnostic-heap": { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
for (const key of ["runtime", "artifact", "oracle", "output"])
|
||||
assert.ok(["analysis", "discovery"].includes(values.phase));
|
||||
assert.ok(["cold", "warm"].includes(values.cache));
|
||||
assert.ok(!values["diagnostic-heap"] || values.phase === "analysis");
|
||||
for (const key of values.phase === "analysis"
|
||||
? ["runtime", "artifact", "oracle", "output"]
|
||||
: ["runtime", "output"])
|
||||
assert.ok(
|
||||
values[key] && resolve(values[key]) === values[key],
|
||||
`${key} must be absolute`,
|
||||
);
|
||||
assert.ok(["default", "full", "compact"].includes(values.profile));
|
||||
if (values.cache === "warm")
|
||||
assert.ok(
|
||||
values["cache-root"] &&
|
||||
resolve(values["cache-root"]) === values["cache-root"],
|
||||
);
|
||||
await mkdir(values.output, { recursive: false });
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
const artifactSha = hash(await readFile(values.artifact));
|
||||
assert.equal(artifactSha, oracle.artifact_sha256);
|
||||
let oracle;
|
||||
let artifactSha;
|
||||
if (values.phase === "analysis") {
|
||||
oracle = JSON.parse(await readFile(values.oracle, "utf8"));
|
||||
artifactSha = hash(await readFile(values.artifact));
|
||||
assert.equal(artifactSha, oracle.artifact_sha256);
|
||||
}
|
||||
let expectedBinding;
|
||||
let bindingBefore;
|
||||
let auditBeforeMs;
|
||||
let measurementInputsSha256;
|
||||
const producerSources = {};
|
||||
if (values.bindings !== undefined) {
|
||||
assert.ok(["baseline", "candidate"].includes(values.role));
|
||||
const inputBytes = await readFile(values.bindings);
|
||||
measurementInputsSha256 = hash(inputBytes);
|
||||
const inputs = JSON.parse(inputBytes.toString("utf8"));
|
||||
for (const name of [
|
||||
"mcp-input-catalog.mjs",
|
||||
"managed-cli-runtime.mjs",
|
||||
"mcp-retained-heap.mjs",
|
||||
]) {
|
||||
producerSources[name] = hash(
|
||||
await readFile(new URL(name, import.meta.url)),
|
||||
);
|
||||
assert.equal(producerSources[name], inputs.harness_sources_sha256[name]);
|
||||
}
|
||||
expectedBinding = inputs.runtime_bindings[values.role];
|
||||
const start = performance.now();
|
||||
bindingBefore = await captureRuntimeBinding(values.runtime, "mcp");
|
||||
auditBeforeMs = performance.now() - start;
|
||||
assertRuntimeBinding(bindingBefore, expectedBinding, values.role);
|
||||
}
|
||||
const environment = {
|
||||
PATH: process.env.PATH,
|
||||
HOME: values.output,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
UV_THREADPOOL_SIZE: "1",
|
||||
NODE_OPTIONS: "--max-old-space-size=2048",
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
...(values.cache === "cold"
|
||||
? { NODE_DISABLE_COMPILE_CACHE: "1" }
|
||||
: { NODE_COMPILE_CACHE: values["cache-root"] }),
|
||||
...(values.profile === "default"
|
||||
? {}
|
||||
: { REA_MCP_INPUT_SCHEMA_PROFILE: values.profile }),
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
|
||||
args: [
|
||||
...(values["diagnostic-heap"] ? ["--inspect=127.0.0.1:0"] : []),
|
||||
join(values.runtime, "scripts/rea.mjs"),
|
||||
"mcp",
|
||||
],
|
||||
cwd: values.output,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const protocolRequests = {};
|
||||
const send = transport.send.bind(transport);
|
||||
transport.send = (...args) => {
|
||||
const method = args[0].method;
|
||||
if (typeof method === "string")
|
||||
protocolRequests[method] = (protocolRequests[method] ?? 0) + 1;
|
||||
return send(...args);
|
||||
};
|
||||
const client = new Client({ name: "rea-catalog-task", version: "1" });
|
||||
const report = {
|
||||
profile: values.profile,
|
||||
phase: values.phase,
|
||||
cache: values.cache,
|
||||
cache_root: values["cache-root"] ?? null,
|
||||
role: values.role,
|
||||
measurement_inputs_sha256: measurementInputsSha256,
|
||||
producer_sources_sha256: producerSources,
|
||||
artifact_sha256: artifactSha,
|
||||
calls: [],
|
||||
checks: [],
|
||||
status: "incomplete",
|
||||
cleanup_verified: false,
|
||||
protocol_requests: protocolRequests,
|
||||
};
|
||||
const transcript = [];
|
||||
let stderr = "";
|
||||
let inspectorEndpoint;
|
||||
transport.stderr?.on("data", (value) => {
|
||||
stderr += value;
|
||||
if (values["diagnostic-heap"] && inspectorEndpoint === undefined)
|
||||
inspectorEndpoint = stderr.match(
|
||||
/Debugger listening on (ws:\/\/127\.0\.0\.1:\d+\/[^\s]+)/u,
|
||||
)?.[1];
|
||||
});
|
||||
const began = performance.now();
|
||||
try {
|
||||
await client.connect(transport);
|
||||
report.protocol_era = client.getProtocolEra();
|
||||
await writeFile(
|
||||
join(values.output, "server.json"),
|
||||
JSON.stringify({
|
||||
info: client.getServerVersion(),
|
||||
capabilities: client.getServerCapabilities(),
|
||||
instructions: client.getInstructions(),
|
||||
}),
|
||||
);
|
||||
const catalog = await client.listTools();
|
||||
assert.equal(catalog.nextCursor, undefined);
|
||||
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
|
||||
@@ -73,97 +159,154 @@ try {
|
||||
ajv.validateSchema(tool.inputSchema),
|
||||
`Invalid schema: ${tool.name}`,
|
||||
);
|
||||
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
|
||||
{
|
||||
const encoded = JSON.stringify(catalog);
|
||||
await writeFile(join(values.output, "catalog.json"), encoded);
|
||||
report.catalog_sha256 = hash(encoded);
|
||||
}
|
||||
report.tool_names = [...definitions.keys()];
|
||||
const call = async (name, args) => {
|
||||
const definition = definitions.get(name);
|
||||
assert.ok(definition, `Missing named tool ${name}`);
|
||||
const start = performance.now();
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ toolDefinition: definition },
|
||||
if (values.phase === "discovery") {
|
||||
const prompts = await client.listPrompts();
|
||||
assert.equal(prompts.nextCursor, undefined);
|
||||
await writeFile(
|
||||
join(values.output, "prompts.json"),
|
||||
JSON.stringify(prompts),
|
||||
);
|
||||
report.calls.push({
|
||||
name,
|
||||
elapsed_ms: performance.now() - start,
|
||||
is_error: reply.isError === true,
|
||||
});
|
||||
transcript.push({ name, arguments: args, reply });
|
||||
return reply;
|
||||
};
|
||||
const input = {
|
||||
path: values.artifact,
|
||||
view: {
|
||||
collection: "packages",
|
||||
names: ["serde"],
|
||||
fields: ["name", "version", "kind"],
|
||||
},
|
||||
};
|
||||
const first = await call("inspect_rust_binary", input);
|
||||
assert.notEqual(first.isError, true);
|
||||
const selected = first.structuredContent;
|
||||
assert.ok(selected?.normalized_result?.page);
|
||||
assert.deepEqual(selected.normalized_result.page.rows, oracle.serde_rows);
|
||||
assert.equal(selected.subject.digest.sha256, artifactSha);
|
||||
const parentId = selected.normalized_result.parent_evidence_id;
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const reply = await call("inspect_analysis_view", {
|
||||
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||
report.prompts_sha256 = hash(JSON.stringify(prompts));
|
||||
report.answer = {
|
||||
tools: report.tool_names,
|
||||
prompts: prompts.prompts.map((prompt) => prompt.name),
|
||||
};
|
||||
report.status = "passed";
|
||||
} else {
|
||||
const call = async (name, args) => {
|
||||
const definition = definitions.get(name);
|
||||
assert.ok(definition, `Missing named tool ${name}`);
|
||||
const start = performance.now();
|
||||
const reply = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ toolDefinition: definition },
|
||||
);
|
||||
report.calls.push({
|
||||
name,
|
||||
elapsed_ms: performance.now() - start,
|
||||
is_error: reply.isError === true,
|
||||
});
|
||||
transcript.push({ name, arguments: args, reply });
|
||||
return reply;
|
||||
};
|
||||
const input = {
|
||||
path: values.artifact,
|
||||
view: {
|
||||
kind: "rust",
|
||||
collection: "packages",
|
||||
names: ["serde"],
|
||||
fields: ["name", "version", "kind"],
|
||||
},
|
||||
};
|
||||
const first = await call("inspect_rust_binary", input);
|
||||
assert.notEqual(first.isError, true);
|
||||
const selected = first.structuredContent;
|
||||
assert.ok(selected?.normalized_result?.page);
|
||||
assert.deepEqual(selected.normalized_result.page.rows, oracle.serde_rows);
|
||||
assert.equal(selected.subject.digest.sha256, artifactSha);
|
||||
const parentId = selected.normalized_result.parent_evidence_id;
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const reply = await call("inspect_analysis_view", {
|
||||
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||
view: {
|
||||
kind: "rust",
|
||||
collection: "packages",
|
||||
names: ["serde"],
|
||||
fields: ["name", "version", "kind"],
|
||||
},
|
||||
});
|
||||
assert.notEqual(reply.isError, true);
|
||||
assert.deepEqual(
|
||||
reply.structuredContent.normalized_result.page.rows,
|
||||
oracle.serde_rows,
|
||||
);
|
||||
assert.equal(
|
||||
reply.structuredContent.normalized_result.parent_evidence_id,
|
||||
parentId,
|
||||
);
|
||||
}
|
||||
const malformed = await call("inspect_rust_binary", {
|
||||
path: values.artifact,
|
||||
view: { collection: "packages", offset: -1 },
|
||||
});
|
||||
assert.notEqual(reply.isError, true);
|
||||
assert.equal(malformed.isError, true);
|
||||
report.checks.push(
|
||||
"Independent package rows and digest; repeated retained identity; canonical negative-offset refusal",
|
||||
);
|
||||
const exported = await call("export_evidence_bundle", {
|
||||
path: join(values.output, "evidence.json"),
|
||||
});
|
||||
assert.notEqual(exported.isError, true);
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||
);
|
||||
const parent = bundle.records.find(
|
||||
(record) => record.evidence_id === parentId,
|
||||
);
|
||||
assert.ok(parent, "Complete original Evidence is retained");
|
||||
assert.equal(parent.subject.digest.sha256, artifactSha);
|
||||
assert.deepEqual(
|
||||
reply.structuredContent.normalized_result.page.rows,
|
||||
oracle.serde_rows,
|
||||
parent.normalized_result.auditable_metadata.packages,
|
||||
oracle.packages,
|
||||
);
|
||||
assert.equal(
|
||||
reply.structuredContent.normalized_result.parent_evidence_id,
|
||||
parentId,
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.dependency_edges,
|
||||
oracle.edges,
|
||||
);
|
||||
report.parent_evidence_id = parentId;
|
||||
report.parent_semantic_sha256 = hash(JSON.stringify(parent));
|
||||
report.answer = oracle.serde_rows;
|
||||
assert.deepEqual(
|
||||
await client.listTools(),
|
||||
catalog,
|
||||
"Advertisement changed after canonical materialization",
|
||||
);
|
||||
report.checks.push(
|
||||
"Catalog remains identical after canonical materialization",
|
||||
);
|
||||
report.status = "passed";
|
||||
}
|
||||
if (values["diagnostic-heap"]) {
|
||||
report.status = "incomplete";
|
||||
assert.ok(inspectorEndpoint, "Missing explicitly launched inspector");
|
||||
report.diagnostic_heap = await sampleRetainedHeap(
|
||||
inspectorEndpoint,
|
||||
transport.pid,
|
||||
);
|
||||
report.status = "passed";
|
||||
}
|
||||
const malformed = await call("inspect_rust_binary", {
|
||||
path: values.artifact,
|
||||
view: { collection: "packages", offset: -1 },
|
||||
});
|
||||
assert.equal(malformed.isError, true);
|
||||
report.checks.push(
|
||||
"Independent package rows and digest; repeated retained identity; canonical negative-offset refusal",
|
||||
);
|
||||
const exported = await call("export_evidence_bundle", {
|
||||
path: join(values.output, "evidence.json"),
|
||||
});
|
||||
assert.notEqual(exported.isError, true);
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(values.output, "evidence.json"), "utf8"),
|
||||
);
|
||||
const parent = bundle.records.find(
|
||||
(record) => record.evidence_id === parentId,
|
||||
);
|
||||
assert.ok(parent, "Complete original Evidence is retained");
|
||||
assert.equal(parent.subject.digest.sha256, artifactSha);
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.packages,
|
||||
oracle.packages,
|
||||
);
|
||||
assert.deepEqual(
|
||||
parent.normalized_result.auditable_metadata.dependency_edges,
|
||||
oracle.edges,
|
||||
);
|
||||
report.parent_evidence_id = parentId;
|
||||
report.parent_semantic_sha256 = hash(JSON.stringify(parent));
|
||||
report.answer = oracle.serde_rows;
|
||||
report.status = "passed";
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
report.cleanup_verified = transport.pid === null;
|
||||
assert.ok(report.cleanup_verified);
|
||||
report.elapsed_ms = performance.now() - began;
|
||||
if (expectedBinding !== undefined) {
|
||||
const start = performance.now();
|
||||
const after = await captureRuntimeBinding(values.runtime, "mcp");
|
||||
report.runtime_audit_ms = {
|
||||
before: auditBeforeMs,
|
||||
after: performance.now() - start,
|
||||
};
|
||||
assertRuntimeBinding(after, expectedBinding, values.role);
|
||||
assert.deepEqual(after, bindingBefore);
|
||||
assert.equal(
|
||||
hash(await readFile(values.bindings)),
|
||||
measurementInputsSha256,
|
||||
);
|
||||
for (const [name, digest] of Object.entries(producerSources))
|
||||
assert.equal(
|
||||
hash(await readFile(new URL(name, import.meta.url))),
|
||||
digest,
|
||||
);
|
||||
report.runtime_binding = after;
|
||||
report.runtime_binding_verified = true;
|
||||
}
|
||||
await writeFile(
|
||||
join(values.output, "report.json"),
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
# Integer validator allocation after MCP materialization
|
||||
|
||||
Issue #6236; PR #4360. Linux x64, 2026-10-22.
|
||||
|
||||
<!-- BEGIN:allocation-report:provenance -->
|
||||
|
||||
Baseline `main@4a4bbab3c337701febeb6b532ace49041c4876e0`; candidate `461f342c71a72ef2be60912391df2393aa4e2ea3`. This is a pinned comparison.
|
||||
|
||||
In separate inspector/forced-GC diagnostics, retained REA V8 heap decreases by about 9.6 MiB. The three-pair ranges do not overlap in either profile. Inspector perturbation is included. Normal task timing, CPU and peak memory are reported separately; these observations do not establish a general latency or peak-memory reduction.
|
||||
|
||||
<!-- END:allocation-report:provenance -->
|
||||
|
||||
Directory discovery uses recorded advertisements after #4424; this study separates it from completed tasks that materialize canonical handlers. Context is unchanged.
|
||||
|
||||
## Change and compatibility
|
||||
|
||||
Replace 1,247 argument-free `z.number().int()` constructions with `z.int()` across 175 existing source files. Keep current shared schema owners, refinements, bounds, defaults and metadata. AST-token comparison against the baseline, after normalizing this construction and formatting, is equal for every touched source file. No new tools, provider effects, caches or response contracts.
|
||||
|
||||
The locked Zod 4.4.3 chained form retains a `ZodNumber` with a separate `ZodNumberFormat` check; direct construction uses the format itself. Incur 0.6.1 coerces argv strings only for `ZodNumber`. Existing CLI declarations and shared-input exceptions therefore retain the chained form. The actual compiled CLI registers 160 leaf commands; 104 numeric argument/option fields have identical results/errors in 974 installed-Incur parser cases across both runtimes, each field with an accepted probe. Fields are isolated from unrelated required options and no handlers are invoked. This is consumer-boundary coverage, not a real-engine claim.
|
||||
|
||||
Seven dependency-boundary variants match parsed values and complete error issues for 126 cases, including fractions, nonfinite values, strings/null/undefined and unsafe integers; 14 JSON Schema projections match in draft-07/draft-2020-12. Actual SDK advertisements match byte-for-byte in both profiles before/after materialization and across roles.
|
||||
|
||||
## Tasks, artifacts and bounds
|
||||
|
||||
Reuse the shared SDK task driver and independent ELF/section/zlib/JSON oracles from [the catalog study](mcp-input-catalog.md): official Alpine v3.21 x86_64 ripgrep 14.1.1-r0 and Zellij 0.40.0-r0. All 42/422 packages and 88/1,063 edges match the independent oracles. Select serde name/version/kind, repeat exact retained-parent reads twice, verify a canonical negative-offset refusal, export/check complete Evidence and confirm the unchanged directory. `kind: null` stays unknown. Targets are not executed; dependency presence does not establish runtime use. Acquisition URLs, archive/executable/oracle hashes and complete answers are in [the receipts](mcp-integer-allocation.json). Previously acquired public bytes are rehashed, not redownloaded.
|
||||
|
||||
Normal cohort: three alternating baseline/candidate pairs for each task × cold/warm server compile cache × default compact/full inputs: 72 trials, plus four excluded full-task warm priming runs. Separate diagnostic cohort: three alternating pairs per profile, cold Zellij tasks, 12 trials. All pass, with identical complete parent IDs/hashes and answers.
|
||||
|
||||
Node 24.18.0/npm 11.16.0; fresh client/server and HOME per trial, driver compile cache disabled. Each trial is serial, pinned to one CPU, 4 GiB hard/3 GiB early stop, no swap, at most 192 tasks. Before/after checks bind clean source, all compiled JS/JSON assets (1,990 baseline / 1,990 candidate files), scripts and manifests; producer and resource-observer hashes are also bound. No OOM, resource stop or leftover payload processes. Whole-family measurements include SDK validation, extra calls, source/compiled audits and oracle checking; kernel peak includes file cache. Build/acquisition/oracle generation and priming are excluded. Warm uses a populated role/profile-specific Node cache; hit rate and OS page cache are unmeasured, and audits warm compiled-file pages.
|
||||
|
||||
## Normal task costs
|
||||
|
||||
Medians, baseline → candidate; all ranges are in the JSON. No inspector or diagnostic GC runs in this cohort.
|
||||
|
||||
<!-- BEGIN:allocation-report:normal -->
|
||||
|
||||
| Task | Cache | Input profile | Wall s | Family CPU s | Family peak MiB |
|
||||
| --------- | ----- | ------------- | --------------: | --------------: | --------------: |
|
||||
| directory | cold | default | 2.739 → 2.239 | 2.553 → 2.075 | 247.4 → 248.4 |
|
||||
| directory | cold | full | 2.940 → 2.437 | 2.682 → 2.217 | 348.0 → 346.8 |
|
||||
| directory | warm | default | 2.639 → 2.135 | 2.461 → 1.980 | 251.8 → 246.2 |
|
||||
| directory | warm | full | 2.840 → 2.338 | 2.666 → 2.144 | 348.6 → 347.2 |
|
||||
| ripgrep | cold | default | 7.571 → 7.169 | 7.372 → 6.918 | 798.8 → 791.1 |
|
||||
| zellij | cold | default | 7.876 → 7.373 | 7.623 → 7.134 | 812.2 → 810.5 |
|
||||
| ripgrep | cold | full | 7.876 → 7.475 | 7.710 → 7.295 | 803.7 → 801.2 |
|
||||
| zellij | cold | full | 8.079 → 7.573 | 7.852 → 7.378 | 815.7 → 819.1 |
|
||||
| ripgrep | warm | default | 7.575 → 6.977 | 7.352 → 6.810 | 815.0 → 791.6 |
|
||||
| zellij | warm | default | 7.774 → 7.178 | 7.581 → 6.981 | 820.9 → 822.0 |
|
||||
| ripgrep | warm | full | 7.976 → 7.875 | 7.746 → 7.679 | 812.7 → 789.3 |
|
||||
| zellij | warm | full | 14.995 → 13.978 | 14.742 → 13.718 | 818.5 → 811.5 |
|
||||
|
||||
<!-- END:allocation-report:normal -->
|
||||
|
||||
### SDK span and audit separation
|
||||
|
||||
<!-- BEGIN:allocation-report:sdk -->
|
||||
|
||||
Cold/default directory audit wall medians are 1.273 s baseline / 0.782 s candidate; SDK spans are 0.944 → 0.948 s. Whole-family costs include those experimental audits. Their cause and per-span CPU are unmeasured.
|
||||
|
||||
SDK span from client connect through task checks and client/server close. Excludes pre/post runtime binding and preflight artifact/oracle loading. Diagnostic spans include inspector and forced GC. CPU is measured for the whole family only; per-span CPU is unmeasured.
|
||||
|
||||
| Task | Cache | Inputs | SDK span s, baseline → candidate |
|
||||
| --------- | ----- | ------- | -------------------------------: |
|
||||
| directory | cold | default | 0.944 → 0.948 |
|
||||
| directory | cold | full | 1.067 → 1.102 |
|
||||
| directory | warm | default | 0.888 → 0.878 |
|
||||
| directory | warm | full | 1.038 → 1.042 |
|
||||
| ripgrep | cold | default | 5.778 → 5.752 |
|
||||
| zellij | cold | default | 5.998 → 5.991 |
|
||||
| ripgrep | cold | full | 6.077 → 6.119 |
|
||||
| zellij | cold | full | 6.228 → 6.209 |
|
||||
| ripgrep | warm | default | 5.743 → 5.656 |
|
||||
| zellij | warm | default | 5.965 → 5.788 |
|
||||
| ripgrep | warm | full | 6.100 → 6.492 |
|
||||
| zellij | warm | full | 11.776 → 11.529 |
|
||||
|
||||
<!-- END:allocation-report:sdk -->
|
||||
|
||||
Full-profile warm Zellij is slower than its cold condition in both roles in this cohort. Cache hit rate and the cause of this observation are unmeasured, and cold/warm groups run sequentially. These timings support neither a general cache speedup nor a causal regression attribution.
|
||||
|
||||
## Separate retained-heap diagnostics
|
||||
|
||||
The same completed SDK task launches only its owned REA process with `--inspect=127.0.0.1:0`. Four CDP calls verify the owned transport PID, read heap, force GC and read heap again. The endpoint comes from that child’s stderr; the PID is a cross-check of the owned endpoint, not sole ownership proof. The inspector socket closes before MCP shutdown. These V8 values include inspector perturbation and exclude the client; diagnostic wall/CPU/family peaks include inspector and forced-GC overhead and are not normal-task timing results.
|
||||
|
||||
<!-- BEGIN:allocation-report:heap -->
|
||||
|
||||
| Inputs | REA V8 retained MiB | Baseline range | Candidate range | Diagnostic wall s | CPU s | Family peak MiB |
|
||||
| ------- | ------------------: | --------------: | --------------: | ----------------: | --------------: | --------------: |
|
||||
| default | 189.835 → 180.242 | 189.823–189.855 | 180.208–180.366 | 15.490 → 14.291 | 15.246 → 14.123 | 814.1 → 786.3 |
|
||||
| full | 191.260 → 181.693 | 191.165–191.304 | 181.588–181.848 | 8.979 → 8.377 | 8.769 → 8.142 | 810.3 → 824.9 |
|
||||
|
||||
<!-- END:allocation-report:heap -->
|
||||
|
||||
A mismatched PID is rejected; removing only that guard makes the negative oracle fail. Both connections in that check use the same owned REA endpoint, with no foreign-process attachment. Aggregation rejects diagnostic GC in a normal trial. Historical #4424 receipt reaggregation preserves previous metrics and adds the separately reported SDK-span fields; its aggregator source hash changes.
|
||||
|
||||
## Context accounting
|
||||
|
||||
<!-- BEGIN:allocation-report:context -->
|
||||
|
||||
tiktoken==0.14.0/cl100k_base; estimates, not model usage. Actual model usage is unmeasured.
|
||||
|
||||
| Profile | Input directory once | Directory task | ripgrep task | Zellij task |
|
||||
| ------- | -------------------: | -------------: | -----------: | ----------: |
|
||||
| default | 49,670 | 50,840 | 54,029 | 53,949 |
|
||||
| full | 205,744 | 206,914 | 210,103 | 210,023 |
|
||||
|
||||
Raw SDK directory costs per list: 756,165/912,436 tokens and 2,935,148/3,587,792 bytes for default/full.
|
||||
|
||||
Question, explicit recipe, server instructions, one complete name/description/input-schema directory, every tool argument and text reply, plus prompt directory for discovery. This projection assumes the client retains a single unchanged directory. Raw catalog result costs include every observed tools/list result separately, with output schemas and without JSON-RPC envelopes. SDK initialization/framing, reasoning and actual model usage are unmeasured. The analysis task includes retained-read, rejection, export/oracle and post-materialization directory checks.
|
||||
|
||||
<!-- END:allocation-report:context -->
|
||||
|
||||
## Validation and reproduction
|
||||
|
||||
- Both runtime builds and all six static checks plus architecture guards pass. Before the final main synchronization, 5,972 source/contract/public CLI/MCP tests pass across 323 files; four skip for unavailable/guarded coverage. After synchronization, both builds/static checks repeat successfully, 77 affected producer and CLI/MCP tests pass, and all 974 registered numeric-parameter parser cases match again. Includes real SQLite CLI/MCP record/column selection and JavaScript heap/deadline preservation. A first direct schema-validity invocation lacked generated catalog metadata; the supported focused entry generates it and all 15 cases pass. A later affected-test invocation selected two nonexistent paths; the corrected three-file producer/consumer run is the 77-case result above. Neither setup failure was counted as a passing test, and no production or timeout change was needed.
|
||||
- All 84 measured tasks and four warm priming tasks pass independent oracles, canonical refusal, parent/directory identity and owned cleanup checks. This does not verify external Hopper/Ghidra/IDA, Android devices, all providers or foreign OS behavior. The earlier macOS numbers in #4360 describe a different revision/platform and are not combined with these results.
|
||||
|
||||
Build the pinned baseline/candidate in separate clean worktrees with the locked toolchain. Use the candidate’s producer scripts for both roles. `measurement_inputs` in the JSON records the complete matrix, questions/recipes, runtime bindings and producer hashes; extract it unchanged as the bindings file. Historical receipts require their matching producer revision. One normal task:
|
||||
|
||||
```bash
|
||||
NODE_DISABLE_COMPILE_CACHE=1 node experiments/analysis-efficiency/mcp-input-catalog.mjs \
|
||||
--runtime "$CANDIDATE_RUNTIME" --bindings "$MEASUREMENT_INPUTS" --role candidate \
|
||||
--artifact "$RIPGREP_BINARY" --oracle "$RIPGREP_ORACLE" \
|
||||
--output "$NEW_CASE_DIRECTORY" --profile default --phase analysis --cache cold
|
||||
```
|
||||
|
||||
Run each task through `resource-run.py --observe-payload-exit --` to obtain whole-family counters. For warm runs replace `--cache cold` with `--cache warm --cache-root <role/profile cache>` after one complete priming task. For the separate Zellij diagnostic tasks add `--diagnostic-heap` and use cold cache. Keep their case names/logs aligned with `measurement_inputs`; aggregate both cohorts with:
|
||||
|
||||
```bash
|
||||
python experiments/analysis-efficiency/mcp-startup-costs.py "$RECEIPT_ROOT" \
|
||||
--output mcp-integer-allocation.json
|
||||
```
|
||||
|
||||
The aggregator requires the recorded tiktoken version, exact catalog/parent/runtime/producer/guard identities, clean source, successful payload cleanup and matching matrix coverage. Default driver behavior continues without an inspector; the heap flag is confined to the experiment.
|
||||
|
||||
Regenerate or verify every numerical section directly from the committed JSON:
|
||||
|
||||
```bash
|
||||
node experiments/analysis-efficiency/mcp-integer-report.mjs
|
||||
node experiments/analysis-efficiency/mcp-integer-report.mjs --check
|
||||
```
|
||||
|
||||
The documentation conformance gate checks current receipts, rejects stale normal/heap tables, and refuses missing producer markers without writing.
|
||||
@@ -0,0 +1,212 @@
|
||||
// One producer for the numerical sections of the committed allocation report.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
data: { type: "string" },
|
||||
markdown: { type: "string" },
|
||||
check: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
const dataPath =
|
||||
values.data ?? new URL("mcp-integer-allocation.json", import.meta.url);
|
||||
const markdownPath =
|
||||
values.markdown ?? new URL("mcp-integer-allocation.md", import.meta.url);
|
||||
const data = JSON.parse(await readFile(dataPath, "utf8"));
|
||||
assert.equal(data.status, "passed");
|
||||
const original = await readFile(markdownPath, "utf8");
|
||||
const fixed = (value, digits = 3) => {
|
||||
assert.ok(Number.isFinite(value));
|
||||
return value.toFixed(digits);
|
||||
};
|
||||
const mib = (value, digits = 1) => fixed(value / 2 ** 20, digits);
|
||||
const median = (values) => {
|
||||
assert.ok(values.length > 0);
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
};
|
||||
const table = (headers, rows, right = []) =>
|
||||
[
|
||||
`| ${headers.join(" | ")} |`,
|
||||
`| ${headers.map((_, index) => (right.includes(index) ? "---:" : "---")).join(" | ")} |`,
|
||||
...rows.map((row) => `| ${row.join(" | ")} |`),
|
||||
].join("\n");
|
||||
const pair = (item, key, format = fixed) =>
|
||||
`${format(item.baseline[key].median)} → ${format(item.candidate[key].median)}`;
|
||||
const heapMetric = "retained_v8_heap_bytes_after_gc";
|
||||
const heaps = data.heap_diagnostics.summary;
|
||||
const changes = heaps.map(
|
||||
(item) =>
|
||||
(item.baseline[heapMetric].median - item.candidate[heapMetric].median) /
|
||||
2 ** 20,
|
||||
);
|
||||
const changesText = [...new Set(changes.map((value) => fixed(value, 1)))].join(
|
||||
"–",
|
||||
);
|
||||
const separated = heaps.every(
|
||||
(item) => item.baseline[heapMetric].min > item.candidate[heapMetric].max,
|
||||
);
|
||||
const provenance = `Baseline \`main@${data.measurement_inputs.baseline_revision}\`; candidate \`${data.measurement_inputs.candidate_revision}\`. This is a pinned comparison.\n\nIn separate inspector/forced-GC diagnostics, retained REA V8 heap decreases by about ${changesText} MiB. ${separated ? "The three-pair ranges do not overlap in either profile." : "Ranges overlap; inspect the observations below."} Inspector perturbation is included. Normal task timing, CPU and peak memory are reported separately; these observations do not establish a general latency or peak-memory reduction.`;
|
||||
const normal = table(
|
||||
[
|
||||
"Task",
|
||||
"Cache",
|
||||
"Input profile",
|
||||
"Wall s",
|
||||
"Family CPU s",
|
||||
"Family peak MiB",
|
||||
],
|
||||
data.summary.map((item) => [
|
||||
item.artifact ?? "directory",
|
||||
item.cache,
|
||||
item.profile,
|
||||
pair(item, "wall_seconds"),
|
||||
pair(item, "cpu_seconds"),
|
||||
pair(item, "kernel_family_peak_bytes_including_page_cache", mib),
|
||||
]),
|
||||
[3, 4, 5],
|
||||
);
|
||||
const discovery = data.summary.find(
|
||||
(item) =>
|
||||
item.phase === "discovery" &&
|
||||
item.cache === "cold" &&
|
||||
item.profile === "default",
|
||||
);
|
||||
assert.ok(discovery);
|
||||
const audit = (role) =>
|
||||
median(
|
||||
data.records
|
||||
.filter(
|
||||
(row) =>
|
||||
row.phase === "discovery" &&
|
||||
row.cache === "cold" &&
|
||||
row.profile === "default" &&
|
||||
row.role === role,
|
||||
)
|
||||
.map(
|
||||
(row) =>
|
||||
(row.runtime_audit_ms.before + row.runtime_audit_ms.after) / 1000,
|
||||
),
|
||||
);
|
||||
const sdk =
|
||||
`Cold/default directory audit wall medians are ${fixed(audit("baseline"))} s baseline / ${fixed(audit("candidate"))} s candidate; SDK spans are ${pair(discovery, "sdk_task_elapsed_seconds")} s. Whole-family costs include those experimental audits. Their cause and per-span CPU are unmeasured.\n\n${data.sdk_task_elapsed_scope}\n\n` +
|
||||
table(
|
||||
["Task", "Cache", "Inputs", "SDK span s, baseline → candidate"],
|
||||
data.summary.map((item) => [
|
||||
item.artifact ?? "directory",
|
||||
item.cache,
|
||||
item.profile,
|
||||
pair(item, "sdk_task_elapsed_seconds"),
|
||||
]),
|
||||
[3],
|
||||
);
|
||||
const heap = table(
|
||||
[
|
||||
"Inputs",
|
||||
"REA V8 retained MiB",
|
||||
"Baseline range",
|
||||
"Candidate range",
|
||||
"Diagnostic wall s",
|
||||
"CPU s",
|
||||
"Family peak MiB",
|
||||
],
|
||||
heaps.map((item) => [
|
||||
item.profile,
|
||||
pair(item, heapMetric, (value) => mib(value, 3)),
|
||||
...["baseline", "candidate"].map(
|
||||
(role) =>
|
||||
`${mib(item[role][heapMetric].min, 3)}–${mib(item[role][heapMetric].max, 3)}`,
|
||||
),
|
||||
pair(item, "wall_seconds"),
|
||||
pair(item, "cpu_seconds"),
|
||||
pair(item, "kernel_family_peak_bytes_including_page_cache", mib),
|
||||
]),
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
);
|
||||
const integer = (value) => new Intl.NumberFormat("en-US").format(value);
|
||||
const tokens = (profile, phase, artifact) => {
|
||||
const values = data.records
|
||||
.filter(
|
||||
(row) =>
|
||||
row.profile === profile &&
|
||||
row.phase === phase &&
|
||||
row.artifact === artifact,
|
||||
)
|
||||
.map((row) => row.tokens_estimated.whole_task);
|
||||
assert.ok(values.length > 0);
|
||||
return [...new Set([Math.min(...values), Math.max(...values)])]
|
||||
.map(integer)
|
||||
.join("–");
|
||||
};
|
||||
const catalogs = data.catalogs;
|
||||
const context =
|
||||
`${data.tokenizer}. Actual model usage is unmeasured.\n\n` +
|
||||
table(
|
||||
[
|
||||
"Profile",
|
||||
"Input directory once",
|
||||
"Directory task",
|
||||
"ripgrep task",
|
||||
"Zellij task",
|
||||
],
|
||||
["default", "full"].map((profile) => [
|
||||
profile,
|
||||
integer(catalogs[profile].input_catalog_tokens),
|
||||
tokens(profile, "discovery", null),
|
||||
tokens(profile, "analysis", "ripgrep"),
|
||||
tokens(profile, "analysis", "zellij"),
|
||||
]),
|
||||
[1, 2, 3, 4],
|
||||
) +
|
||||
`\n\nRaw SDK directory costs per list: ${integer(catalogs.default.raw_catalog_tokens)}/${integer(catalogs.full.raw_catalog_tokens)} tokens and ${integer(catalogs.default.raw_catalog_bytes)}/${integer(catalogs.full.raw_catalog_bytes)} bytes for default/full.\n\n${data.context_scope}`;
|
||||
const sections = { provenance, normal, sdk, heap, context };
|
||||
// oxfmt may pad table cells and separator dashes; neither changes report facts.
|
||||
const normalized = (value) =>
|
||||
value
|
||||
.trim()
|
||||
.replace(/\s+/gu, " ")
|
||||
.replace(/\s*\|\s*/gu, "|")
|
||||
.replace(
|
||||
/(?<=\|)(:)?-{3,}(:)?(?=\|)/gu,
|
||||
(_, left, right) => `${left ?? ""}---${right ?? ""}`,
|
||||
);
|
||||
let rendered = original;
|
||||
const changed = [];
|
||||
for (const [name, content] of Object.entries(sections)) {
|
||||
const begin = `<!-- BEGIN:allocation-report:${name} -->`;
|
||||
const end = `<!-- END:allocation-report:${name} -->`;
|
||||
assert.equal(
|
||||
rendered.split(begin).length,
|
||||
2,
|
||||
`Expected one opening report marker: ${name}`,
|
||||
);
|
||||
assert.equal(
|
||||
rendered.split(end).length,
|
||||
2,
|
||||
`Expected one closing report marker: ${name}`,
|
||||
);
|
||||
const start = rendered.indexOf(begin) + begin.length;
|
||||
const finish = rendered.indexOf(end);
|
||||
assert.ok(finish > start, `Invalid report marker order: ${name}`);
|
||||
if (normalized(rendered.slice(start, finish)) !== normalized(content)) {
|
||||
changed.push(name);
|
||||
rendered =
|
||||
rendered.slice(0, start) + `\n\n${content}\n\n` + rendered.slice(finish);
|
||||
}
|
||||
}
|
||||
if (values.check) {
|
||||
assert.equal(
|
||||
changed.length,
|
||||
0,
|
||||
`Generated allocation report differs: ${changed.join(", ")}`,
|
||||
);
|
||||
console.log("Allocation report matches committed receipt data");
|
||||
} else if (changed.length) {
|
||||
await writeFile(markdownPath, rendered);
|
||||
console.log(`Updated allocation report sections: ${changed.join(", ")}`);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Diagnostic only: inspect the explicitly launched REA process after its task.
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export const sampleRetainedHeap = async (endpoint, expectedPid) => {
|
||||
const url = new URL(endpoint);
|
||||
assert.equal(url.protocol, "ws:");
|
||||
assert.equal(url.hostname, "127.0.0.1");
|
||||
assert.ok(Number.isSafeInteger(expectedPid) && expectedPid > 0);
|
||||
const socket = new WebSocket(url);
|
||||
let sequence = 0;
|
||||
const pending = new Map();
|
||||
const closed = new Promise((resolve) => {
|
||||
socket.addEventListener("close", resolve, { once: true });
|
||||
});
|
||||
const rejectPending = (cause) => {
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timer);
|
||||
request.reject(cause);
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
socket.addEventListener("error", () =>
|
||||
rejectPending(new Error("Owned REA inspector connection failed")),
|
||||
);
|
||||
socket.addEventListener("close", () =>
|
||||
rejectPending(new Error("Owned REA inspector connection closed")),
|
||||
);
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
const reply = JSON.parse(event.data);
|
||||
const request = pending.get(reply.id);
|
||||
if (request === undefined) return;
|
||||
pending.delete(reply.id);
|
||||
clearTimeout(request.timer);
|
||||
if (reply.error !== undefined)
|
||||
request.reject(new Error(JSON.stringify(reply.error)));
|
||||
else request.resolve(reply.result);
|
||||
} catch (cause) {
|
||||
rejectPending(cause);
|
||||
}
|
||||
});
|
||||
const withinDeadline = async (promise) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Owned REA inspector deadline exceeded")),
|
||||
10_000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
const call = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = ++sequence;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Owned REA inspector timed out: ${method}`));
|
||||
}, 10_000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
} catch (cause) {
|
||||
pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(cause);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await withinDeadline(
|
||||
new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
socket.addEventListener("close", reject, { once: true });
|
||||
}),
|
||||
);
|
||||
const identity = await call("Runtime.evaluate", {
|
||||
expression: "process.pid",
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(identity.result.value, expectedPid);
|
||||
const before = await call("Runtime.getHeapUsage");
|
||||
await call("HeapProfiler.collectGarbage");
|
||||
const after = await call("Runtime.getHeapUsage");
|
||||
for (const sample of [before, after]) {
|
||||
assert.ok(Number.isSafeInteger(sample.usedSize) && sample.usedSize >= 0);
|
||||
assert.ok(Number.isSafeInteger(sample.totalSize));
|
||||
assert.ok(sample.totalSize >= sample.usedSize);
|
||||
}
|
||||
return {
|
||||
scope:
|
||||
"Owned REA V8 heap after the complete SDK task; inspector and forced GC are diagnostic overhead",
|
||||
pid_verified: true,
|
||||
before_gc: before,
|
||||
after_gc: after,
|
||||
};
|
||||
} finally {
|
||||
rejectPending(new Error("Owned REA inspector diagnostic finished"));
|
||||
socket.close();
|
||||
await withinDeadline(closed);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Verify paired SDK startup/analysis receipts and account for the whole task."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
import tiktoken
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('root', type=Path)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.root
|
||||
input_bytes = (root / 'measurement-inputs.json').read_bytes()
|
||||
inputs = json.loads(input_bytes)
|
||||
expected = inputs['runtime_bindings']
|
||||
sha = lambda value: hashlib.sha256(value).hexdigest()
|
||||
compact = lambda value: json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
|
||||
encoding = tiktoken.get_encoding('cl100k_base')
|
||||
count = lambda value: len(encoding.encode(value, disallowed_special=()))
|
||||
for role in ['baseline', 'candidate']:
|
||||
assert expected[role]['git_revision'] == inputs[f'{role}_revision']
|
||||
assert expected[role]['source_diff_sha256'] == sha(b'')
|
||||
assert expected[role]['untracked_source_sha256'] == sha(b'[]')
|
||||
assert expected['baseline']['root'] != expected['candidate']['root']
|
||||
assert expected['baseline']['compiled_runtime_sha256'] != expected['candidate']['compiled_runtime_sha256']
|
||||
spec = importlib.util.spec_from_file_location('catalog_costs', Path(__file__).with_name('mcp-input-catalog-costs.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
catalogs = {}
|
||||
identities = {}
|
||||
rows = []
|
||||
heap_rows = []
|
||||
for case in inputs['cases'] + inputs.get('diagnostic_cases', []):
|
||||
directory = root / case['case']
|
||||
report = json.loads((directory / 'report.json').read_text())
|
||||
assert report['status'] == 'passed' and report['cleanup_verified']
|
||||
assert report['runtime_binding_verified']
|
||||
assert report['measurement_inputs_sha256'] == sha(input_bytes)
|
||||
for name, producer_digest in report['producer_sources_sha256'].items():
|
||||
assert producer_digest == inputs['harness_sources_sha256'][name]
|
||||
assert report['runtime_binding'] == expected[case['role']]
|
||||
assert type(report['elapsed_ms']) in [int, float] and math.isfinite(report['elapsed_ms']) and report['elapsed_ms'] >= 0
|
||||
for key in ['role', 'cache', 'profile', 'phase']:
|
||||
assert report[key] == case[key]
|
||||
raw = (directory / 'catalog.json').read_bytes()
|
||||
catalog_digest = sha(raw)
|
||||
assert catalog_digest == report['catalog_sha256']
|
||||
profile = case['profile']
|
||||
if profile not in catalogs:
|
||||
catalog = json.loads(raw)
|
||||
definitions = [{key: tool[key] for key in ['name', 'description', 'inputSchema']} for tool in catalog['tools']]
|
||||
catalogs[profile] = {'sha256': catalog_digest, 'tool_count': len(catalog['tools']),
|
||||
'input_catalog_tokens': count(compact(definitions)),
|
||||
'raw_catalog_tokens': count(raw.decode()), 'raw_catalog_bytes': len(raw)}
|
||||
assert catalog_digest == catalogs[profile]['sha256'], 'Directory differs across roles or phases'
|
||||
transcript = json.loads((directory / 'transcript.json').read_text())
|
||||
server = json.loads((directory / 'server.json').read_text())
|
||||
tokens = {'question_and_recipe': count(inputs['questions'][case['phase']] + '\n' + inputs['recipes'][case['phase']]),
|
||||
'server_instructions': count(server.get('instructions') or ''),
|
||||
'input_catalog_once': catalogs[profile]['input_catalog_tokens'],
|
||||
'arguments': sum(count(compact({'name': call['name'], 'arguments': call['arguments']})) for call in transcript),
|
||||
'text_replies': sum(count(block['text']) for call in transcript for block in call['reply']['content'] if block['type'] == 'text')}
|
||||
requests = report['protocol_requests']
|
||||
assert requests['initialize'] == 1
|
||||
if case['phase'] == 'analysis':
|
||||
assert len(transcript) == 5 and requests['tools/call'] == 5 and requests['tools/list'] == 2
|
||||
assert report['artifact_sha256'] == inputs['artifacts'][case['artifact']]['sha256']
|
||||
identity = (report['parent_evidence_id'], report['parent_semantic_sha256'], sha(compact(report['answer']).encode()))
|
||||
assert identities.setdefault(case['artifact'], identity) == identity
|
||||
assert sum(call['is_error'] for call in report['calls']) == 1
|
||||
else:
|
||||
assert not transcript and requests.get('tools/call', 0) == 0
|
||||
assert requests['tools/list'] == 1 and requests['prompts/list'] == 1
|
||||
prompts = (directory / 'prompts.json').read_bytes()
|
||||
assert sha(prompts) == report['prompts_sha256']
|
||||
assert identities.setdefault('prompts', report['prompts_sha256']) == report['prompts_sha256']
|
||||
tokens['prompt_directory'] = count(prompts.decode())
|
||||
tokens['whole_task'] = sum(tokens.values())
|
||||
resource = module.resource(directory)
|
||||
for name, guard_digest in resource['guard_sources_sha256'].items():
|
||||
assert guard_digest == inputs['harness_sources_sha256'][name]
|
||||
del resource['guard_sources_sha256']
|
||||
row = {**case, 'runtime_binding_sha256': sha(compact(report['runtime_binding']).encode()), 'runtime_audit_ms': report['runtime_audit_ms'],
|
||||
'sdk_task_elapsed_seconds': report['elapsed_ms'] / 1000,
|
||||
'catalog_sha256': catalog_digest, 'parent_evidence_id': report.get('parent_evidence_id'),
|
||||
'parent_semantic_sha256': report.get('parent_semantic_sha256'),
|
||||
'answer_sha256': sha(compact(report['answer']).encode()), 'tokens_estimated': tokens,
|
||||
'raw_catalog_result_tokens_all_lists': catalogs[profile]['raw_catalog_tokens'] * requests['tools/list'],
|
||||
'raw_catalog_result_bytes_all_lists': catalogs[profile]['raw_catalog_bytes'] * requests['tools/list'],
|
||||
'actual_model_usage': None, 'protocol_era': report['protocol_era'], 'protocol_requests': requests, 'calls': report['calls'],
|
||||
**resource,
|
||||
'receipt_sha256': {name: sha((directory / name).read_bytes()) for name in ['report.json', 'catalog.json', 'server.json', 'transcript.json']}}
|
||||
assert row['catalog_sha256'] == row['receipt_sha256']['catalog.json'] == catalogs[profile]['sha256']
|
||||
heap = report.get('diagnostic_heap')
|
||||
if case.get('diagnostic_heap'):
|
||||
assert case['phase'] == 'analysis' and case['cache'] == 'cold' and case['artifact'] == 'zellij'
|
||||
assert heap is not None and heap['pid_verified'] is True
|
||||
for sample in [heap['before_gc'], heap['after_gc']]:
|
||||
assert type(sample['usedSize']) is int and sample['usedSize'] >= 0
|
||||
assert type(sample['totalSize']) is int and sample['totalSize'] >= sample['usedSize']
|
||||
row['heap_observation'] = heap
|
||||
row['retained_v8_heap_bytes_after_gc'] = heap['after_gc']['usedSize']
|
||||
heap_rows.append(row)
|
||||
else:
|
||||
assert heap is None, 'Diagnostic GC contaminated a normal task trial'
|
||||
rows.append(row)
|
||||
assert len(rows) == 72
|
||||
summary = []
|
||||
for phase in ['discovery', 'analysis']:
|
||||
for cache in ['cold', 'warm']:
|
||||
for profile in ['default', 'full']:
|
||||
for artifact in ([None] if phase == 'discovery' else ['ripgrep', 'zellij']):
|
||||
item = {'phase': phase, 'cache': cache, 'profile': profile, 'artifact': artifact}
|
||||
for role in ['baseline', 'candidate']:
|
||||
selected = [row for row in rows if all(row[key] == item[key] for key in ['phase', 'cache', 'profile', 'artifact']) and row['role'] == role]
|
||||
assert len(selected) == 3
|
||||
item[role] = {metric: {'median': statistics.median(row[metric] for row in selected),
|
||||
'min': min(row[metric] for row in selected), 'max': max(row[metric] for row in selected)}
|
||||
for metric in ['wall_seconds', 'cpu_seconds', 'kernel_family_peak_bytes_including_page_cache', 'sdk_task_elapsed_seconds']}
|
||||
item[role]['tokens_estimated'] = [row['tokens_estimated']['whole_task'] for row in selected]
|
||||
summary.append(item)
|
||||
result = {'status': 'passed', 'tokenizer': f'tiktoken=={tiktoken.__version__}/cl100k_base; estimates, not model usage',
|
||||
'aggregation_sources_sha256': {path.name: sha(path.read_bytes()) for path in [Path(__file__), Path(spec.origin)]},
|
||||
'context_scope': 'Question, explicit recipe, server instructions, one complete name/description/input-schema directory, every tool argument and text reply, plus prompt directory for discovery. This projection assumes the client retains a single unchanged directory. Raw catalog result costs include every observed tools/list result separately, with output schemas and without JSON-RPC envelopes. SDK initialization/framing, reasoning and actual model usage are unmeasured. The analysis task includes retained-read, rejection, export/oracle and post-materialization directory checks.',
|
||||
'cache_scope': 'Cold disables server Node compile cache. Warm enables a prepopulated role/profile-specific cache after one complete analysis priming run. Fresh server/client processes and HOME each trial. Driver compile cache disabled in both cohorts. Cache hit rate and OS page cache are unmeasured; source/runtime audits warm compiled-file pages before startup.',
|
||||
'resource_scope': 'Whole SDK driver, resource observer and owned REA process family. Includes before/after source-and-all-compiled-JS/JSON binding, SDK schema checks, extra validation calls, bundle export and independent oracle checking. Kernel cgroup peak includes file cache. Build/download/oracle preparation and cache priming excluded. Retained V8 heap and forced-GC diagnostics were not separately measured.',
|
||||
'measurement_inputs': inputs, 'catalogs': catalogs, 'summary': summary, 'records': rows,
|
||||
'sdk_task_elapsed_scope': 'SDK span from client connect through task checks and client/server close. Excludes pre/post runtime binding and preflight artifact/oracle loading. Diagnostic spans include inspector and forced GC. CPU is measured for the whole family only; per-span CPU is unmeasured.',
|
||||
'warmup_receipts': {name: sha((root / name / 'report.json').read_bytes()) for name in [f'warmup-{profile}-{role}' for profile in ['default', 'full'] for role in ['baseline', 'candidate']]}}
|
||||
if heap_rows:
|
||||
assert len(heap_rows) == 12
|
||||
heap_summary = []
|
||||
for profile in ['default', 'full']:
|
||||
item = {'profile': profile}
|
||||
for role in ['baseline', 'candidate']:
|
||||
selected = [row for row in heap_rows if row['profile'] == profile and row['role'] == role]
|
||||
assert len(selected) == 3
|
||||
item[role] = {metric: {'median': statistics.median(row[metric] for row in selected),
|
||||
'min': min(row[metric] for row in selected), 'max': max(row[metric] for row in selected)}
|
||||
for metric in ['retained_v8_heap_bytes_after_gc', 'wall_seconds', 'cpu_seconds', 'sdk_task_elapsed_seconds',
|
||||
'kernel_family_peak_bytes_including_page_cache']}
|
||||
heap_summary.append(item)
|
||||
result['heap_diagnostics'] = {
|
||||
'scope': 'Separate three-pair Zellij tasks per profile, with an explicitly launched loopback inspector. Four CDP calls verify the owned REA PID, read heap, force GC and read heap again after the completed SDK task. These whole-family costs include inspector and diagnostic GC overhead; they are excluded from the normal timing cohort. Heap values describe REA V8 only, not the client or the whole process family.',
|
||||
'summary': heap_summary, 'records': heap_rows}
|
||||
args.output.write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(summary, indent=2))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
# MCP discovery and complete Rust tasks
|
||||
|
||||
Target-free stdio discovery previously constructed the canonical analysis runtime before the client could inspect its directory. The candidate records SDK advertisements at build time for default/explicit compact and explicit full inputs. Its first tool, prompt or completion request materializes canonical validators and handlers on the same SDK server. HTTP, output-schema-off, initial-target and unrecorded source runs retain eager composition.
|
||||
|
||||
Pinned baseline: [`main@c465ff5a8`](https://github.com/morluto/rea/commit/c465ff5a814b8e1a386fc547cbcd94df5c35f086). Measured candidate: [`04900abbc`](https://github.com/leeguooooo/rea/commit/04900abbcd3c3438fce4af932ff28400fe744a7d). Both use the same main base and locked dependencies. Before/after source and all compiled JS/JSON bindings identify the exact runtime used in every trial. The input manifest and producer scripts are also hashed and checked.
|
||||
|
||||
## Real tasks and independent facts
|
||||
|
||||
Official Alpine v3.21 packages supply ripgrep 14.1.1-r0 (3,416,168-byte executable) and Zellij 0.40.0-r0 (17,151,864 bytes). Their download URLs, archive/executable digests and aports revisions are retained in [the measurement data](mcp-startup.json). The independent Python ELF/section/zlib/JSON oracle checks 42 packages and 88 dependency edges for ripgrep, and 422 packages and 1,063 edges for Zellij. Serde versions are 1.0.210 and 1.0.137 respectively; the selected package kind remains explicit unknown (`null`). Verification covers embedded static build metadata; execution and runtime reachability remain unverified.
|
||||
|
||||
Each analysis task initializes the actual SDK client, lists and validates the input directory, selects serde name/version/kind, reads the exact retained parent twice, verifies canonical rejection of a negative offset, exports and independently checks the complete package/edge inventory, confirms unchanged advertisements after materialization, then closes. All five tool calls and both directory reads are included in the measured task. Discovery tasks initialize, list tools/prompts, validate the input directory and close without an analysis call.
|
||||
|
||||
All 72 paired trials and four priming runs passed. Each artifact has identical answers, parent Evidence IDs and complete semantic hashes across roles, profiles and cache modes. Both profiles retain byte-identical directories across baseline/candidate and before/after canonical materialization. The directory contains 213 tools. Complete Evidence is retained and exported through the existing shared workflow.
|
||||
|
||||
## Measured cost
|
||||
|
||||
Three alternating pairs per condition; table values are medians. Min/max ranges and individual receipts are in [mcp-startup.json](mcp-startup.json). Each cell is baseline → candidate. Kernel process-family peak includes file cache, SDK driver, observer and REA children.
|
||||
|
||||
| Task | Cache | Inputs | Wall seconds | Family CPU seconds | Family peak MiB |
|
||||
| --------- | ----- | ----------------- | ------------: | -----------------: | --------------: |
|
||||
| Directory | cold | compact (default) | 5.661 → 2.236 | 5.403 → 2.050 | 559.3 → 248.6 |
|
||||
| Directory | cold | full | 5.256 → 2.337 | 5.040 → 2.175 | 650.9 → 349.1 |
|
||||
| Directory | warm | compact (default) | 5.056 → 2.236 | 4.800 → 2.015 | 572.8 → 251.4 |
|
||||
| Directory | warm | full | 4.957 → 2.338 | 4.754 → 2.139 | 672.7 → 349.6 |
|
||||
| ripgrep | cold | compact (default) | 8.177 → 8.078 | 7.945 → 7.899 | 849.7 → 792.5 |
|
||||
| zellij | cold | compact (default) | 8.276 → 7.276 | 8.041 → 7.105 | 846.2 → 819.0 |
|
||||
| ripgrep | cold | full | 8.180 → 7.483 | 8.000 → 7.312 | 846.5 → 794.4 |
|
||||
| zellij | cold | full | 8.484 → 7.776 | 8.284 → 7.514 | 850.4 → 813.1 |
|
||||
| ripgrep | warm | compact (default) | 7.976 → 6.879 | 7.739 → 6.678 | 826.8 → 802.6 |
|
||||
| zellij | warm | compact (default) | 8.285 → 7.472 | 8.019 → 7.254 | 882.1 → 811.4 |
|
||||
| ripgrep | warm | full | 8.074 → 7.476 | 7.890 → 7.265 | 852.1 → 803.6 |
|
||||
| zellij | warm | full | 8.382 → 7.479 | 8.151 → 7.293 | 854.3 → 830.0 |
|
||||
|
||||
Discovery improves clearly in this cohort. Completed-task changes depend on the workload: default cold ripgrep is approximately unchanged. Initialization moves into the first actual analysis request; its latency must be assessed with the full workflow.
|
||||
|
||||
| Default ripgrep first RPC | Baseline ms | Candidate ms |
|
||||
| ------------------------- | ----------: | -----------: |
|
||||
| cold | 231.5 | 2532.1 |
|
||||
| warm | 227.1 | 2118.2 |
|
||||
|
||||
Within each profile, advertised context remains identical. Input-directory estimates are 49,670 tokens for default compact and 205,744 for full. Whole-task estimates include the supplied question/recipe, server instructions, directory once, every tool argument and text reply, and the prompt directory in discovery tasks. This projection assumes the client retains one unchanged directory; raw catalog result costs count every observed list result separately, including output schemas. These use tiktoken 0.14.0/cl100k_base; actual model usage, reasoning and SDK initialization/framing are unmeasured.
|
||||
|
||||
Linux x64 / AMD EPYC 7B12 / kernel 6.1.0-54-cloud-amd64 / Node 24.18.0, npm 11.16.0. Timed SDK tasks negotiated the legacy protocol era; auto/modern correctness is covered separately by compiled boundary tests. Trials run serially under a 1-CPU quota pinned to CPU 15, 4 GiB hard memory / 3 GiB early stop, no swap and 192 tasks. No resource stop, OOM or remaining payload process was observed. Cold disables server Node compile cache; warm uses a role/profile-specific cache populated by one complete task. Fresh processes and HOME are used each trial. Cache hit rate and OS page cache are unmeasured. Runtime audits warm compiled-file pages and are included in whole-task costs. Builds, downloads, oracle generation and cache priming are preparation outside those costs. Retained V8 heap and forced-GC measurements were not separately collected.
|
||||
|
||||
## Ownership and validation
|
||||
|
||||
`OwnedMcpServers` seals publication before taking its close snapshot, keeps failed exact close capabilities for retry and serves both eager and deferred paths. Deferred startup still joins servers published before an asynchronous serving handle settles. Post-snapshot and post-close factory calls are refused before allocation. Both production-boundary regressions fail with only the shared guard removed and pass restored.
|
||||
|
||||
The moved transport/delivery implementation preserves current complete-error retention, escaped-preview fitting, response/string-limit recovery, target-content provenance and native Evidence continuations. Canonical parsers remain authoritative after lazy binding. Both protocol eras, input/output profile choices, invalid input/output, completion cancellation, partial startup and cleanup retry are exercised.
|
||||
|
||||
Aggregation keeps catalog, producer-script and guard-script digests in distinct variables. Before publishing a trial it verifies that the catalog digest matches both its catalog-file receipt and the shared profile identity. Deliberately selecting the guard digest for that field is rejected. Regeneration preserves all measured timing, CPU, memory, context and parent-Evidence values.
|
||||
|
||||
Executed local gates: 125 focused tests across 14 files; a repeated 22-case schema/profile subset after the pinned main merge; all six static-check tasks plus architecture guards; builds for both pinned runtimes; six documentation checks and five README consistency tests. HTTP coverage includes the real compiled CLI session/replacement/Host tests. Runtime-binding admission rejects a mismatched compiled graph before MCP launch; aggregation rejects a tampered trial binding. The raw receipts remain in `/tmp/rea-perf-mcp-startup-20261022`, identified by hashes in the data. Real initial-target providers, foreign OS runtime behavior and other providers remain outside this measurement.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Use the pinned Node/npm toolchain and separate clean baseline/candidate checkouts. Build each with `npm ci` and `npm run build:cached` (the measured workspace shared one locked dependency installation). Acquire and verify the official packages listed in the data, extract the named executables and generate independent oracles with `mcp-input-catalog-oracle.py`.
|
||||
|
||||
Capture each checkout with `node experiments/analysis-efficiency/managed-cli-runtime.mjs CHECKOUT mcp`. Construct `measurement-inputs.json` using the recorded schema, updating absolute checkout/artifact/oracle paths and their independently verified bindings. Keep it immutable through a cohort; producer source digests cover `mcp-input-catalog.mjs`, `managed-cli-runtime.mjs`, `resource-run.py` and `resource_payload.py`.
|
||||
|
||||
Run `mcp-input-catalog.mjs` through `resource-run.py --observe-payload-exit` with `--runtime`, `--output`, `--bindings`, `--role baseline|candidate`, `--profile default|full`, `--phase discovery|analysis` and `--cache cold|warm`. Analysis also takes `--artifact` and `--oracle`; warm requires a role/profile-specific `--cache-root`. Disable the driver compile cache for both cohorts. Prime each warm role/profile once with the complete ripgrep task. Follow the 72-case order in the data, reversing role order in every second pair. Aggregate with `python mcp-startup-costs.py LAB --output mcp-startup.json` using tiktoken 0.14.0. The aggregator verifies every input/runtime/guard binding and completed receipt before producing summaries.
|
||||
+2
-2
@@ -305,8 +305,8 @@
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
"verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs",
|
||||
"mcp-catalog:check:built": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile": "node scripts/clean-build-output.mjs && node scripts/run-typescript.mjs -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
|
||||
"compile:termux": "node scripts/clean-build-output.mjs && node --max-old-space-size=3072 node_modules/typescript-termux/lib/tsc.js -p tsconfig.build.json && node scripts/record-catalog-digests.mjs && node scripts/record-mcp-catalog.mjs && node scripts/bundle-runtime.mjs",
|
||||
"artifacts:mcp-catalog": "node scripts/generate-mcp-tool-catalog.mjs",
|
||||
"artifacts:skills": "node scripts/generate-skill-metadata.mjs",
|
||||
"artifacts:product-catalog": "node scripts/generate-product-catalog.mjs",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const toolFamilyCatalog = (sources) => {
|
||||
...sources.il2cppContracts.IL2CPP_TOOL_CONTRACTS,
|
||||
...sources.modelContracts.MODEL_TOOL_CONTRACTS,
|
||||
...sources.rustContracts.RUST_TOOL_CONTRACTS,
|
||||
...sources.erlangContracts.ERLANG_BEAM_TOOL_CONTRACTS,
|
||||
...sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
|
||||
...sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
|
||||
@@ -286,6 +287,10 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY,
|
||||
contracts: sources.rustContracts.RUST_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.erlangProvider.ERLANG_BEAM_PROVIDER_IDENTITY,
|
||||
contracts: sources.erlangContracts.ERLANG_BEAM_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.electronFuseProvider.ELECTRON_FUSE_PROVIDER_IDENTITY,
|
||||
contracts: fuseContracts,
|
||||
|
||||
@@ -77,6 +77,8 @@ const SOURCE_PATHS = {
|
||||
il2cppProvider: "dist/il2cpp/Il2cppMetadataProvider.js",
|
||||
rustContracts: "dist/contracts/rust/rustToolContracts.js",
|
||||
rustProvider: "dist/rust/RustBinaryProvider.js",
|
||||
erlangContracts: "dist/contracts/erlang/beamToolContracts.js",
|
||||
erlangProvider: "dist/erlang/BeamProvider.js",
|
||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||
desktopContracts: "dist/contracts/desktopApplicationToolContracts.js",
|
||||
managedContracts: "dist/contracts/managed/managedToolContracts.js",
|
||||
|
||||
@@ -39,6 +39,7 @@ export const parseDevelopmentTestRequest = (mode, arguments_) => {
|
||||
// runtime or typecheck prerequisites. Add a consumer here when it reads one.
|
||||
const artifactConsumers = {
|
||||
"artifacts:mcp-catalog": [
|
||||
"tests/boundary/mcp/deferredRuntime.test.ts",
|
||||
"tests/boundary/mcp/contractPresentation.test.ts",
|
||||
"tests/boundary/mcp/toolSchemaValidity.test.ts",
|
||||
],
|
||||
|
||||
@@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [
|
||||
group: "source",
|
||||
include: [
|
||||
"src/*.test.ts",
|
||||
"src/{android,apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts",
|
||||
"src/{android,apktool,jeb,adb,artifacts,browser,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
|
||||
// Runtime discovery is generated from the same canonical registrations, after
|
||||
// default identity has been recorded and before bundling. Test catalogs stay separate.
|
||||
const compiled = resolve(dirname(fileURLToPath(import.meta.url)), "../dist");
|
||||
const read = (name) => import(pathToFileURL(join(compiled, name)).href);
|
||||
const [
|
||||
{ createServer },
|
||||
{ createBinarySession },
|
||||
{ parseConfig },
|
||||
{ silentLogger },
|
||||
{ PROMPT_CONTRACTS },
|
||||
] = await Promise.all([
|
||||
read("server/createServer.js"),
|
||||
read("composition/binary.js"),
|
||||
read("config/parseConfig.js"),
|
||||
read("logger.js"),
|
||||
read("contracts/promptContracts.js"),
|
||||
]);
|
||||
const recordCatalog = async (profile) => {
|
||||
// Catalog generation is independent of the host account and user configuration.
|
||||
const home = await mkdtemp(join(tmpdir(), "rea-mcp-catalog-"));
|
||||
const environment = {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, "config"),
|
||||
XDG_CACHE_HOME: join(home, "cache"),
|
||||
XDG_STATE_HOME: join(home, "state"),
|
||||
};
|
||||
const cleanup = [];
|
||||
let producerFailure;
|
||||
try {
|
||||
const config = parseConfig(environment);
|
||||
if (!config.ok) throw config.error;
|
||||
const session = createBinarySession(
|
||||
config.value,
|
||||
silentLogger,
|
||||
environment,
|
||||
);
|
||||
cleanup.push(async () => {
|
||||
const closed = await session.close({ retainProviderDocuments: true });
|
||||
if (!closed.ok) throw closed.error;
|
||||
});
|
||||
const server = createServer({ kind: "session", session }, { environment });
|
||||
cleanup.push(() => server.close());
|
||||
const client = new Client({ name: "rea-catalog-recorder", version: "1" });
|
||||
cleanup.push(() => client.close());
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const tools = (await client.listTools()).tools;
|
||||
const prompts = (await client.listPrompts()).prompts.map((prompt) => {
|
||||
const contract = PROMPT_CONTRACTS.find(
|
||||
(candidate) => candidate.name === prompt.name,
|
||||
);
|
||||
if (contract === undefined)
|
||||
throw new Error(`Missing prompt ${prompt.name}`);
|
||||
return { ...prompt, parameters: contract.arguments };
|
||||
});
|
||||
const catalog = {
|
||||
version: 1,
|
||||
info: client.getServerVersion(),
|
||||
capabilities: client.getServerCapabilities(),
|
||||
instructions: client.getInstructions(),
|
||||
identity: server.catalogIdentity,
|
||||
tools,
|
||||
prompts,
|
||||
};
|
||||
const encoded = JSON.stringify(catalog);
|
||||
const digest = createHash("sha256").update(encoded).digest("hex");
|
||||
await writeFile(
|
||||
join(
|
||||
compiled,
|
||||
profile === "full" ? "mcp-catalog-full.json" : "mcp-catalog.json",
|
||||
),
|
||||
encoded,
|
||||
);
|
||||
return digest;
|
||||
} catch (cause) {
|
||||
producerFailure = { cause };
|
||||
throw cause;
|
||||
} finally {
|
||||
const results = await Promise.allSettled(
|
||||
cleanup.reverse().map((close) => Promise.resolve().then(close)),
|
||||
);
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
);
|
||||
if (failures.length === 0) {
|
||||
try {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
producerFailure === undefined
|
||||
? failures
|
||||
: [producerFailure.cause, ...failures],
|
||||
`Catalog cleanup failed; temporary home retained at ${home}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const digests = {};
|
||||
for (const profile of ["compact", "full"])
|
||||
digests[profile] = await recordCatalog(profile);
|
||||
await writeFile(
|
||||
join(compiled, "recordedMcpCatalog.js"),
|
||||
`/** Recorded from the compiled runtime; do not edit. */\nexport const RECORDED_MCP_CATALOG_SHA256 = ${JSON.stringify(digests)};\n`,
|
||||
);
|
||||
@@ -3,6 +3,26 @@ import { dirname, join, relative } from "node:path";
|
||||
|
||||
import { exec, execNpm, pathExists } from "./lib/verify-package-core.mjs";
|
||||
|
||||
/** Install the separately locked Skills CLI so verification resolves no registry metadata. */
|
||||
const lockedSkillsCli = async (root, workspace) => {
|
||||
const tool = join(workspace, "skills-cli");
|
||||
await mkdir(tool, { recursive: true });
|
||||
for (const file of ["package.json", "package-lock.json"])
|
||||
await cp(join(root, "scripts/verify-tools/skills", file), join(tool, file));
|
||||
await execNpm(
|
||||
["ci", "--prefer-offline", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: tool, timeout: 120_000 },
|
||||
);
|
||||
const installed = join(tool, "node_modules/skills");
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(installed, "package.json"), "utf8"),
|
||||
);
|
||||
return {
|
||||
version: manifest.version,
|
||||
entry: join(installed, manifest.bin.skills),
|
||||
};
|
||||
};
|
||||
|
||||
/** Exercise Skills' default root discovery without ignored build outputs. */
|
||||
export async function verifyRepositorySkill({ root, workspace }) {
|
||||
const snapshot = join(workspace, "repository");
|
||||
@@ -33,15 +53,14 @@ export async function verifyRepositorySkill({ root, workspace }) {
|
||||
throw new Error("repository skill check must run without generated skills");
|
||||
await mkdir(consumer, { recursive: true });
|
||||
await mkdir(home, { recursive: true });
|
||||
const skillsVersion = "1.7.2";
|
||||
await execNpm(
|
||||
const { version: skillsVersion, entry } = await lockedSkillsCli(
|
||||
root,
|
||||
workspace,
|
||||
);
|
||||
await exec(
|
||||
process.execPath,
|
||||
[
|
||||
"exec",
|
||||
"--yes",
|
||||
"--package",
|
||||
`skills@${skillsVersion}`,
|
||||
"--",
|
||||
"skills",
|
||||
entry,
|
||||
"add",
|
||||
snapshot,
|
||||
"--skill",
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "rea-verify-skills-cli",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-verify-skills-cli",
|
||||
"dependencies": {
|
||||
"skills": "1.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/skills": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/skills/-/skills-1.7.2.tgz",
|
||||
"integrity": "sha512-j+GzyF/oNahsjjjup6y3lJpFkC1lQjIkf4kAMXICA1hboBqi0fAGXAGoE4x9FRZiP1br/YE9+L3/Tqq6PIAe0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tar": "^7.5.20",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"bin": {
|
||||
"add-skill": "bin/cli.mjs",
|
||||
"skills": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "rea-verify-skills-cli",
|
||||
"private": true,
|
||||
"description": "Lockfile-pinned Skills CLI for the repository skill check in verify:package.",
|
||||
"dependencies": {
|
||||
"skills": "1.7.2"
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import { dexDescriptorSchema } from "../domain/android/androidAnalysis.js";
|
||||
|
||||
const count = z.number().int().nonnegative();
|
||||
const count = z.int().nonnegative();
|
||||
/** Typed allocation failures authored before upstream erases the JVM exception. */
|
||||
const jadxMemoryFailureSchema = z.strictObject({
|
||||
kind: z.literal("jvm-memory-failure"),
|
||||
memory_area: z.enum(["heap", "unknown"]),
|
||||
exception_type: z.literal("java.lang.OutOfMemoryError"),
|
||||
message: z.string().optional(),
|
||||
max_heap_bytes: z.number().int().positive().safe(),
|
||||
max_heap_bytes: z.int().positive().safe(),
|
||||
});
|
||||
/** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */
|
||||
const jadxInputFailureSchema = z.strictObject({
|
||||
@@ -40,8 +40,8 @@ export const parseJadxFailure = (
|
||||
/** Values observed inside the exact JVM producing the analysis. */
|
||||
export const jadxRuntimeSchema = z.object({
|
||||
engine_reported_version: z.string().min(1),
|
||||
max_heap_bytes: z.number().int().positive().safe(),
|
||||
available_processors: z.number().int().positive(),
|
||||
max_heap_bytes: z.int().positive().safe(),
|
||||
available_processors: z.int().positive(),
|
||||
java_version: z.string().min(1),
|
||||
metadata_scope: z.literal("parsed_members"),
|
||||
});
|
||||
@@ -81,11 +81,11 @@ export const jadxClassSchema = z.object({
|
||||
signature: z.string(),
|
||||
dex_descriptor: dexDescriptorSchema,
|
||||
is_constructor: z.boolean(),
|
||||
def_pos: z.number().int(),
|
||||
def_pos: z.int(),
|
||||
}),
|
||||
),
|
||||
fields: z.array(
|
||||
z.object({ name: z.string(), type: z.string(), def_pos: z.number().int() }),
|
||||
z.object({ name: z.string(), type: z.string(), def_pos: z.int() }),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export const jadxXrefsSchema = z.object({
|
||||
full_name: z.string(),
|
||||
containing_class: z.string(),
|
||||
top_class: z.string(),
|
||||
def_pos: z.number().int(),
|
||||
def_pos: z.int(),
|
||||
direct_invokes_exhaustive: z.boolean().optional(),
|
||||
direct_invokes: z
|
||||
.array(
|
||||
|
||||
@@ -417,6 +417,7 @@ const workflowAvailabilityFor = ({
|
||||
name === "inspect_model_artifact" ||
|
||||
name === "inspect_gpu_code_objects" ||
|
||||
name === "inspect_rust_binary" ||
|
||||
name === "inspect_erlang_beam" ||
|
||||
name === "inspect_electron_fuses"
|
||||
)
|
||||
return { reason: "available", remediation: null };
|
||||
|
||||
@@ -198,12 +198,13 @@ export class EnhancedTools {
|
||||
): EnhancedResult {
|
||||
switch (call.name) {
|
||||
case "inspect_native_dispatch_metadata":
|
||||
return this.#inspectNativeDispatchMetadata(
|
||||
call.input.max_records,
|
||||
return inspectNativeDispatch(this.analysis, call.input.max_records, {
|
||||
signal,
|
||||
call.input.objc_class_import_addresses,
|
||||
call.input.objc_class_import_addresses_complete,
|
||||
);
|
||||
objcClassImportAddresses: call.input.objc_class_import_addresses,
|
||||
objcClassImportAddressesComplete:
|
||||
call.input.objc_class_import_addresses_complete,
|
||||
maxInputBytes: call.input.max_input_bytes,
|
||||
});
|
||||
case "get_objc_classes":
|
||||
return this.#objcClasses(call.input.pattern, signal);
|
||||
case "get_objc_protocols":
|
||||
@@ -420,21 +421,6 @@ export class EnhancedTools {
|
||||
);
|
||||
}
|
||||
|
||||
#inspectNativeDispatchMetadata(
|
||||
maxRecords: number,
|
||||
signal?: AbortSignal,
|
||||
objcClassImportAddresses?: readonly string[],
|
||||
objcClassImportAddressesComplete?: boolean,
|
||||
): EnhancedResult {
|
||||
return inspectNativeDispatch(
|
||||
this.analysis,
|
||||
maxRecords,
|
||||
signal,
|
||||
objcClassImportAddresses,
|
||||
objcClassImportAddressesComplete,
|
||||
);
|
||||
}
|
||||
|
||||
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
|
||||
const names = await this.#allAddressed("list_names", signal);
|
||||
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
|
||||
|
||||
@@ -98,26 +98,60 @@ export const deliverAnalysisEvidence = async (
|
||||
)
|
||||
: ok(evidence);
|
||||
if (!delivered.ok) return delivered;
|
||||
const written = await persistEvidenceBundleCommand(
|
||||
const published = await publishEvidenceDelivery({
|
||||
bundle,
|
||||
complete: evidence,
|
||||
delivered: delivered.value,
|
||||
outputPath,
|
||||
operation: evidence.operation,
|
||||
signal,
|
||||
});
|
||||
return published.ok ? ok(published.value.delivered) : published;
|
||||
};
|
||||
|
||||
/** Selected Evidence delivered after its complete bundle was committed. */
|
||||
export interface PublishedEvidenceDelivery {
|
||||
readonly delivered: Evidence;
|
||||
readonly evidenceBundle: PublishedEvidenceFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a complete bundle, then deliver Evidence already projected from it.
|
||||
* Cancellation observed before commit leaves no output and retains the
|
||||
* complete Evidence. A committed publication wins a later cancellation race;
|
||||
* its receipt stays in the typed cancellation as diagnostics.evidence_bundle,
|
||||
* and a cleanup failure after commit keeps its own published receipt.
|
||||
*/
|
||||
export const publishEvidenceDelivery = async (input: {
|
||||
readonly bundle: EvidenceBundle;
|
||||
readonly complete: Evidence;
|
||||
readonly delivered: Evidence;
|
||||
readonly outputPath: string;
|
||||
readonly operation: string;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
|
||||
const { signal, operation } = input;
|
||||
const written = await persistEvidenceBundleCommand(
|
||||
input.bundle,
|
||||
input.outputPath,
|
||||
false,
|
||||
signal,
|
||||
evidence.operation,
|
||||
operation,
|
||||
);
|
||||
// Deliver interrupts queued during final publication cleanup before the
|
||||
// command's signal owner accepts completion and removes its listeners.
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!written.ok) return err(written.error.retainPartialObservation(evidence));
|
||||
if (!written.ok)
|
||||
return err(written.error.retainPartialObservation(input.complete));
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(evidence.operation, {
|
||||
new AnalysisCancelledError(operation, {
|
||||
cause: signal.reason,
|
||||
partialObservation: delivered.value,
|
||||
partialObservation: input.delivered,
|
||||
diagnostics: { evidence_bundle: { ...written.value } },
|
||||
}),
|
||||
);
|
||||
return delivered;
|
||||
return ok({ delivered: input.delivered, evidenceBundle: written.value });
|
||||
};
|
||||
|
||||
/** Resolve the caller's exact retained parent from an authenticated portable bundle. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
retryRegularFileCleanup,
|
||||
} from "./RegularFileRead.js";
|
||||
import { referenceSourceResourceFailure } from "../reference/ReferenceSourceReaderErrors.js";
|
||||
import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js";
|
||||
|
||||
export type ReferenceSourceVcsInfo =
|
||||
@@ -59,13 +60,7 @@ export const readReferenceSourceVcs = async (
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
||||
return err({
|
||||
tag: "reference-source-reader",
|
||||
code: cause.reason === "cancelled" ? "cancelled" : "io",
|
||||
message: cause.message,
|
||||
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
||||
cause,
|
||||
});
|
||||
return err(referenceSourceResourceFailure(cause));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,20 +5,51 @@ import {
|
||||
analysisViewCaptureFixture,
|
||||
environmentManifestCaptureFixture,
|
||||
} from "../../../tests/fixtures/analysisView.js";
|
||||
import { scenarioCaptureEvidence } from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import {
|
||||
environmentServeFixture,
|
||||
environmentServeSource,
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||
import { loadEnvironmentSource } from "./environmentSource.js";
|
||||
|
||||
const refusal = (manifest: unknown, evidence: unknown) => {
|
||||
const loaded = loadEnvironmentSource(manifest, evidence);
|
||||
const refusal = (manifest: unknown, evidence: unknown, operation?: string) => {
|
||||
const loaded = loadEnvironmentSource(manifest, evidence, operation);
|
||||
if (loaded.ok) throw new Error("Expected the pair to be refused");
|
||||
return projectAnalysisError(loaded.error);
|
||||
};
|
||||
|
||||
/** Authenticated Evidence whose payload is rewritten; createEvidence recomputes the digest. */
|
||||
const resealed = (
|
||||
evidence: Evidence,
|
||||
edit: (result: Record<string, unknown>) => void,
|
||||
operation: string = evidence.operation,
|
||||
): Evidence => {
|
||||
const result = structuredClone(evidence.normalized_result) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
edit(result);
|
||||
return createEvidence(undefined, evidence.provider, {
|
||||
operation,
|
||||
parameters: structuredClone(evidence.parameters),
|
||||
result: result as never,
|
||||
confidence: evidence.confidence,
|
||||
});
|
||||
};
|
||||
|
||||
/** Re-cite a manifest's parent so pairing passes and the capture parse alone decides. */
|
||||
const citing = (manifest: Evidence, capture: Evidence): Evidence =>
|
||||
resealed(manifest, (result) => {
|
||||
result.parent_evidence_id = capture.evidence_id;
|
||||
});
|
||||
|
||||
const record = (value: unknown, key: string): Record<string, unknown> =>
|
||||
(value as Record<string, Record<string, unknown>>)[key] ?? {};
|
||||
|
||||
describe("loadEnvironmentSource", () => {
|
||||
it("authenticates the pair and indexes every cited record as a replayable exchange", () => {
|
||||
const fixture = environmentServeFixture();
|
||||
@@ -165,6 +196,111 @@ describe("loadEnvironmentSource", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadEnvironmentSource payload attribution", () => {
|
||||
it("reports capture payload failures beneath normalized_result instead of the supported operation", () => {
|
||||
const fixture = environmentServeFixture();
|
||||
for (const operation of [
|
||||
"inspect_web_network_capture",
|
||||
"capture_browser_scenario",
|
||||
]) {
|
||||
const capture = createEvidence(undefined, fixture.capture.provider, {
|
||||
operation,
|
||||
parameters: {},
|
||||
result: {},
|
||||
confidence: "observed",
|
||||
});
|
||||
for (const command of ["environment-serve", "environment-export"]) {
|
||||
const loaded = loadEnvironmentSource(
|
||||
citing(fixture.manifest, capture),
|
||||
capture,
|
||||
command,
|
||||
);
|
||||
if (loaded.ok || !(loaded.error instanceof AnalysisInputError))
|
||||
throw new Error("Expected an input refusal");
|
||||
expect(loaded.error.issues.length).toBeGreaterThan(0);
|
||||
for (const issue of loaded.error.issues)
|
||||
expect(issue.path.slice(0, 2)).toEqual([
|
||||
"evidence",
|
||||
"normalized_result",
|
||||
]);
|
||||
expect(loaded.error.cause).toBeInstanceOf(Error);
|
||||
expect(projectAnalysisError(loaded.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
details: { operation: command },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the full path of a nested capture payload field", () => {
|
||||
const fixture = environmentServeFixture();
|
||||
const har = resealed(fixture.capture, (result) => {
|
||||
record(result, "artifact").sha256 = 42;
|
||||
});
|
||||
expect(refusal(citing(fixture.manifest, har), har).details?.issues).toEqual(
|
||||
[
|
||||
expect.objectContaining({
|
||||
path: ["evidence", "normalized_result", "artifact", "sha256"],
|
||||
reason: "invalid_type",
|
||||
}),
|
||||
],
|
||||
);
|
||||
const scenario = resealed(scenarioCaptureEvidence(), (result) => {
|
||||
record(result, "browser").product = 7;
|
||||
});
|
||||
expect(
|
||||
refusal(citing(fixture.manifest, scenario), scenario).details?.issues,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["evidence", "normalized_result", "browser", "product"],
|
||||
reason: "invalid_type",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("still blames the operation of Evidence no capture schema owns", () => {
|
||||
const fixture = environmentServeFixture();
|
||||
const other = resealed(fixture.capture, () => {}, "inspect_other_capture");
|
||||
expect(
|
||||
refusal(citing(fixture.manifest, other), other).details?.issues,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["evidence", "operation"],
|
||||
expected: "inspect_web_network_capture or capture_browser_scenario",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("separates the manifest operation, view kind and view payload failures", () => {
|
||||
const fixture = environmentServeFixture();
|
||||
expect(refusal(fixture.capture, fixture.capture).details?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["manifest", "operation"],
|
||||
expected: "inspect_analysis_view",
|
||||
}),
|
||||
]);
|
||||
const malformed = resealed(fixture.manifest, (result) => {
|
||||
result.name = 7;
|
||||
});
|
||||
expect(refusal(malformed, fixture.capture).details?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["manifest", "normalized_result", "name"],
|
||||
reason: "invalid_type",
|
||||
}),
|
||||
]);
|
||||
const unknownKind = resealed(fixture.manifest, (result) => {
|
||||
result.kind = "not-a-view";
|
||||
});
|
||||
expect(refusal(unknownKind, fixture.capture).details?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["manifest", "normalized_result", "kind"],
|
||||
expected: "environment-manifest",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadEnvironmentSource recorded request lines", () => {
|
||||
it("keeps the delimiter of an explicit empty query in the recorded exchange", () => {
|
||||
const capture = environmentManifestCaptureFixture();
|
||||
|
||||
@@ -143,18 +143,34 @@ export const parseEvidenceFile = (
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof z.ZodError)) throw cause;
|
||||
return err(
|
||||
inputError(
|
||||
schemaInputError(
|
||||
operation,
|
||||
projectInputIssues(cause.issues, value).map((issue) => ({
|
||||
...issue,
|
||||
path: [role, ...issue.path],
|
||||
})),
|
||||
{ prefix: [role], cause, value },
|
||||
`The ${role} file is not authenticated REA Evidence JSON.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** A schema refusal of `value`, found at `prefix`; issue paths extend the prefix and the schema failure is retained. */
|
||||
const schemaInputError = (
|
||||
operation: string,
|
||||
failure: {
|
||||
readonly prefix: readonly (string | number)[];
|
||||
readonly cause: z.ZodError;
|
||||
readonly value: unknown;
|
||||
},
|
||||
userMessage: string,
|
||||
): AnalysisInputError =>
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ userMessage, cause: failure.cause },
|
||||
projectInputIssues(failure.cause.issues, failure.value).map((issue) => ({
|
||||
...issue,
|
||||
path: [...failure.prefix, ...issue.path],
|
||||
})),
|
||||
);
|
||||
|
||||
const recordedExchange = (
|
||||
record: WebNetworkCaptureRecord,
|
||||
): RecordedExchange | undefined => {
|
||||
@@ -185,14 +201,27 @@ export const parseViewEvidence = <Kind extends AnalysisViewResult["kind"]>(
|
||||
> => {
|
||||
const evidence = parseEvidenceFile(operation, role, value);
|
||||
if (!evidence.ok) return evidence;
|
||||
const view = analysisViewResultSchema.safeParse(
|
||||
evidence.value.normalized_result,
|
||||
const message = `The ${role} file is not ${kind} view Evidence.`;
|
||||
if (evidence.value.operation !== "inspect_analysis_view")
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: [role, "operation"],
|
||||
reason: "invalid_value",
|
||||
expected: "inspect_analysis_view",
|
||||
message: `The ${role} file must be inspect_analysis_view Evidence of kind ${kind}.`,
|
||||
},
|
||||
],
|
||||
message,
|
||||
),
|
||||
);
|
||||
const normalized = immutableJsonSnapshotMember(
|
||||
evidence.value,
|
||||
"normalized_result",
|
||||
);
|
||||
if (
|
||||
evidence.value.operation !== "inspect_analysis_view" ||
|
||||
!view.success ||
|
||||
view.data.kind !== kind
|
||||
)
|
||||
if (immutableJsonSnapshotMember(normalized, "kind") !== kind)
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
@@ -204,7 +233,21 @@ export const parseViewEvidence = <Kind extends AnalysisViewResult["kind"]>(
|
||||
message: `The ${role} file must be inspect_analysis_view Evidence of kind ${kind}.`,
|
||||
},
|
||||
],
|
||||
`The ${role} file is not ${kind} view Evidence.`,
|
||||
message,
|
||||
),
|
||||
);
|
||||
// The discriminator selected the requested view, so any failure is in that view's payload.
|
||||
const view = analysisViewResultSchema.safeParse(normalized);
|
||||
if (!view.success)
|
||||
return err(
|
||||
schemaInputError(
|
||||
operation,
|
||||
{
|
||||
prefix: [role, "normalized_result"],
|
||||
cause: view.error,
|
||||
value: normalized,
|
||||
},
|
||||
message,
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
@@ -213,89 +256,132 @@ export const parseViewEvidence = <Kind extends AnalysisViewResult["kind"]>(
|
||||
});
|
||||
};
|
||||
|
||||
/** Authenticate the evidence file and require complete capture Evidence of either kind. */
|
||||
/** What replay needs from a parent capture once its payload has been validated. */
|
||||
interface CaptureFacts {
|
||||
readonly records: readonly WebNetworkCaptureRecord[];
|
||||
readonly captureKind: EnvironmentCaptureKind;
|
||||
readonly captureSha256: string | null;
|
||||
}
|
||||
|
||||
/** Issue paths of a capture payload refusal start here. */
|
||||
const CAPTURE_PAYLOAD_PATH = ["evidence", "normalized_result"] as const;
|
||||
|
||||
/** Validate a capture_browser_scenario payload; its failures name the payload fields. */
|
||||
const parseScenarioCapture = (
|
||||
operation: string,
|
||||
normalized: unknown,
|
||||
parameters: unknown,
|
||||
): Result<CaptureFacts, AnalysisError> => {
|
||||
const scenario = browserScenarioCaptureSchema.safeParse(normalized);
|
||||
if (!scenario.success)
|
||||
return err(
|
||||
schemaInputError(
|
||||
operation,
|
||||
{
|
||||
prefix: CAPTURE_PAYLOAD_PATH,
|
||||
cause: scenario.error,
|
||||
value: normalized,
|
||||
},
|
||||
"The evidence file is not a complete browser scenario capture.",
|
||||
),
|
||||
);
|
||||
const definition = z
|
||||
.object({ scenario_sha256: z.string() })
|
||||
.safeParse(parameters);
|
||||
return ok({
|
||||
records: scenarioCaptureRecords(scenario.data, "replay").records,
|
||||
captureKind: "browser-scenario",
|
||||
captureSha256: definition.success ? definition.data.scenario_sha256 : null,
|
||||
});
|
||||
};
|
||||
|
||||
/** Validate an inspect_web_network_capture payload as HAR; its failures name the payload fields. */
|
||||
const parseHarCapture = (
|
||||
operation: string,
|
||||
normalized: unknown,
|
||||
): Result<CaptureFacts, AnalysisError> => {
|
||||
const records = immutableJsonSnapshotMember(normalized, "records");
|
||||
if (Array.isArray(records))
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const record = immutableJsonSnapshotMember(records, String(index));
|
||||
immutableJsonSnapshotMember(record, "reported");
|
||||
}
|
||||
// An owned snapshot is validated once and borrowed, so deriving the state reuses this validation.
|
||||
const capture = safeParseOwnedWebNetworkCapture(normalized);
|
||||
if (!capture.success)
|
||||
return err(
|
||||
schemaInputError(
|
||||
operation,
|
||||
{
|
||||
prefix: CAPTURE_PAYLOAD_PATH,
|
||||
cause: capture.error,
|
||||
value: normalized,
|
||||
},
|
||||
"The evidence file is not a complete web network capture.",
|
||||
),
|
||||
);
|
||||
// The manifest view projects HAR captures only, and replay reads HAR's response fields.
|
||||
if (capture.data.format !== "har")
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: [...CAPTURE_PAYLOAD_PATH, "format"],
|
||||
reason: "invalid_value",
|
||||
expected: "har",
|
||||
message: `Agent environments replay HAR captures; this capture's format is ${capture.data.format}.`,
|
||||
},
|
||||
],
|
||||
"The evidence file is not a HAR capture.",
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
records: capture.data.records,
|
||||
captureKind: "web-network-capture",
|
||||
captureSha256: capture.data.artifact.sha256,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate the evidence file and require complete capture Evidence of
|
||||
* either kind. Only an operation no capture schema owns blames the operation;
|
||||
* a recognized operation's payload failures name the payload fields.
|
||||
*/
|
||||
const parseCaptureEvidence = (
|
||||
operation: string,
|
||||
value: unknown,
|
||||
): Result<
|
||||
{
|
||||
readonly records: readonly WebNetworkCaptureRecord[];
|
||||
readonly evidenceId: string;
|
||||
readonly captureKind: EnvironmentCaptureKind;
|
||||
readonly captureSha256: string | null;
|
||||
},
|
||||
AnalysisError
|
||||
> => {
|
||||
): Result<CaptureFacts & { readonly evidenceId: string }, AnalysisError> => {
|
||||
const evidence = parseEvidenceFile(operation, "evidence", value);
|
||||
if (!evidence.ok) return evidence;
|
||||
const { evidence_id: evidenceId } = evidence.value;
|
||||
const normalized = immutableJsonSnapshotMember(
|
||||
evidence.value,
|
||||
"normalized_result",
|
||||
);
|
||||
if (evidence.value.operation === "capture_browser_scenario") {
|
||||
const scenario = browserScenarioCaptureSchema.safeParse(normalized);
|
||||
const definition = z
|
||||
.object({ scenario_sha256: z.string() })
|
||||
.safeParse(evidence.value.parameters);
|
||||
if (scenario.success)
|
||||
return ok({
|
||||
records: scenarioCaptureRecords(scenario.data, "replay").records,
|
||||
evidenceId,
|
||||
captureKind: "browser-scenario",
|
||||
captureSha256: definition.success
|
||||
? definition.data.scenario_sha256
|
||||
: null,
|
||||
});
|
||||
} else if (evidence.value.operation === "inspect_web_network_capture") {
|
||||
const records = immutableJsonSnapshotMember(normalized, "records");
|
||||
if (Array.isArray(records))
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const record = immutableJsonSnapshotMember(records, String(index));
|
||||
immutableJsonSnapshotMember(record, "reported");
|
||||
}
|
||||
// An owned snapshot is validated once and borrowed, so deriving the state reuses this validation.
|
||||
const capture = safeParseOwnedWebNetworkCapture(normalized);
|
||||
if (capture.success) {
|
||||
// The manifest view projects HAR captures only, and replay reads HAR's response fields.
|
||||
if (capture.data.format !== "har")
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: ["evidence", "normalized_result", "format"],
|
||||
reason: "invalid_value",
|
||||
expected: "har",
|
||||
message: `Agent environments replay HAR captures; this capture's format is ${capture.data.format}.`,
|
||||
},
|
||||
],
|
||||
"The evidence file is not a HAR capture.",
|
||||
),
|
||||
);
|
||||
return ok({
|
||||
records: capture.data.records,
|
||||
evidenceId,
|
||||
captureKind: "web-network-capture",
|
||||
captureSha256: capture.data.artifact.sha256,
|
||||
});
|
||||
}
|
||||
}
|
||||
return err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: ["evidence", "operation"],
|
||||
reason: "invalid_value",
|
||||
expected: "inspect_web_network_capture or capture_browser_scenario",
|
||||
message:
|
||||
"The evidence file must be complete inspect_web_network_capture or capture_browser_scenario Evidence.",
|
||||
},
|
||||
],
|
||||
"The evidence file is not web network capture or browser scenario Evidence.",
|
||||
),
|
||||
);
|
||||
const capture =
|
||||
evidence.value.operation === "capture_browser_scenario"
|
||||
? parseScenarioCapture(operation, normalized, evidence.value.parameters)
|
||||
: evidence.value.operation === "inspect_web_network_capture"
|
||||
? parseHarCapture(operation, normalized)
|
||||
: err(
|
||||
inputError(
|
||||
operation,
|
||||
[
|
||||
{
|
||||
path: ["evidence", "operation"],
|
||||
reason: "invalid_value",
|
||||
expected:
|
||||
"inspect_web_network_capture or capture_browser_scenario",
|
||||
message:
|
||||
"The evidence file must be inspect_web_network_capture or capture_browser_scenario Evidence.",
|
||||
},
|
||||
],
|
||||
"The evidence file is not web network capture or browser scenario Evidence.",
|
||||
),
|
||||
);
|
||||
return capture.ok
|
||||
? ok({ ...capture.value, evidenceId: evidence.value.evidence_id })
|
||||
: capture;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
InspectErlangBeamInput,
|
||||
ErlangBeam,
|
||||
} from "../../domain/erlang/beam.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Stable read and BEAM IFF decoding, independent of a disassembler session. */
|
||||
export interface BeamPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
close?(): Promise<void>;
|
||||
inspect(
|
||||
input: InspectErlangBeamInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<ErlangBeam, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { BeamService } from "./BeamService.js";
|
||||
import { ERLANG_BEAM_PROVIDER_IDENTITY } from "../../erlang/BeamProvider.js";
|
||||
import type { ErlangBeam } from "../../domain/erlang/beam.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const identity = ERLANG_BEAM_PROVIDER_IDENTITY;
|
||||
const report: ErlangBeam = {
|
||||
artifact: {
|
||||
path: "/artifacts/app.beam",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 64,
|
||||
},
|
||||
form: { location: { offset: 0, bytes: 12 }, size: 4 },
|
||||
chunks: [],
|
||||
atoms: null,
|
||||
atom_encoding: null,
|
||||
exports: null,
|
||||
imports: null,
|
||||
lambdas: null,
|
||||
code: null,
|
||||
module: null,
|
||||
coverage: { status: "complete", examined_chunks: 0, atoms: 0 },
|
||||
limitations: ["No atom table was present."],
|
||||
};
|
||||
|
||||
it("rejects a relative path and extra arguments before reading", async () => {
|
||||
let calls = 0;
|
||||
const service = new BeamService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const relative = await service.inspect({ path: "app.beam" });
|
||||
const extra = await service.inspect({
|
||||
path: report.artifact.path,
|
||||
extra: true,
|
||||
});
|
||||
const nul = await service.inspect({ path: "/artifacts/app\0.beam" });
|
||||
expect(relative.ok).toBe(false);
|
||||
expect(extra.ok).toBe(false);
|
||||
expect(nul.ok).toBe(false);
|
||||
if (!relative.ok)
|
||||
expect(relative.error).toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [{ path: ["path"] }],
|
||||
});
|
||||
if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!nul.ok)
|
||||
expect(nul.error).toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [{ path: ["path"] }],
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("records the selected BEAM form", async () => {
|
||||
const result = await new BeamService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(report)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
confidence: "observed",
|
||||
subject: { format: "file" },
|
||||
normalized_result: report,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards an observation if the caller cancels while the provider completes", async () => {
|
||||
const controller = new AbortController();
|
||||
const service = new BeamService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const result = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { BeamPort } from "./BeamPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectErlangBeamInputSchema,
|
||||
erlangBeamSchema,
|
||||
type ErlangBeam,
|
||||
} from "../../domain/erlang/beam.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "inspect_erlang_beam";
|
||||
|
||||
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
|
||||
export class BeamService {
|
||||
constructor(readonly provider: BeamPort) {}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.provider.close?.() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const input = inspectErlangBeamInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
const result = await this.provider.inspect(input.data, options);
|
||||
if (!result.ok) return result;
|
||||
const report = erlangBeamSchema.safeParse(result.value);
|
||||
if (
|
||||
!report.success ||
|
||||
report.data.artifact.path !== input.data.path ||
|
||||
!validReport(
|
||||
report.data,
|
||||
input.data.max_chunks,
|
||||
input.data.max_atoms,
|
||||
input.data.max_file_bytes,
|
||||
)
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"BEAM 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.data,
|
||||
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.form.location.offset,
|
||||
end: value.form.location.offset + value.form.location.bytes,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validReport = (
|
||||
report: ErlangBeam,
|
||||
maxChunks: number,
|
||||
maxAtoms: number,
|
||||
maxBytes: number,
|
||||
): boolean => {
|
||||
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
|
||||
offset <= report.artifact.bytes - bytes;
|
||||
if (
|
||||
report.artifact.bytes > maxBytes ||
|
||||
report.coverage.examined_chunks > maxChunks ||
|
||||
report.chunks.length > maxChunks ||
|
||||
report.coverage.atoms > maxAtoms ||
|
||||
(report.atoms !== null && report.atoms.length > maxAtoms)
|
||||
)
|
||||
return false;
|
||||
if (!inside(report.form.location)) return false;
|
||||
if (report.code !== null && !inside(report.code.instructions)) return false;
|
||||
return report.chunks.every(
|
||||
(chunk) => inside(chunk.location) && inside(chunk.payload.location),
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { metadataReportSubjectFormat } from "../../domain/apple/machoTargetSelection.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { GoBinaryPort } from "./GoBinaryPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
@@ -73,7 +74,7 @@ export class GoBinaryService {
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.format === "macho" ? "mach-o" : value.format,
|
||||
format: metadataReportSubjectFormat(value),
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { electronActiveObservationInputSchema } from "../../domain/javascript/electronActiveObservation.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
|
||||
import { captureElectronScenario } from "./ElectronActiveObservationService.js";
|
||||
|
||||
const SKIP_PERMISSIONS =
|
||||
process.platform === "win32" || process.getuid?.() === 0;
|
||||
|
||||
/** A provider that records admitted input and never launches anything. */
|
||||
const recordingProvider = () => {
|
||||
const capture = vi.fn<ElectronActiveObservationPort["capture"]>(() =>
|
||||
Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"test",
|
||||
"capture_electron_scenario",
|
||||
"recording provider",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const provider: ElectronActiveObservationPort = {
|
||||
identity: () => ({ id: "test", name: "test", version: "1" }),
|
||||
capture,
|
||||
};
|
||||
return { provider, capture };
|
||||
};
|
||||
|
||||
const fixture = async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-admission-");
|
||||
const denied = join(root, "denied");
|
||||
const readable = join(root, "readable");
|
||||
await mkdir(join(denied, "root"), { recursive: true });
|
||||
await mkdir(readable);
|
||||
const executable = join(denied, "electron");
|
||||
await writeFile(executable, "#!/bin/sh\n", { mode: 0o755 });
|
||||
await writeFile(join(denied, "main.js"), "module.exports = {};\n");
|
||||
await writeFile(join(readable, "main.js"), "module.exports = {};\n");
|
||||
return { root, denied, readable, executable };
|
||||
};
|
||||
|
||||
/** Remove search permission from `directory` for the duration of `run`. */
|
||||
const withoutSearchPermission = async <T>(
|
||||
directory: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
await chmod(directory, 0o000);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
await chmod(directory, 0o700);
|
||||
}
|
||||
};
|
||||
|
||||
it
|
||||
.skipIf(SKIP_PERMISSIONS)
|
||||
.each(["application_path", "application_root", "executable_path"] as const)(
|
||||
"reports a search-denied %s as host access denial before any provider work",
|
||||
async (field) => {
|
||||
const { denied, readable, executable } = await fixture();
|
||||
const selected = {
|
||||
application_path: join(denied, "main.js"),
|
||||
application_root: join(denied, "root"),
|
||||
executable_path: executable,
|
||||
}[field];
|
||||
const { provider, capture } = recordingProvider();
|
||||
const result = await withoutSearchPermission(denied, () =>
|
||||
captureElectronScenario(
|
||||
provider,
|
||||
electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: join(readable, "main.js"),
|
||||
actions: [],
|
||||
[field]: selected,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "access_denied",
|
||||
category: "unavailable",
|
||||
details: {
|
||||
operation: "capture_electron_scenario",
|
||||
path: selected,
|
||||
system_code: "EACCES",
|
||||
boundary: "filesystem-read",
|
||||
},
|
||||
});
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(SKIP_PERMISSIONS)(
|
||||
"reports invalid selections before a host denial on another path",
|
||||
async () => {
|
||||
const { root, denied } = await fixture();
|
||||
const missing = join(root, "missing.js");
|
||||
const { provider, capture } = recordingProvider();
|
||||
const result = await withoutSearchPermission(denied, () =>
|
||||
captureElectronScenario(
|
||||
provider,
|
||||
electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: missing,
|
||||
application_root: join(denied, "root"),
|
||||
actions: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["application_path"],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining("ENOENT"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(SKIP_PERMISSIONS).each([
|
||||
{ label: "0644", mode: 0o644 },
|
||||
{ label: "0000", mode: 0o000 },
|
||||
])(
|
||||
"keeps a reachable executable without execute permission (mode $label) as invalid input",
|
||||
async ({ mode }) => {
|
||||
const { root, readable } = await fixture();
|
||||
const executable = join(root, "not-executable");
|
||||
await writeFile(executable, "not executable");
|
||||
await chmod(executable, mode);
|
||||
const { provider, capture } = recordingProvider();
|
||||
const result = await captureElectronScenario(
|
||||
provider,
|
||||
electronActiveObservationInputSchema.parse({
|
||||
executable_path: executable,
|
||||
application_path: join(readable, "main.js"),
|
||||
actions: [],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining("is not an executable file"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("admits readable selections and hands canonical paths to the provider", async () => {
|
||||
const { readable } = await fixture();
|
||||
const { provider, capture } = recordingProvider();
|
||||
const application = join(readable, "main.js");
|
||||
await captureElectronScenario(
|
||||
provider,
|
||||
electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: application,
|
||||
actions: [],
|
||||
}),
|
||||
);
|
||||
expect(capture).toHaveBeenCalledOnce();
|
||||
expect(capture.mock.calls[0]?.[0]).toMatchObject({
|
||||
application_path: application,
|
||||
application_root: readable,
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dirname } from "node:path";
|
||||
import { constants } from "node:fs";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ElectronActiveObservationPort,
|
||||
} from "./ElectronActiveObservationPort.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
type AnalysisInputIssue,
|
||||
@@ -21,53 +22,107 @@ import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-electron-active";
|
||||
|
||||
const resolveInputPath = async (
|
||||
field: "executable_path" | "application_path" | "application_root",
|
||||
type ElectronPathField =
|
||||
| "executable_path"
|
||||
| "application_path"
|
||||
| "application_root";
|
||||
|
||||
/**
|
||||
* Why a selected path was not admitted. Host permission denial is not
|
||||
* malformed caller input: the path may be correct, but REA cannot read it.
|
||||
*/
|
||||
type PathAdmissionFailure =
|
||||
| {
|
||||
readonly kind: "invalid";
|
||||
readonly issue: AnalysisInputIssue;
|
||||
readonly cause: unknown;
|
||||
}
|
||||
| { readonly kind: "denied"; readonly error: AnalysisAccessDeniedError };
|
||||
|
||||
const hostAccessDenial = (
|
||||
selected: string,
|
||||
): Promise<
|
||||
Result<
|
||||
string,
|
||||
{ readonly issue: AnalysisInputIssue; readonly cause: unknown }
|
||||
>
|
||||
> => {
|
||||
cause: unknown,
|
||||
): PathAdmissionFailure | undefined => {
|
||||
const code: unknown =
|
||||
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
|
||||
return code === "EACCES" || code === "EPERM"
|
||||
? {
|
||||
kind: "denied",
|
||||
error: new AnalysisAccessDeniedError(OPERATION, selected, code, {
|
||||
cause,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const resolveInputPath = async (
|
||||
field: ElectronPathField,
|
||||
selected: string,
|
||||
): Promise<Result<string, PathAdmissionFailure>> => {
|
||||
try {
|
||||
return ok(await realpath(selected));
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: `Cannot resolve ${field} ${selected}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
return err(
|
||||
hostAccessDenial(selected, cause) ?? {
|
||||
kind: "invalid",
|
||||
issue: {
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: `Cannot resolve ${field} ${selected}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve the executable and require a launchable regular file before any spawn. */
|
||||
/**
|
||||
* Resolve the executable and require a launchable regular file before any
|
||||
* spawn. Denied resolution or metadata reads are host access failures; a
|
||||
* readable file without execute permission is an invalid selection.
|
||||
*/
|
||||
const resolveExecutablePath = async (
|
||||
selected: string,
|
||||
): ReturnType<typeof resolveInputPath> => {
|
||||
const resolved = await resolveInputPath("executable_path", selected);
|
||||
if (!resolved.ok) return resolved;
|
||||
let metadata: Stats;
|
||||
try {
|
||||
if (!(await stat(resolved.value)).isFile())
|
||||
throw new Error("not a regular file");
|
||||
if (process.platform !== "win32")
|
||||
await access(resolved.value, constants.X_OK);
|
||||
return resolved;
|
||||
metadata = await stat(resolved.value);
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
return err(
|
||||
hostAccessDenial(selected, cause) ?? notExecutable(selected, cause),
|
||||
);
|
||||
}
|
||||
if (!metadata.isFile())
|
||||
return err(notExecutable(selected, new Error("not a regular file")));
|
||||
if (process.platform !== "win32")
|
||||
try {
|
||||
await access(resolved.value, constants.X_OK);
|
||||
} catch (cause: unknown) {
|
||||
return err(notExecutable(selected, cause));
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const notExecutable = (
|
||||
selected: string,
|
||||
cause: unknown,
|
||||
): PathAdmissionFailure => ({
|
||||
kind: "invalid",
|
||||
issue: {
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
|
||||
/**
|
||||
* Admit every selected path before any provider work. Invalid selections are
|
||||
* reported together as input issues; a host access denial is reported only
|
||||
* when no selection is invalid, because correcting the request comes first.
|
||||
*/
|
||||
const canonicalizeInput = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
|
||||
@@ -82,16 +137,23 @@ const canonicalizeInput = async (
|
||||
const failures = [executable, application, root].flatMap((result) =>
|
||||
result.ok ? [] : [result.error],
|
||||
);
|
||||
const issues = failures.flatMap((failure) =>
|
||||
failure.kind === "invalid" ? [failure.issue] : [],
|
||||
);
|
||||
const denial = failures.find((failure) => failure.kind === "denied");
|
||||
if (issues.length === 0 && denial !== undefined) return err(denial.error);
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
{
|
||||
cause: new AggregateError(
|
||||
failures.map(({ cause }) => cause),
|
||||
failures.map((failure) =>
|
||||
failure.kind === "invalid" ? failure.cause : failure.error,
|
||||
),
|
||||
"Electron paths could not be resolved",
|
||||
),
|
||||
},
|
||||
failures.map(({ issue }) => issue),
|
||||
issues,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import {
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
import {
|
||||
artifactLocalIdentity,
|
||||
@@ -191,13 +191,15 @@ const matches = (
|
||||
path.steps.some(
|
||||
(step) =>
|
||||
step.kind === sourceStepKind &&
|
||||
electronRangeKey(step.location) === electronRangeKey(finding.location),
|
||||
javascriptSourceRangeKey(step.location) ===
|
||||
javascriptSourceRangeKey(finding.location),
|
||||
) &&
|
||||
(invocation === undefined ||
|
||||
path.steps.some(
|
||||
(step) =>
|
||||
(step.kind === "call" || step.kind === "exposure") &&
|
||||
electronRangeKey(step.location) === electronRangeKey(invocation),
|
||||
javascriptSourceRangeKey(step.location) ===
|
||||
javascriptSourceRangeKey(invocation),
|
||||
))
|
||||
);
|
||||
};
|
||||
@@ -108,23 +108,3 @@ export const addElectronInferenceEdge = (
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/** Deterministic source-range key for artifact-local graph identities. */
|
||||
export const electronRangeKey = (range: JavaScriptSourceRange): string =>
|
||||
`${String(range.start.line)}:${String(range.start.column)}-${String(range.end.line)}:${String(range.end.column)}`;
|
||||
|
||||
/** Test exact source-range containment without assuming runtime reachability. */
|
||||
export const electronRangeContains = (
|
||||
outer: JavaScriptSourceRange,
|
||||
inner: JavaScriptSourceRange,
|
||||
): boolean =>
|
||||
comparePoint(outer.start, inner.start) <= 0 &&
|
||||
comparePoint(outer.end, inner.end) >= 0;
|
||||
|
||||
const comparePoint = (
|
||||
left: JavaScriptSourceRange["start"],
|
||||
right: JavaScriptSourceRange["start"],
|
||||
): number =>
|
||||
left.line === right.line
|
||||
? left.column - right.column
|
||||
: left.line - right.line;
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
javascriptSourceRangeKey,
|
||||
javascriptSourceRangeContains,
|
||||
} from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -21,8 +25,6 @@ import {
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronObservationIdentity,
|
||||
electronRangeContains,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
|
||||
interface IpcGraphState {
|
||||
@@ -182,7 +184,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-ipc-handler",
|
||||
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${electronRangeKey(handlerRange)}`,
|
||||
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${javascriptSourceRangeKey(handlerRange)}`,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
@@ -338,7 +340,7 @@ const containingHandler = (
|
||||
(record) =>
|
||||
record.file.path === file.path &&
|
||||
record.finding.handler_location !== null &&
|
||||
electronRangeContains(
|
||||
javascriptSourceRangeContains(
|
||||
record.finding.handler_location,
|
||||
validation.location,
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronObservationIdentity,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
|
||||
interface NativeBindingInput {
|
||||
@@ -65,7 +65,7 @@ const addNativeBinding = (input: NativeBindingInput): void => {
|
||||
value.members,
|
||||
value.namespace_access ?? null,
|
||||
value.dynamic_member_access ?? null,
|
||||
electronRangeKey(value.location),
|
||||
javascriptSourceRangeKey(value.location),
|
||||
]),
|
||||
),
|
||||
observations: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||
import type {
|
||||
ElectronBrowserWindowFinding,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
addElectronAstEdge,
|
||||
addElectronInferenceEdge,
|
||||
electronFindingSourceNode,
|
||||
electronRangeKey,
|
||||
} from "./ElectronBoundaryGraphContext.js";
|
||||
import {
|
||||
artifactPathResolutionProperties,
|
||||
@@ -86,11 +86,11 @@ const addBrowserWindow = (
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-browser-window",
|
||||
electronRangeKey(value.location),
|
||||
javascriptSourceRangeKey(value.location),
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
label: `BrowserWindow@${electronRangeKey(value.location)}`,
|
||||
label: `BrowserWindow@${javascriptSourceRangeKey(value.location)}`,
|
||||
properties: {
|
||||
source_path: file.path,
|
||||
options_status: value.options_status,
|
||||
@@ -202,7 +202,7 @@ const addContextBridge = (
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-context-bridge",
|
||||
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${electronRangeKey(value.location)}`,
|
||||
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${javascriptSourceRangeKey(value.location)}`,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
@@ -271,7 +271,7 @@ const addUtilityProcess = (
|
||||
identity: artifactLocalIdentity(
|
||||
file.sha256,
|
||||
"electron-utility-process",
|
||||
`${value.module_path ?? value.module_expression}:${electronRangeKey(value.location)}`,
|
||||
`${value.module_path ?? value.module_expression}:${javascriptSourceRangeKey(value.location)}`,
|
||||
),
|
||||
observations: [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ASAR_HEADER_ADMISSION_SETTING,
|
||||
parseAsarHeaderAdmission,
|
||||
} from "../../config/asarHeaderAdmission.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
@@ -29,11 +33,16 @@ import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
const OPERATION = "analyze_javascript_application" as const;
|
||||
|
||||
/** Execution options plus the composition root's environment for admission settings. */
|
||||
export interface JavaScriptApplicationOptions extends ExecutionOptions {
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/** Statically analyze one local JavaScript/Electron application. */
|
||||
export const analyzeJavaScriptApplication = async (
|
||||
rawInput: unknown,
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
options: JavaScriptApplicationOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
@@ -47,8 +56,13 @@ export const analyzeJavaScriptApplication = async (
|
||||
export const analyzeJavaScriptApplicationValidated = async (
|
||||
input: z.output<typeof analyzeJavaScriptApplicationInputSchema>,
|
||||
scope: ArtifactResourceScope,
|
||||
options: ExecutionOptions = {},
|
||||
options: JavaScriptApplicationOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
// An invalid setting is refused before analysis, never replaced by the default.
|
||||
const asarHeaderAdmission = parseAsarHeaderAdmission(
|
||||
options.environment?.[ASAR_HEADER_ADMISSION_SETTING],
|
||||
);
|
||||
if (!asarHeaderAdmission.ok) return asarHeaderAdmission;
|
||||
let reconstructed: JavaScriptArtifactReconstructionResult | undefined;
|
||||
let result: JavaScriptApplicationAnalysisResult | undefined;
|
||||
let evidence: Evidence | undefined;
|
||||
@@ -66,6 +80,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
scope,
|
||||
options.signal,
|
||||
options.progress,
|
||||
asarHeaderAdmission.value,
|
||||
);
|
||||
const { electron_summary: summary, ...application } = reconstructed;
|
||||
await options.progress?.report({
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
type AsarHeaderAdmission,
|
||||
} from "../../config/asarHeaderAdmission.js";
|
||||
import { constants } from "node:fs";
|
||||
import { access, lstat, realpath } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
@@ -75,6 +79,7 @@ export const reconstructJavaScriptArtifact = async (
|
||||
scope: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
progress?: ProgressReporter,
|
||||
asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
): Promise<JavaScriptArtifactReconstructionResult> =>
|
||||
scope.run(async () => {
|
||||
const reportPhase = async (
|
||||
@@ -97,13 +102,14 @@ export const reconstructJavaScriptArtifact = async (
|
||||
resourceScope: scope,
|
||||
signal,
|
||||
integrity: { mode: input.integrity_policy },
|
||||
asarHeaderAdmission,
|
||||
});
|
||||
if (snapshot.manifest.root_format !== format)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`,
|
||||
);
|
||||
const reader = createReader(path, format);
|
||||
const reader = createReader(path, format, asarHeaderAdmission);
|
||||
const readerOwner = {
|
||||
kind: "reader" as const,
|
||||
reader,
|
||||
@@ -127,6 +133,7 @@ export const reconstructJavaScriptArtifact = async (
|
||||
scope,
|
||||
signal,
|
||||
input.source_paths,
|
||||
asarHeaderAdmission,
|
||||
);
|
||||
// Release acquisition before analysis consumes these owned file facts.
|
||||
const earlyCleanup = await scope.release(readerOwner);
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
projectSemanticResultBindings,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
/** Project literal EventEmitter registrations, removals, and dispatches. */
|
||||
export const projectSemanticEvents = (
|
||||
@@ -180,18 +183,11 @@ const projectTimerSchedule = (
|
||||
relation: "schedules-timer",
|
||||
resolution: "resolved",
|
||||
});
|
||||
for (const projected of operation.projectedResultBindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: timerNodes.get(operation.timerId),
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
projectSemanticResultBindings(
|
||||
context,
|
||||
timerNodes.get(operation.timerId),
|
||||
operation.projectedResultBindings,
|
||||
);
|
||||
};
|
||||
|
||||
const projectTimerCancellation = (
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
projectSemanticResultBindings,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
interface ChildUnknownInput {
|
||||
readonly context: SemanticFlowProjectionContext;
|
||||
@@ -80,18 +83,7 @@ const projectSpawn = (
|
||||
relation: "spawns",
|
||||
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
for (const projected of spawn.projectedResultBindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: child,
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
projectSemanticResultBindings(context, child, spawn.projectedResultBindings);
|
||||
if (spawn.argvCount !== null)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { z } from "zod";
|
||||
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -338,7 +339,5 @@ const applicationLocationKey = (
|
||||
[
|
||||
artifactSha256,
|
||||
path,
|
||||
range === null
|
||||
? "artifact-path"
|
||||
: `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`,
|
||||
range === null ? "artifact-path" : javascriptSourceRangeKey(range),
|
||||
].join("\u0000");
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
addSemanticGraphUnknown,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import {
|
||||
projectSemanticResultBindings,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
|
||||
|
||||
/** Project environment, argv, file, default, and precedence candidates. */
|
||||
@@ -179,18 +182,11 @@ const projectRequestConstruction = (
|
||||
relation: "constructs-request",
|
||||
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
for (const projected of operation.projectedResultBindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: request,
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
projectSemanticResultBindings(
|
||||
context,
|
||||
request,
|
||||
operation.projectedResultBindings,
|
||||
);
|
||||
for (const field of operation.fields)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import type {
|
||||
JavaScriptSemanticIr,
|
||||
JavaScriptSemanticProjectedResultBinding,
|
||||
} from "../../domain/javascript/javascriptSemanticIr.js";
|
||||
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
@@ -34,6 +38,26 @@ export interface SemanticFlowProjectionContext {
|
||||
) => JavaScriptSemanticGraphNode | undefined;
|
||||
}
|
||||
|
||||
/** Project result destructuring consistently for timers, child processes, and requests. */
|
||||
export const projectSemanticResultBindings = (
|
||||
context: Pick<SemanticFlowProjectionContext, "state" | "bindingNodes">,
|
||||
source: JavaScriptSemanticGraphNode | undefined,
|
||||
bindings: readonly JavaScriptSemanticProjectedResultBinding[],
|
||||
): void => {
|
||||
for (const projected of bindings)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source,
|
||||
target: context.bindingNodes.get(projected.bindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
projected.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: {
|
||||
projection_path: [...projected.projectionPath],
|
||||
projection_resolution: projected.resolution,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Project explicit static Promise/task ownership without runtime claims. */
|
||||
export const projectSemanticPromises = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
@@ -198,9 +222,6 @@ interface ObjectReadIndex {
|
||||
|
||||
const objectReadIndexes = new WeakMap<JavaScriptSemanticIr, ObjectReadIndex>();
|
||||
|
||||
const objectReadRangeKey = (location: JavaScriptSourceRange): string =>
|
||||
`${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
|
||||
|
||||
/**
|
||||
* The object projection owns a member read's occurrence. Index it once per
|
||||
* file so return and argument flow can connect that expression without
|
||||
@@ -227,7 +248,7 @@ const objectReadOccurrences = (
|
||||
}),
|
||||
);
|
||||
if (node !== undefined)
|
||||
nodes.set(objectReadRangeKey(operation.location), node);
|
||||
nodes.set(javascriptSourceRangeKey(operation.location), node);
|
||||
}
|
||||
objectReadIndexes.set(context.ir, { state: context.state, nodes });
|
||||
return nodes;
|
||||
@@ -240,7 +261,7 @@ export const semanticObjectReadOccurrence = (
|
||||
): JavaScriptSemanticGraphNode | undefined =>
|
||||
location === null
|
||||
? undefined
|
||||
: objectReadOccurrences(context).get(objectReadRangeKey(location));
|
||||
: objectReadOccurrences(context).get(javascriptSourceRangeKey(location));
|
||||
|
||||
/** Link direct return expressions to their retained semantic references. */
|
||||
export const projectSemanticReturnValues = (
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
compareJavaScriptSourcePoints,
|
||||
javascriptSourceRangeContains,
|
||||
javascriptSourceRangeKey,
|
||||
} from "../../domain/javascript/javascriptSourceCoordinates.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||
@@ -40,7 +45,7 @@ export const createSemanticCallableOwnerLookup = (
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
pointCompare(left.range.start, right.range.start) ||
|
||||
compareJavaScriptSourcePoints(left.range.start, right.range.start) ||
|
||||
left.order - right.order,
|
||||
);
|
||||
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
||||
@@ -54,12 +59,13 @@ export const createSemanticCallableOwnerLookup = (
|
||||
const current = pending.pop();
|
||||
if (
|
||||
current === undefined ||
|
||||
pointCompare(current.minimumStart, location.start) > 0 ||
|
||||
pointCompare(current.maximumEnd, location.end) < 0
|
||||
compareJavaScriptSourcePoints(current.minimumStart, location.start) >
|
||||
0 ||
|
||||
compareJavaScriptSourcePoints(current.maximumEnd, location.end) < 0
|
||||
)
|
||||
continue;
|
||||
const candidate = current.entry;
|
||||
if (contains(candidate.range, location)) {
|
||||
if (javascriptSourceRangeContains(candidate.range, location)) {
|
||||
const node = nodes.get(candidate.callableId);
|
||||
const rank =
|
||||
best === undefined
|
||||
@@ -93,9 +99,15 @@ const buildSourceIntervalTree = <Interval extends SourceInterval>(
|
||||
let minimumEnd = entry.range.end;
|
||||
let maximumEnd = entry.range.end;
|
||||
for (const child of [left, right]) {
|
||||
if (child !== null && pointCompare(child.minimumEnd, minimumEnd) < 0)
|
||||
if (
|
||||
child !== null &&
|
||||
compareJavaScriptSourcePoints(child.minimumEnd, minimumEnd) < 0
|
||||
)
|
||||
minimumEnd = child.minimumEnd;
|
||||
if (child !== null && pointCompare(child.maximumEnd, maximumEnd) > 0)
|
||||
if (
|
||||
child !== null &&
|
||||
compareJavaScriptSourcePoints(child.maximumEnd, maximumEnd) > 0
|
||||
)
|
||||
maximumEnd = child.maximumEnd;
|
||||
}
|
||||
return {
|
||||
@@ -120,7 +132,7 @@ export const createSemanticNodeRangeLookup = (
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
pointCompare(left.range.start, right.range.start) ||
|
||||
compareJavaScriptSourcePoints(left.range.start, right.range.start) ||
|
||||
left.order - right.order,
|
||||
);
|
||||
const tree = buildSourceIntervalTree(entries, 0, entries.length);
|
||||
@@ -132,11 +144,12 @@ export const createSemanticNodeRangeLookup = (
|
||||
const current = pending.pop();
|
||||
if (
|
||||
current === undefined ||
|
||||
pointCompare(current.maximumStart, range.start) < 0 ||
|
||||
pointCompare(current.minimumEnd, range.end) > 0
|
||||
compareJavaScriptSourcePoints(current.maximumStart, range.start) < 0 ||
|
||||
compareJavaScriptSourcePoints(current.minimumEnd, range.end) > 0
|
||||
)
|
||||
continue;
|
||||
if (contains(range, current.entry.range)) matches.push(current.entry);
|
||||
if (javascriptSourceRangeContains(range, current.entry.range))
|
||||
matches.push(current.entry);
|
||||
if (current.left !== null) pending.push(current.left);
|
||||
if (current.right !== null) pending.push(current.right);
|
||||
}
|
||||
@@ -155,18 +168,15 @@ export const createSemanticCallSiteLookup = (
|
||||
) => JavaScriptSemanticGraphNode | undefined) => {
|
||||
const identifiers = new Map<string, string>();
|
||||
for (const call of ir.callSites) {
|
||||
const key = rangeKey(call.location);
|
||||
const key = javascriptSourceRangeKey(call.location);
|
||||
if (!identifiers.has(key)) identifiers.set(key, call.callSiteId);
|
||||
}
|
||||
return (range) => {
|
||||
const identifier = identifiers.get(rangeKey(range));
|
||||
const identifier = identifiers.get(javascriptSourceRangeKey(range));
|
||||
return identifier === undefined ? undefined : nodes.get(identifier);
|
||||
};
|
||||
};
|
||||
|
||||
const rangeKey = (range: JavaScriptSourceRange): string =>
|
||||
`${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`;
|
||||
|
||||
/** Report extractor support without treating missing families as absence. */
|
||||
export const semanticFamilyStatus = (
|
||||
family: (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number],
|
||||
@@ -191,17 +201,5 @@ export const semanticFamilyStatus = (
|
||||
return "partial";
|
||||
};
|
||||
|
||||
const contains = (
|
||||
outer: JavaScriptSourceRange,
|
||||
inner: JavaScriptSourceRange,
|
||||
): boolean =>
|
||||
pointCompare(outer.start, inner.start) <= 0 &&
|
||||
pointCompare(outer.end, inner.end) >= 0;
|
||||
|
||||
const pointCompare = (
|
||||
left: JavaScriptSourceRange["start"],
|
||||
right: JavaScriptSourceRange["start"],
|
||||
): number => left.line - right.line || left.column - right.column;
|
||||
|
||||
const positionKey = (range: JavaScriptSourceRange): string =>
|
||||
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
|
||||
@@ -20,12 +20,23 @@ export const NATIVE_DISPATCH_METADATA_SOURCES = {
|
||||
export const inspectNativeDispatch = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
maxRecords: number,
|
||||
signal?: AbortSignal,
|
||||
objcClassImportAddresses?: readonly string[],
|
||||
objcClassImportAddressesComplete?: boolean,
|
||||
options: {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly objcClassImportAddresses?: readonly string[] | undefined;
|
||||
readonly objcClassImportAddressesComplete?: boolean | undefined;
|
||||
/** Binary metadata input budget; symbol-only inventories ignore it. */
|
||||
readonly maxInputBytes?: number | undefined;
|
||||
} = {},
|
||||
): EnhancedResult => {
|
||||
const {
|
||||
signal,
|
||||
objcClassImportAddresses,
|
||||
objcClassImportAddressesComplete,
|
||||
maxInputBytes,
|
||||
} = options;
|
||||
const input = {
|
||||
max_records: maxRecords,
|
||||
...(maxInputBytes === undefined ? {} : { max_input_bytes: maxInputBytes }),
|
||||
...(objcClassImportAddresses === undefined
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -46,7 +46,7 @@ export const traceNativeUiAction = async (
|
||||
},
|
||||
signal === undefined ? {} : { signal },
|
||||
),
|
||||
inspectNativeDispatch(analysis, 20_000, signal),
|
||||
inspectNativeDispatch(analysis, 20_000, { signal }),
|
||||
]);
|
||||
if (!uiExecution.ok && uiExecution.error._tag === "AnalysisCancelledError")
|
||||
return err(uiExecution.error);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { metadataReportSubjectFormat } from "../../domain/apple/machoTargetSelection.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { RustBinaryPort } from "./RustBinaryPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
@@ -9,6 +10,7 @@ import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
rustArchitectureForSubject,
|
||||
rustBinarySchema,
|
||||
type InspectRustBinaryInput,
|
||||
} from "../../domain/rust/rustBinary.js";
|
||||
import { inspectRustBinaryRequestSchema } from "../../domain/rust/rustBinaryQuery.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
@@ -82,7 +84,7 @@ export class RustBinaryService {
|
||||
|
||||
const evidenceFor = (
|
||||
provider: RustBinaryPort["identity"],
|
||||
parameters: { readonly path: string },
|
||||
parameters: InspectRustBinaryInput,
|
||||
value: ReturnType<typeof rustBinarySchema.parse>,
|
||||
): Evidence => {
|
||||
const architecture = rustArchitectureForSubject(value.architecture);
|
||||
@@ -91,7 +93,7 @@ const evidenceFor = (
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.format === "macho" ? "mach-o" : value.format,
|
||||
format: metadataReportSubjectFormat(value),
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
provider,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
||||
import {
|
||||
createAsarHeaderSignatureProbe,
|
||||
type FinalAsarHeaderProbeObservation,
|
||||
@@ -105,9 +106,11 @@ const finishClassificationEvidence = (
|
||||
};
|
||||
|
||||
/** Share acquisition bounds and signature observations across files and streams. */
|
||||
export const createArtifactClassificationProbe = () => {
|
||||
export const createArtifactClassificationProbe = (
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
) => {
|
||||
const xml = createXmlPlistSignatureProbe();
|
||||
const asar = createAsarHeaderSignatureProbe();
|
||||
const asar = createAsarHeaderSignatureProbe(asarHeaderAdmission);
|
||||
const blocks: Buffer[] = [];
|
||||
let retainedBytes = 0;
|
||||
let observedBytes = 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
@@ -30,10 +31,11 @@ export const hashReadable = async (
|
||||
stream: Readable,
|
||||
signal?: AbortSignal,
|
||||
integrityAlgorithm?: "sha256" | "md5",
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): Promise<HashResult> => {
|
||||
const hash = createHash("sha256");
|
||||
const md5 = integrityAlgorithm === "md5" ? createHash("md5") : undefined;
|
||||
const probe = createArtifactClassificationProbe();
|
||||
const probe = createArtifactClassificationProbe(asarHeaderAdmission);
|
||||
let bytes = 0;
|
||||
for await (const raw of stream) {
|
||||
abortIfNeeded(signal);
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "../domain/artifactOperationError.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
BinaryTargetError,
|
||||
ConfigurationError,
|
||||
} from "../domain/configurationErrors.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
@@ -40,13 +43,14 @@ export const translateArtifactFailure = (
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
}
|
||||
// Caller-selection, target and unsupported-target failures are already typed;
|
||||
// Caller-selection, target, unsupported-target and configuration failures are already typed;
|
||||
// keep their correction details instead of reducing them to an I/O failure.
|
||||
if (
|
||||
cause instanceof AnalysisInputError ||
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
cause instanceof AnalysisUnsupportedTargetError ||
|
||||
cause instanceof BinaryTargetError
|
||||
cause instanceof BinaryTargetError ||
|
||||
cause instanceof ConfigurationError
|
||||
)
|
||||
return cause;
|
||||
return new ArtifactOperationError(operation, "io");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAsarHeaderAdmission } from "./AsarHeader.js";
|
||||
import { artifactTargetRefusal } from "../domain/apple/artifactTargetApplicability.js";
|
||||
import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -480,6 +481,7 @@ class ArtifactClient implements AnalysisClient {
|
||||
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
|
||||
resourceScope: this.#resourceScope,
|
||||
environment: this.environment,
|
||||
asarHeaderAdmission: resolveAsarHeaderAdmission(this.environment),
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
integrity: { mode: parsed.integrity_policy },
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
||||
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
|
||||
import { constants, createWriteStream, type Stats } from "node:fs";
|
||||
import {
|
||||
@@ -142,7 +143,11 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
try {
|
||||
admission = {
|
||||
ok: true,
|
||||
value: await admitAsarHeader(headerHandle, signal),
|
||||
value: await admitAsarHeader(
|
||||
headerHandle,
|
||||
signal,
|
||||
this.options.headerAdmission,
|
||||
),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
admission = { ok: false, cause };
|
||||
@@ -622,7 +627,9 @@ type OwnedAsarHandle = {
|
||||
source?: Readable;
|
||||
output?: Readable;
|
||||
};
|
||||
type AsarArtifactReaderOptions = {
|
||||
export type AsarArtifactReaderOptions = {
|
||||
/** Header/index admission resolved by the caller's composition root; default otherwise. */
|
||||
readonly headerAdmission?: AsarHeaderAdmission;
|
||||
readonly openFile?: OpenAsarFile;
|
||||
readonly openUnpackedFile?: OpenAsarFile;
|
||||
readonly removeSnapshot?: (path: string) => Promise<void>;
|
||||
|
||||
+60
-27
@@ -4,40 +4,63 @@ import type { FileHandle } from "node:fs/promises";
|
||||
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
ASAR_HEADER_ADMISSION_SETTING,
|
||||
DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
DEFAULT_ASAR_MAX_HEADER_BYTES,
|
||||
MAXIMUM_ASAR_MAX_HEADER_BYTES,
|
||||
parseAsarHeaderAdmission,
|
||||
type AsarHeaderAdmission,
|
||||
} from "../config/asarHeaderAdmission.js";
|
||||
|
||||
/**
|
||||
* Fixed header/index admission for this release. The header is decoded before
|
||||
* any member path can be selected. There is no caller or configuration override.
|
||||
* Resolve the admission from a composition root's selected environment; an
|
||||
* invalid setting is refused, not ignored. Callers without an environment use
|
||||
* the default.
|
||||
*/
|
||||
export const MAX_ASAR_HEADER_BYTES = 16 * 1024 * 1024;
|
||||
export const resolveAsarHeaderAdmission = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): AsarHeaderAdmission => {
|
||||
const parsed = parseAsarHeaderAdmission(
|
||||
environment[ASAR_HEADER_ADMISSION_SETTING],
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
|
||||
/** User-visible recovery for the fixed ASAR header/index admission budget. */
|
||||
export const asarHeaderAdmissionRecovery = (): string => {
|
||||
const bytes = MAX_ASAR_HEADER_BYTES;
|
||||
const byteAllowance = (bytes: number): string => {
|
||||
const mebibytes = bytes / MEBIBYTE;
|
||||
const allowance = Number.isInteger(mebibytes)
|
||||
return Number.isInteger(mebibytes)
|
||||
? `${String(mebibytes)} MiB / ${String(bytes)} bytes`
|
||||
: `${String(bytes)} bytes`;
|
||||
return [
|
||||
`ASAR header/index admission is limited to ${allowance}.`,
|
||||
"This release has no runtime override.",
|
||||
"Selecting fewer sources or narrowing an internal path does not bypass header admission, because the archive index is read before any member can be selected.",
|
||||
"Raising max_heap_mb or available host memory does not raise it.",
|
||||
"Analyze an extracted application directory, which does not admit an ASAR header, or supply an archive whose header fits this allowance.",
|
||||
"Repeating the unchanged request fails the same way.",
|
||||
].join(" ");
|
||||
};
|
||||
|
||||
/** Refuse an ASAR whose header/index exceeds the fixed admission budget. */
|
||||
export const asarHeaderAdmissionFailure = (): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("limit", asarHeaderAdmissionRecovery(), {
|
||||
admission: { kind: "asar-header", maximumBytes: MAX_ASAR_HEADER_BYTES },
|
||||
/** User-visible recovery for the effective ASAR header/index admission budget. */
|
||||
export const asarHeaderAdmissionRecovery = (
|
||||
admission: AsarHeaderAdmission,
|
||||
): string =>
|
||||
[
|
||||
`ASAR header/index admission is limited to ${byteAllowance(admission.maximumBytes)}${admission.source === "setting" ? ` by ${ASAR_HEADER_ADMISSION_SETTING}` : ""}.`,
|
||||
`Set ${ASAR_HEADER_ADMISSION_SETTING} in the environment that starts REA to a byte count up to ${byteAllowance(MAXIMUM_ASAR_MAX_HEADER_BYTES)} (default ${byteAllowance(DEFAULT_ASAR_MAX_HEADER_BYTES)}), then restart REA.`,
|
||||
"Selecting fewer sources or narrowing an internal path does not bypass header admission, because the archive index is read before any member can be selected.",
|
||||
"Raising max_heap_mb or available host memory does not raise it.",
|
||||
"Alternatively, analyze an extracted application directory, which does not admit an ASAR header.",
|
||||
"Repeating the unchanged request fails the same way.",
|
||||
].join(" ");
|
||||
|
||||
/** Refuse an ASAR whose header/index exceeds the effective admission budget. */
|
||||
export const asarHeaderAdmissionFailure = (
|
||||
admission: AsarHeaderAdmission,
|
||||
): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("limit", asarHeaderAdmissionRecovery(admission), {
|
||||
admission: { kind: "asar-header", ...admission },
|
||||
});
|
||||
|
||||
/** Absolute archive prefix required to inspect an admitted header. */
|
||||
export const MAX_ASAR_HEADER_PROBE_BYTES = MAX_ASAR_HEADER_BYTES + 8;
|
||||
/** Absolute archive prefix required to inspect a header under this admission. */
|
||||
export const asarHeaderProbeBytes = (admission: AsarHeaderAdmission): number =>
|
||||
admission.maximumBytes + 8;
|
||||
|
||||
export interface AsarHeaderExtents {
|
||||
readonly archiveBytes: number;
|
||||
@@ -77,6 +100,7 @@ export type AsarHeaderProbeObservation =
|
||||
readonly extents?: AsarHeaderProbeExtents;
|
||||
readonly scannedJsonBytes: number;
|
||||
readonly limitBytes: number;
|
||||
readonly admission: AsarHeaderAdmission;
|
||||
};
|
||||
|
||||
export type FinalAsarHeaderProbeObservation = Exclude<
|
||||
@@ -136,6 +160,7 @@ export const parseAsarHeaderExtents = (
|
||||
* scans the declared JSON bytes until the first non-JSON-whitespace byte.
|
||||
*/
|
||||
export interface AsarHeaderSignatureProbe {
|
||||
readonly admission: AsarHeaderAdmission;
|
||||
readonly limit: number;
|
||||
readonly observation: AsarHeaderProbeObservation;
|
||||
write(input: Uint8Array): AsarHeaderProbeObservation;
|
||||
@@ -143,13 +168,17 @@ export interface AsarHeaderSignatureProbe {
|
||||
}
|
||||
|
||||
class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe {
|
||||
readonly limit = MAX_ASAR_HEADER_PROBE_BYTES;
|
||||
readonly limit: number;
|
||||
readonly #fixed = Buffer.alloc(16);
|
||||
#fixedBytes = 0;
|
||||
#scannedJsonBytes = 0;
|
||||
#extents: Omit<AsarHeaderExtents, "archiveBytes"> | undefined;
|
||||
#stop: ProbeStop = { state: "pending" };
|
||||
|
||||
constructor(readonly admission: AsarHeaderAdmission) {
|
||||
this.limit = asarHeaderProbeBytes(admission);
|
||||
}
|
||||
|
||||
get observation(): AsarHeaderProbeObservation {
|
||||
return this.#observation();
|
||||
}
|
||||
@@ -227,7 +256,7 @@ class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe {
|
||||
) {
|
||||
this.#stop = { state: "rejected", reason: "archive-too-short" };
|
||||
} else if (
|
||||
this.#extents.headerBytes > MAX_ASAR_HEADER_BYTES &&
|
||||
this.#extents.headerBytes > this.admission.maximumBytes &&
|
||||
this.#stop.state === "matched"
|
||||
) {
|
||||
this.#stop = { state: "limit" };
|
||||
@@ -279,6 +308,7 @@ class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe {
|
||||
...(extents === undefined ? {} : { extents }),
|
||||
scannedJsonBytes: this.#scannedJsonBytes,
|
||||
limitBytes: this.limit,
|
||||
admission: this.admission,
|
||||
};
|
||||
case "matched":
|
||||
return {
|
||||
@@ -308,6 +338,7 @@ class AsarHeaderSignatureProbeImpl implements AsarHeaderSignatureProbe {
|
||||
extents: finalizedExtents,
|
||||
scannedJsonBytes: this.#scannedJsonBytes,
|
||||
limitBytes: this.limit,
|
||||
admission: this.admission,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -346,6 +377,7 @@ const isJsonWhitespace = (value: number): boolean =>
|
||||
export const admitAsarHeader = async (
|
||||
handle: Pick<FileHandle, "read"> & { stat(): Promise<Stats> },
|
||||
signal?: AbortSignal,
|
||||
admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
): Promise<AsarHeaderExtents> => {
|
||||
assertArtifactReadActive(signal);
|
||||
const observed = await handle.stat();
|
||||
@@ -372,13 +404,14 @@ export const admitAsarHeader = async (
|
||||
}
|
||||
const parsed = parseAsarHeaderExtents(prefix, observed.size);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
if (parsed.value.headerBytes > MAX_ASAR_HEADER_BYTES)
|
||||
throw asarHeaderAdmissionFailure();
|
||||
if (parsed.value.headerBytes > admission.maximumBytes)
|
||||
throw asarHeaderAdmissionFailure(admission);
|
||||
return {
|
||||
archiveBytes: observed.size,
|
||||
...parsed.value,
|
||||
};
|
||||
};
|
||||
|
||||
export const createAsarHeaderSignatureProbe = (): AsarHeaderSignatureProbe =>
|
||||
new AsarHeaderSignatureProbeImpl();
|
||||
export const createAsarHeaderSignatureProbe = (
|
||||
admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
): AsarHeaderSignatureProbe => new AsarHeaderSignatureProbeImpl(admission);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAsarHeaderAdmission } from "../AsarHeader.js";
|
||||
import { locateGodotPck } from "../GodotPckLocation.js";
|
||||
import { withArtifactRegularFile } from "../ArtifactFileOperation.js";
|
||||
import type { ArtifactCleanupAttempt } from "../ArtifactResourceScope.js";
|
||||
@@ -75,6 +76,7 @@ const extractArtifactInScope = async (
|
||||
resourceScope: input.resourceScope,
|
||||
signal,
|
||||
environment: input.environment,
|
||||
asarHeaderAdmission: resolveAsarHeaderAdmission(input.environment),
|
||||
integrity: { mode: input.integrityPolicy },
|
||||
});
|
||||
return materializeArtifactInventoryInScope(
|
||||
@@ -483,7 +485,10 @@ const createReader = async (
|
||||
return new PyInstallerArtifactReader(path);
|
||||
if (await requireExtractableFormat(path, input, signal))
|
||||
return new DirectoryArtifactReader(path);
|
||||
if (format === "asar") return new AsarArtifactReader(path);
|
||||
if (format === "asar")
|
||||
return new AsarArtifactReader(path, {
|
||||
headerAdmission: resolveAsarHeaderAdmission(input.environment),
|
||||
});
|
||||
if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format);
|
||||
return new MachOSliceArtifactReader(path, input.environment);
|
||||
};
|
||||
|
||||
@@ -529,7 +529,8 @@ export const classifyArtifactContent = (
|
||||
path.toLowerCase().endsWith(".asar") &&
|
||||
asarHeader.state !== "rejected"
|
||||
) {
|
||||
if (asarHeader.state === "limit") throw asarHeaderAdmissionFailure();
|
||||
if (asarHeader.state === "limit")
|
||||
throw asarHeaderAdmissionFailure(asarHeader.admission);
|
||||
if (asarHeader.state === "matched")
|
||||
return { kind: "container", format: "asar" };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import { locateGodotPck } from "../GodotPckLocation.js";
|
||||
import { sameRegularFileState } from "../../filesystem/RegularFile.js";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
@@ -78,7 +79,10 @@ export const classifyAndHashRootForInventory = async (
|
||||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "asarHeaderAdmission"
|
||||
>,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
if (directory)
|
||||
return { format: "directory", digest: null, rootSource: undefined };
|
||||
@@ -106,7 +110,12 @@ export const classifyAndHashRootForInventory = async (
|
||||
"integrity",
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
const format = await classifyRootFormat(path, handle, options.signal);
|
||||
const format = await classifyRootFormat(
|
||||
path,
|
||||
handle,
|
||||
options.signal,
|
||||
options.asarHeaderAdmission,
|
||||
);
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
@@ -144,8 +153,9 @@ const classifyRootFormat = async (
|
||||
path: string,
|
||||
handle: FileHandle,
|
||||
signal?: AbortSignal,
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): Promise<ArtifactOccurrence["artifact_format"]> => {
|
||||
const probe = createArtifactClassificationProbe();
|
||||
const probe = createArtifactClassificationProbe(asarHeaderAdmission);
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
while (probe.length < probe.limit) {
|
||||
abortIfNeeded(signal);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import type {
|
||||
ArtifactReader,
|
||||
@@ -24,6 +25,7 @@ export const createReader = (
|
||||
format: ArtifactOccurrence["artifact_format"],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
rootSource?: StableRegularFileDescriptor,
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): ArtifactReader | undefined => {
|
||||
if (isZipPackageFormat(format))
|
||||
return new ZipArtifactReader(path, format, undefined, rootSource);
|
||||
@@ -31,7 +33,12 @@ export const createReader = (
|
||||
case "directory":
|
||||
return new DirectoryArtifactReader(path);
|
||||
case "asar":
|
||||
return new AsarArtifactReader(path);
|
||||
return new AsarArtifactReader(
|
||||
path,
|
||||
asarHeaderAdmission === undefined
|
||||
? {}
|
||||
: { headerAdmission: asarHeaderAdmission },
|
||||
);
|
||||
case "pyinstaller":
|
||||
return new PyInstallerArtifactReader(path, rootSource);
|
||||
case "godot-pck":
|
||||
|
||||
@@ -84,6 +84,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
rootFormat,
|
||||
options.environment ?? {},
|
||||
rootSource,
|
||||
options.asarHeaderAdmission,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
readerCreationFailure = { cause };
|
||||
@@ -101,7 +102,13 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
||||
caseCollisionLimitation,
|
||||
unknownParentOccurrences,
|
||||
traversal,
|
||||
} = await scanReader(reader, ownedReaders, options.signal, integrity);
|
||||
} = await scanReader(
|
||||
reader,
|
||||
ownedReaders,
|
||||
options.signal,
|
||||
integrity,
|
||||
options.asarHeaderAdmission,
|
||||
);
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
snapshot: await buildInventorySnapshot({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import {
|
||||
artifactIntegrityPair,
|
||||
artifactIntegrityDetails,
|
||||
@@ -56,6 +57,7 @@ interface ScanContext {
|
||||
readonly reader: ArtifactReader;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly integrity: ArtifactIntegrityPolicy;
|
||||
readonly asarHeaderAdmission: AsarHeaderAdmission | undefined;
|
||||
readonly nodes: Map<string, ArtifactNode>;
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
@@ -72,6 +74,7 @@ export const scanReader = async (
|
||||
ownedReaders: ArtifactReader[],
|
||||
signal?: AbortSignal,
|
||||
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): Promise<{
|
||||
readonly nodes: Map<string, ArtifactNode>;
|
||||
readonly occurrences: MutableOccurrence[];
|
||||
@@ -94,6 +97,7 @@ export const scanReader = async (
|
||||
reader,
|
||||
signal,
|
||||
integrity,
|
||||
asarHeaderAdmission,
|
||||
nodes,
|
||||
occurrences,
|
||||
pendingContradictions,
|
||||
@@ -248,7 +252,12 @@ const visitArtifactEntries = async (
|
||||
digested.classification.format === "asar" &&
|
||||
!digested.mismatched
|
||||
) {
|
||||
const nested = new AsarArtifactReader(entry.adapterKey);
|
||||
const nested = new AsarArtifactReader(
|
||||
entry.adapterKey,
|
||||
context.asarHeaderAdmission === undefined
|
||||
? {}
|
||||
: { headerAdmission: context.asarHeaderAdmission },
|
||||
);
|
||||
context.ownedReaders.push(nested);
|
||||
await nested.prepareContainer(digested.node.sha256, context.signal);
|
||||
// Only traversed containers can own members, not opaque ASAR-named files.
|
||||
@@ -304,6 +313,7 @@ const digestArtifactEntry = async (
|
||||
await currentReader.open(entry, context.signal),
|
||||
context.signal,
|
||||
entry.declaredIntegrity?.algorithm,
|
||||
context.asarHeaderAdmission,
|
||||
);
|
||||
const claim = entry.declaredIntegrity;
|
||||
const observed = claim?.algorithm === "md5" ? digest.md5 : digest.sha256;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
|
||||
/** Resolved integrity behavior admitted to the artifact scanner. */
|
||||
@@ -15,4 +16,6 @@ export interface ArtifactInventoryOptions {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly integrity?: ArtifactIntegrityPolicy | undefined;
|
||||
readonly environment?: Readonly<NodeJS.ProcessEnv> | undefined;
|
||||
/** ASAR header/index admission resolved from the owner's environment; default when absent. */
|
||||
readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
@@ -52,6 +53,7 @@ interface ReadContext {
|
||||
readonly files: JavaScriptArtifactFile[];
|
||||
readonly containers: JavaScriptArtifactContainer[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly asarHeaderAdmission: AsarHeaderAdmission | undefined;
|
||||
readonly selectedPaths: ReadonlyMap<string, number> | undefined;
|
||||
textBytes: number;
|
||||
invalidUtf8: number;
|
||||
@@ -70,6 +72,7 @@ export const readJavaScriptArtifactFiles = async (
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
sourcePaths?: readonly string[],
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): Promise<JavaScriptArtifactFileSet> => {
|
||||
const inventory = expectedInventory(snapshot);
|
||||
const expected = inventory.files;
|
||||
@@ -129,6 +132,7 @@ export const readJavaScriptArtifactFiles = async (
|
||||
files: [],
|
||||
containers: [],
|
||||
signal,
|
||||
asarHeaderAdmission,
|
||||
selectedPaths,
|
||||
textBytes: 0,
|
||||
invalidUtf8: 0,
|
||||
@@ -250,7 +254,12 @@ const visitReader = async (
|
||||
context.signal,
|
||||
);
|
||||
context.containers.push(inventory);
|
||||
const nested = new AsarArtifactReader(entry.adapterKey);
|
||||
const nested = new AsarArtifactReader(
|
||||
entry.adapterKey,
|
||||
context.asarHeaderAdmission === undefined
|
||||
? {}
|
||||
: { headerAdmission: context.asarHeaderAdmission },
|
||||
);
|
||||
ownedReaders.push({ reader: nested, path });
|
||||
stack.push({
|
||||
reader: nested,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import type { ArtifactReader } from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
@@ -6,7 +7,13 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
export const createJavaScriptArtifactReader = (
|
||||
path: string,
|
||||
format: "asar" | "directory",
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
): ArtifactReader =>
|
||||
format === "asar"
|
||||
? new AsarArtifactReader(path)
|
||||
? new AsarArtifactReader(
|
||||
path,
|
||||
asarHeaderAdmission === undefined
|
||||
? {}
|
||||
: { headerAdmission: asarHeaderAdmission },
|
||||
)
|
||||
: new DirectoryArtifactReader(path);
|
||||
@@ -386,8 +386,16 @@ const withTargetSession = async <
|
||||
let outcome: Result<Output, unknown>;
|
||||
let cleanupFailure: AnalysisError | undefined;
|
||||
try {
|
||||
const target = authorizeTarget(endpoint.discovery, input);
|
||||
const scopedInput = scopeToTargetOrigin(input, target);
|
||||
const { target, allowedOrigins } = authorizeCdpTarget(
|
||||
endpoint.discovery,
|
||||
input,
|
||||
operation,
|
||||
);
|
||||
// The authorization owner already derived the target origin scope.
|
||||
const scopedInput =
|
||||
input.allowed_origins.length > 0
|
||||
? input
|
||||
: { ...input, allowed_origins: [...allowedOrigins] };
|
||||
targetSession =
|
||||
endpoint.connection === undefined
|
||||
? await openCdpTargetSession(
|
||||
@@ -548,31 +556,13 @@ const selectTargets = (
|
||||
};
|
||||
};
|
||||
|
||||
const authorizeTarget = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
input: Pick<InspectWebPageInput, "target_id" | "allowed_origins">,
|
||||
): CdpEndpointTarget => {
|
||||
return authorizeCdpTarget(discovery, input, "inspect_web_page").target;
|
||||
};
|
||||
|
||||
const scopeToTargetOrigin = <T extends { allowed_origins: readonly string[] }>(
|
||||
input: T,
|
||||
target: CdpEndpointTarget,
|
||||
): T => {
|
||||
if (input.allowed_origins.length > 0) return input;
|
||||
const origin = sanitizeBrowserUrl(target.url).origin;
|
||||
if (origin === null)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
return { ...input, allowed_origins: [origin] };
|
||||
};
|
||||
|
||||
const providerError = (
|
||||
cause: unknown,
|
||||
operation: BrowserObservationOperation,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError =>
|
||||
cause instanceof BrowserObservationError && cause.operation !== operation
|
||||
? new BrowserObservationError(operation, cause.reason, { cause })
|
||||
cause instanceof BrowserObservationError
|
||||
? cause.forOperation(operation)
|
||||
: cause instanceof AnalysisError
|
||||
? cause
|
||||
: signal?.aborted === true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_ASAR_HEADER_ADMISSION } from "../config/asarHeaderAdmission.js";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -54,6 +55,7 @@ describe("Electron frame capture", () => {
|
||||
},
|
||||
},
|
||||
completeness,
|
||||
DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
);
|
||||
|
||||
expect(frames.map((frame) => frame.frame_id)).toEqual(["frame-main"]);
|
||||
@@ -133,6 +135,7 @@ describe("Electron frame capture", () => {
|
||||
scripts,
|
||||
frameIds: new Set(["frame-old", "frame-new"]),
|
||||
completeness,
|
||||
asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
});
|
||||
|
||||
expect(capture.total).toBe(4);
|
||||
@@ -212,6 +215,7 @@ it("admits a prefix and fitting suffix around an oversized Electron source", asy
|
||||
scripts,
|
||||
frameIds: new Set(),
|
||||
completeness,
|
||||
asarHeaderAdmission: DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
});
|
||||
const byHash = new Map(
|
||||
capture.items.map((item) => [item.cdp_hash, item.source]),
|
||||
|
||||
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user