Require operator-declared hostnames before serving MCP HTTP outside loopback, preserving Host/Origin gates. Parse hostname identities once, share typed startup recovery across CLI/MCP, and announce an admitted client URL. Generated AgentEnv bundles declare their client/service names and validate DNS/HTTP name identity before export.
Validated through real packaged HTTP requests, direct SDK connection, compiled export refusals, 184 focused tests, static and documentation checks on the refreshed main integration. Final CI and review evidence is recorded in the PR body.
BREAKING CHANGE: non-loopback deployments require MCP_ALLOWED_HOSTS or rea mcp --http --allowed-hosts; bundle names must be unchanged HTTP hostnames within the 63-octet DNS label bound.
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
Passive page capture retained console entries and websocket frames with no
aggregate ceiling. Every sibling retention surface already enforces
WEB_RUNTIME_LIMITS.retainedEventBytes (8 MiB): the observation session's
pre-arm buffer and document-request identities, the browser runtime sources,
requests, and metadata, the V8 inspector's retained metadata, and this same
capture's script metadata, which has its own count-and-bytes budget. A page
that logs a few hundred megabytes of console text, or sends text frames whose
JSON shapes carry hundreds of thousands of keys, grew the capture without
limit until the process ran out of memory; the MCP response budget runs after
the handler, so the growth happened before any typed refusal could exist.
Admit each console entry and websocket frame against the same shared budget
before retaining it. A refused entry is counted through the completeness
tracker's dropped-events vocabulary, which already carries console_events and
websocket_frames kinds and was so far used only for late scripts, so the
inspection reports a truncated inventory with the dropped count instead of
silently overflowing. Console entry bytes measure the retained representation:
the entry's type, url, argument-type strings, and the text capture's retained
bytes, which the handler already computed per entry. A websocket frame retains
its shape summary rather than the payload, and the shape grows with the
payload's own keys, so the frame is charged the serialized size of the shape
it retains. beginAuthorizedFrame resets the counter together with the rest of
the per-frame state.
Out of scope: response metadata and agent hints, whose rows are fixed-shape
facts already bounded per response, and any new setting. The shared 8 MiB
constant is reused as-is.
Validation: RED then green in src/browser/CdpCaptureEvents.eventBudget.test.ts,
where the console case previously retained every oversized entry and the
websocket case retained the over-budget frame; 26 tests across the neighboring
console, websocket, coverage, and completeness suites pass. On this Windows
host the wider browser project has 26 pre-existing failures in three
Electron/WebBundle worker-spawn files, reproduced identically with this change
stashed (26 failed, 644 passed on the pristine base; 26 failed, 647 passed
with it on the same file set). tsc --noEmit over the changed set exits 0,
oxlint reports 0 errors, oxfmt --check is clean, and verify:module-boundaries
passes. No real browser session was driven on this host; the browser CI lane
owns that coverage.
Each retained entry now carries a conservative fixed overhead, and each captured console text value a wrapper cost, so a stream of minimal entries cannot admit millions of objects into the budget when shapes are disabled; a mixed-kind test locks that console entries and websocket frames spend one shared allowance.
The IPS JSON reader decodes \uXXXX escapes with String.fromCharCode and
returns raw slices verbatim, so an escaped unpaired surrogate reaches
Evidence as-is: malformed text reported under complete coverage, with no
refusal and no uncertainty. A crash report's procName or exception message
comes from the crashed process, so the escape is reachable input.
Refuse the string the way the parser already refuses other malformed
content. The check runs on the decoded value, so it covers escaped and
unescaped text alike, and a high surrogate followed by its low surrogate
escape stays valid text.
Validation: RED then green in src/native/apple/losslessJson.test.ts, where
the new case previously accepted the escaped surrogate; 311 tests across
the apple parser suites pass; tsc --noEmit over the changed set exits 0;
oxlint reports 0 errors; oxfmt --check is clean; verify:module-boundaries
passes.
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
* fix(ghidra): enforce the bridge request ceiling before writing
Hopper and Cutter both measure an oversized payload and refuse it before the
request reaches its bridge, using the shared REA_BRIDGE_MAX_REQUEST_BYTES
ceiling. Ghidra had no ceiling on either end: GhidraWire.request wrote the
stringified request straight to the socket, and the Java bridge reads request
lines with an unbounded readLine() after its bounded 64 KiB handshake.
Serialize once, measure the encoded payload, and refuse before the operation
is registered as pending and before any socket write, so an oversized request
never reaches the bridge. Carry the typed constraint as the cause and
re-attribute it in projectPrimarySessionError, so callers see the same
resource constraint Hopper and Cutter already report.
The Ghidra bridge reads no ceiling of its own, so its recovery text names that
only the REA client applies the setting instead of asking for a bridge restart.
Validation: src/ghidra/GhidraClientWire.test.ts (3 cases, red then green),
src/config/bridgeMaxRequestBytes.test.ts (16), src/ghidra/GhidraRequestQueue.test.ts
(25); tsc --noEmit over the changed set exits 0; oxlint reports 0 errors;
oxfmt --check clean; verify:module-boundaries passes. The two failures in the
wider src/config+src/ghidra+src/hopper+src/cutter sweep
(captureDecoderHeap, GhidraAnalysisSeeds symlink EPERM) reproduce identically
on pristine bded9c628 with this change stashed. The Ghidra fixture suite
cannot run on this Windows host: fakeGhidra.mjs renames bridge-endpoint.json
and hits EBUSY on 3 of 3 runs, so the fixture-level test was not added.
* fix(ghidra): preflight request bytes before encoding
---------
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
* fix(setup): report host-owned cleanup resources alongside the primary's
SetupHost.close documents that it reports the resources a setup host still
owns, but its contract returned only a string. runSetup therefore built the
ProviderCleanupError from the primary failure's cleanupResources alone, so a
resource the host failed to release reached the projection only inside a
free-text reason while the error asks the caller to review the reported local
resources.
Return a SetupCloseReport instead. closeSetupResources absorbs the structured
resources of any cause that reports them, through
ArtifactReaderFailure.cleanupObservation, and leaves opaque failures
message-only so no resource name is invented. runSetup then unions the host's
resources with the primary's before building the typed cleanup error.
Two boundary tests read the close result directly: the skill-cleanup case now
asserts the retained backup path in the report reason rather than in a bare
string, and the shared ownership helper takes an opaque close result because it
only ever awaits undefined from either host it is given.
Validation: RED then green in src/application/Setup.recovery.test.ts, where
the new case previously received only the primary resource; 116 tests across
the seven Setup and cleanup-ownership suites pass; tsc --noEmit over the
changed set, its setup callers, and both boundary tests exits 0; oxlint
reports 0 errors; oxfmt --check is clean; verify:module-boundaries passes.
The two failures in src/setupEnvironment.test.ts reproduce identically on
pristine bded9c628 with this change stashed.
* fix(setup): preserve cleanup ownership through host reports
* fix(setup): bind finalization reports to cleanup owners
* fix(setup): retain live resources in returned cleanup views
* fix(setup): preserve completed cleanup and exact installer resources
* fix(setup): retain delegated cleanup across authority changes
* fix(setup): compose callback and source cleanup authorities
* fix(setup): retain producer cleanup failure reasons
* fix(cli): render structured setup cleanup resources
* fix(setup): separate failure evidence from cleanup ownership
* fix(setup): normalize close failures and unify lease binding
* fix(setup): preserve direct lease resource attribution
---------
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
A projection wider than SQLite's native result-column limit failed with the
generic "too many columns in result set" SQL error. The inspector could not
attribute that error to a generated column, so the worker reported "format"
and the parent blamed the caller's path as invalid_format even though the
selected database was readable.
Classify the refusal from SQLite's own column limit for the private
connection, carry the selected and engine counts through the worker reply,
and project it as a capacity resource constraint. The database stays
readable, no partial rows are returned, and the recovery names the column
counts and splitting the projection across calls.
* fix(hopper): bound bridge request frames like the Cutter bridge
The Hopper bridge read each request with an unbounded readline, so a peer
that never wrote a newline kept Hopper allocating for as long as it kept
writing. The Cutter bridge already refuses a frame above MAX_FRAME_BYTES;
the Hopper bridge had no request ceiling at all.
Read the request line with a 16 MiB bound and refuse a line that fills the
limit without a terminator, using the existing invalid_request diagnostic
before closing the connection. Such a line cannot be answered or
resynchronized, because its remainder would be read as the next request.
The response direction is unchanged, so large decompilation results still
flow, and every dispatchable method carries small bounded parameters.
The stalled-line case keeps its connection open instead of half-closing
it, so the test pins the exact ceiling: a bound one byte larger waits for
a byte the peer never sends and fails instead of passing quietly.
Validation: npm run check:fast (typecheck, lint) passed uncached, and the
new boundary test passed against the real bridge over socket pairs at the
frame limit and above it. Removing only the read bound makes the same test
fail, and widening the bound to one byte past the limit fails it too. The
Hopper boundary and bridge-packaging suites report the same 17 failing
files and 126 failing tests before and after this change on this Windows
host; those failures are POSIX-only probes (Unix socket directories,
os.statvfs) unrelated to this diff. Native macOS Hopper and real-client
traffic were not run.
* fix(bridges): exclude line delimiter from payload limit
* test(hopper): cover partial request at EOF
---------
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Route the caller-selected local path fields in load_frida_script,
instrument_with_frida, start_frida_session, start_rizin_debug_session,
and start_gdb_session through the shared localPathStringSchema so a NUL
byte is refused at the advertised contract boundary instead of reaching
the filesystem or a provider subprocess and surfacing as a raw error.
Extend the OS-invalid-local-input acceptance harness to the tools that
were missing from it.
The Java version probe captured its output with maxBuffer set to Infinity, so a
chatty or hostile JVM selected as the Ghidra launcher could grow the captured
stdout/stderr without limit and exhaust REA's memory during installation
discovery. Bound the capture to 1 MiB, matching the budget already used for
other Java listings. A probe that exceeds the budget yields no observation and
is reported through the existing failed-probe path, the same one the Java check
already uses whenever the probe cannot produce an observation.
Co-authored-by: Claude Code <noreply@anthropic.com>
* fix(artifacts): bound archive decompression by source size
A ZIP/APK/IPA/MSIX member is attacker-controlled and can expand far
beyond the archive that carries it. Inventory hashed every member to EOF
and extraction wrote every member with no ceiling, so a ~2 MiB archive
could drive ~30 s of CPU during inventory and ~150 s plus 2 GiB of disk
writes during extraction.
A fixed per-entry byte cap is the wrong shape: it rejects legitimately
large members inside large packages. The ceiling instead scales with the
archive's own size (500x, with a 256 MiB floor), so large packages keep a
proportionally large budget while a small archive cannot amplify into
unbounded work. The cumulative declared size is charged from the central
directory before any entry is decompressed, and zip.js already verifies
streamed output against each declaration, so the check is exact and
covers both inventory and extraction through the shared reader.
* fix(artifacts): own decompression accounting per traversal
* test(artifacts): verify declared ZIP output enforcement
---------
Co-authored-by: baron <richstyles@gmail.com>
pull_adb_file derived its host file name from the last path segment of a
caller-supplied device path and joined it to the output directory without
checking that the name is usable on the host. A device path ending in a
separator, a dot segment, a control character, a Windows reserved device
name, a trailing dot or space, or a ':' alternate-data-stream marker could
resolve to an unexpected host path or fail opaquely after the transfer.
The single-file path now derives the name with POSIX semantics and refuses
any non-portable segment as invalid_request before creating a directory or
running adb pull. This matches the package path's pending host-name policy
and never renames a device-reported file.
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Unblob reports a missing external extractor as an
ExtractorDependencyNotFoundReport nested inside a chunk or multi-file
report, and normalizeUnblobReport forwarded that value verbatim into
diagnostics as { input_path, report }. A caller that reached partial
coverage therefore had to decode the raw producer report to learn which
extractor was absent and at which stage, even though the report already
names the dependencies.
Extraction diagnostics now normalize those entries: the named
dependencies are collected from the report and any nested value, the
producing handler is recorded as the stage, and the original report stays
retained for provenance. Entries without a dependency report keep their
previous shape.
Validation: FirmwareReports unit cases cover the chunk-nested and
multi-file-nested dependency reports and the retained raw report;
firmware suite passes; typecheck, oxlint and oxfmt on changed files. Real
Unblob extraction is unverified here because it needs the external
extractor stack.
Evidence subject local_path and analysis-snapshot subject path accepted
any string, so a caller-supplied inline Evidence record could carry a NUL
byte into the observation and bundle output even though every other
selected-path boundary already refuses it. Both fields now use the shared
NUL-free local-path schema: the snapshot path keeps its nonempty rule,
and the Evidence local_path stays free to be empty because network
capture Evidence uses an empty path as a deliberate sentinel.
localPath.ts now exports the NUL-free base schema separately so the
nonempty rule is composed rather than duplicated; the advertised pattern
and minLength for existing localPathStringSchema consumers are unchanged.
Validation: new unit cases reject a NUL local_path and snapshot path while
admitting an empty Evidence path and an ordinary path; domain suite
126 files / 1704 tests pass, evidence bundle and retained-evidence MCP
boundaries pass, localToolContracts NUL harness passes, toolSchemaValidity
passes with a regenerated catalog, typecheck, oxlint, oxfmt.
Reject positively identified foreign-only Ghidra decompiler binaries and non-regular native prerequisites before provider startup, while retaining unknown classifications for incomplete or unrecognized headers.
Verified with 54 focused tests and real Linux Ghidra CLI/MCP admission, decompilation, cleanup, wrong-host PE rejection, and non-blocking FIFO rejection. All 19 final-head checks passed.
Fixes#1192
Co-authored-by: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com>
* fix(domain): bound JSON depth and oversized payloads at parsing boundaries
jsonValueSchema re-walked hostile input through z.json()'s recursive core, so a deeply nested payload from an MCP tool argument, inline evidence, provider result, or keyed archive threw RangeError: Maximum call stack size exceeded instead of a validation issue. The schema now runs an iterative depth guard first and keeps z.json() for full JSON semantics behind it, which also turns cyclic values into validation errors. The 1000-edge limit stays far below the range where the recursive core overflows its stack (measured between 2000 and 3000 on Node 24) while leaving room for legitimately deep target-derived trees such as compiled NIB view hierarchies, and the guard narrows with a typed record check instead of any or unchecked casts. The non-recursive JSON Schema advertisement stays intact on both the public and standard projection paths. Two payload sizes get explicit budgets tied to their producer constraints: web text artifacts reject beyond the 16 MiB source budget the web tooling already advertises, and native UI screenshot base64 beyond the 64 MiB helper output budget. Regenerated product catalog, skill catalog digest, and completion-ledger manifest follow the schema-boundary change.
* fix(validation): preserve depth diagnostics across process inputs
---------
Co-authored-by: N0zoM1z0 <1705004398@qq.com>
fix(contracts): require absolute local paths for session filesystem inputs
Snapshot, evidence-bundle export, and managed-target inputs accepted any non-empty string, so relative paths were resolved against the MCP server working directory instead of the caller's intent. Refine these contract fields to absolute host paths (Windows drive and forward-slash forms included), advertise the constraint on the property descriptions, and regenerate the derived catalog, product catalog, and skill catalog digest. Relative CLI options are unaffected because CLI flows do not parse these MCP contracts.
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
SafeOutputTree.create() called fchmod(0o700) on a directory handle and
commit() fsynced a directory handle. Both are POSIX-only: Windows rejects
them with EPERM ("operation not permitted, fchmod"/"fsync"), so every
extract_artifact call failed with a generic io error and produced no output.
Skip the redundant directory chmod and directory fsync on Windows. mkdir
already applies the requested mode, and file contents are synced in write().
Add a Windows regression test and run it, plus the archive-safety test, in
the curated Windows CI lane so the behavior is covered.
* fix(browser): classify CSP host sources by their own origin
A Content-Security-Policy host-source without a scheme (for example
`cdn.example.com` or `*.trusted.test`) was resolved with
`new URL(token, baseUrl)`, so it became a page-relative path and was
reported as the observed page's own origin. Third-party script, style, and
connect sources were therefore misreported as approved origins, and a bare
`*` wildcard collapsed to the page origin as well.
Resolve host-sources per CSP3 \u00a72.3.1: a scheme-less source inherits the
protected resource's scheme, wildcard host/port sources stay classified as a
set of origins, and non-host tokens remain `other`. Keyword and scheme
sources are unchanged.
Adds boundary coverage for scheme-less host sources, wildcards, and scheme
inheritance against the approved-origin set.
* fix(browser): validate CSP host source syntax
---------
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>