Compare commits

..
Author SHA1 Message Date
Cursor Agent 48247f1ba7 Merge branch 'cursor/setting-effect-field-01f4' into cursor/runtime-per-call-limits-01f4 2026-11-11 02:28:56 +00:00
Cursor Agent 6d9618b467 Merge branch 'cursor/rea-limit-inventory-01f4' into cursor/setting-effect-field-01f4 2026-11-11 02:28:56 +00:00
Cursor Agent 0e7a140007 merge: bring main into the limit inventory
Keep the file-level Ghidra bridge context assertion. Main removed the empty suite.
2026-11-11 02:28:55 +00:00
Cursor Agent c8a8c330ef Merge branch 'cursor/setting-effect-field-01f4' into cursor/runtime-per-call-limits-01f4 2026-11-11 02:26:29 +00:00
Cursor Agent 9935794a5a Merge branch 'cursor/rea-limit-inventory-01f4' into cursor/setting-effect-field-01f4 2026-11-11 02:26:28 +00:00
Cursor Agent 3ebd9292d0 test(mcp): give the catalog presentation child a 60s bound
A loaded shard killed the full-catalog child at its 20s limit.
2026-11-11 02:26:28 +00:00
Cursor Agent bb0cd98211 Merge branch 'cursor/setting-effect-field-01f4' into cursor/runtime-per-call-limits-01f4 2026-11-11 01:04:40 +00:00
Cursor Agent 58bd9a6f02 Merge branch 'cursor/rea-limit-inventory-01f4' into cursor/setting-effect-field-01f4 2026-11-11 01:04:39 +00:00
Cursor Agent 02d8fd878d test(ghidra): restore the bridge script read for the context contract
The main merge dropped the fs import that the file-level assertion uses.
2026-11-11 01:04:39 +00:00
Cursor Agent ffc73251d0 Merge branch 'cursor/setting-effect-field-01f4' into cursor/runtime-per-call-limits-01f4 2026-11-11 01:04:09 +00:00
Cursor Agent 7816142cd1 Merge branch 'cursor/rea-limit-inventory-01f4' into cursor/setting-effect-field-01f4 2026-11-11 01:04:04 +00:00
Cursor Agent b29909dbb8 merge: bring main into the limit inventory
Keep the Ghidra bridge context assertion on the file. Main's empty suite is the collection failure.
2026-11-11 01:03:59 +00:00
Cursor Agent cf8d8fd98b fix(jeb): keep the deadline limit when a timeout is reattributed
The provider copies the selected jeb.mcp_timeout_ms limit onto the operation timeout, so callers can read its source and update mode.
2026-11-11 01:03:45 +00:00
Cursor Agent 9b870088f9 test(jeb): assert the timeout limit source instead of a restart instruction
A launch-selected JEB deadline reports source setting and update_mode runtime. The recovery tells the caller to use update_analysis_settings.
2026-11-11 00:58:40 +00:00
Cursor Agent dc6f0c5d3e merge: note why the Ghidra bridge context contract is file-level 2026-11-11 00:54:40 +00:00
Cursor Agent fd9e91d11b merge: note why the Ghidra bridge context contract is file-level 2026-11-11 00:54:39 +00:00
Cursor Agent 6fedd53ebb test(ghidra): note why the bridge context contract is file-level 2026-11-11 00:54:17 +00:00
Cursor Agent 0687f638cc test(pwntools): expect parameter when the caller selects the input limit
A request-sourced pwntools input limit is a parameter update, so update_analysis_settings cannot change that call.
2026-11-11 00:18:29 +00:00
Cursor Agent 5d79a3c395 merge: keep the Ghidra bridge context contract as a file-level test 2026-11-11 00:18:01 +00:00
Cursor Agent 0e1389858e test(ghidra): keep the bridge context contract as a file-level test
A one-test suite named for the bridge script was collected with no tests in the full source run, which failed the file. The same assertion now sits directly on the file.
2026-11-11 00:18:01 +00:00
Cursor Agent 9857528dde merge: keep the Ghidra bridge context contract as a file-level test 2026-11-11 00:18:01 +00:00
Cursor Agent a3f35140f1 fix(settings): advertise every bridge operation and prefer accepted overrides
The bridge ceiling lists every Hopper and Ghidra bridge operation plus the Cutter calls, and a test fails when a registry entry is missing. ChangeTime observation and the other launch-seed parsers read an accepted override before rejecting an invalid value.
2026-11-10 23:48:21 +00:00
Cursor Agent efca09f28f merge: list parameter as an update_mode and extend limit discovery 2026-11-10 23:45:07 +00:00
Cursor Agent 6d58a5fbd4 docs(settings): list parameter as an update_mode
The changelog and the limit migration now name runtime, restart, and parameter. parameter is the mode for a value this call supplied.
2026-11-10 23:44:47 +00:00
Cursor Agent 944e203f2f merge: read destructured environment keys in numeric-limit discovery 2026-11-10 23:44:18 +00:00
Cursor Agent d957d00db4 fix(limits): read destructured environment keys and keep the pre-install guard dependency-free
Object bindings, in-checks, and object keys that a spread can copy are producers. The architecture guard skips the TypeScript parse until typescript6 is installed, and the inventory links stay inside the published site.
2026-11-10 23:44:12 +00:00
Cursor Agent bf4e7158b7 fix(settings): apply runtime overrides before provider limit validation
JEB, Kuna, Windows, debugger, and Cutter consult an accepted override before rejecting an invalid launch value. JEB and Hopper startup deadlines keep default, setting, or runtime as the limit source, and a Kuna timeout without its own limit uses the selected project deadline source.
2026-11-10 23:33:14 +00:00
Cursor Agent a1dc4c9a5f merge: classify caller-selected limits as parameter updates 2026-11-10 23:24:30 +00:00
Cursor Agent 70f91ac602 fix(settings): classify caller-selected limits as parameter updates
analysis_timeout_ms, max_heap_mb, and command_timeout_ms keep the source chosen by the caller, the runtime override, or the built-in default. A request value reports update_mode parameter because update_analysis_settings cannot change that call.
2026-11-10 23:24:09 +00:00
Cursor Agent f3dfe55eca merge: discover numeric limit reads from the syntax tree 2026-11-10 23:19:57 +00:00
Cursor Agent e603982ddd fix(limits): discover numeric limit reads from the syntax tree
Property access, element access, and helper-call arguments are producers. Comments and array literals are excluded by the tree, so a later argument such as read(environment, "REA_NEW_MAX_BYTES") stays classified.
2026-11-10 23:19:31 +00:00
Cursor Agent 27b939682f fix(settings): keep limit provenance through wrappers and Cutter inspection
Startup timeout wrapping keeps the debugger setting limit. Pwntools and Kuna failures report the selected source. Cutter session and command operations can inspect the bridge ceiling.
2026-11-10 23:12:54 +00:00
Cursor Agent a4a929e9ce merge: keep bracket-access producers in the per-call limit migration 2026-11-10 23:10:47 +00:00
Cursor Agent e4801e357a merge: keep bracket-access producers in numeric-limit discovery 2026-11-10 23:10:32 +00:00
Cursor Agent 8a7fbfdd21 fix(limits): keep bracket-access reads in numeric-limit discovery
A quoted name after an identifier, call, or optional chain is a producer. Only declaration arrays and comments stay out of discovery.
2026-11-10 23:10:30 +00:00
Cursor Agent d90eb820de merge: bring parameter update mode and limit-discovery rules into the per-call migration 2026-11-10 23:05:56 +00:00
Cursor Agent bb598a23cf fix(settings): mark per-call parameters and keep historical receipts
artifact_limits and web-network budgets report update_mode parameter because update_analysis_settings cannot change them. The legacy-name scan no longer reads docs, and the 2026-11-09 Ghidra receipts are restored to the fields they recorded.
2026-11-10 23:05:50 +00:00
Cursor Agent 659fb1c7e6 merge: bring allowlist discovery and tracked-limit rules into the setting fields 2026-11-10 23:04:22 +00:00
Cursor Agent 42f42c1ffc fix(limits): ignore allowlists and require tracked limits to be settings
Discovery no longer treats comments or registration-list entries as producers. The three limits moving on their own PRs must join ANALYSIS_SETTINGS; the exception registry cannot classify them.
2026-11-10 23:04:06 +00:00
Cursor Agent 8048fe8e62 fix(settings): enforce runtime caps on each GDB command and Cutter request
GDB reads the accepted command-output cap when it admits a command. Cutter uses that same accepted bridge ceiling for mismatch and request checks. Kuna project-timeout metadata includes the operations that wait on the shared export.
2026-11-10 23:00:35 +00:00
Cursor Agent 7fd318372f merge: bring update-mode errors and limit classification into the per-call migration 2026-11-10 22:58:35 +00:00
Cursor Agent 58b4b17fd3 fix(settings): keep Windows update mode on configuration errors
Invalid ChangeTime settings now carry update_mode and pending_restart through the configuration error and its projection, including startup parsing. Runtime recoveries name update_analysis_settings instead of telling the caller to restart REA.
2026-11-10 22:57:11 +00:00
Cursor Agent 3bc85783b9 merge: bring numeric-limit classification fixes into the setting fields 2026-11-10 22:54:28 +00:00
Cursor Agent 972d652483 fix(limits): classify endpoint candidates and ignore registry declarations
The numeric-limit check now treats REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES as a count limit and does not count registry text as a producer.
2026-11-10 22:53:54 +00:00
Cursor Agent 4fc8e35f67 merge: bring update_mode and pending_restart into the per-call limit migration 2026-11-10 22:44:39 +00:00
Cursor Agent f2b7c3bb92 feat(settings)!: replace effect with update_mode and pending_restart
Callers read update_mode to see whether update_analysis_settings can change a limit, and pending_restart only when a configured value is not active in this process.
2026-11-10 22:40:49 +00:00
Cursor Agent 51c104c827 feat(settings): read per-call REA_ limits from the operation snapshot
Part of #9881. Move the limits checked on each call into update_analysis_settings, keep their REA_ seeds and bounds, and reject updates outside the allowed range.
2026-11-10 22:27:22 +00:00
Cursor Agent f4f927c94e feat(settings)!: report when a limit change takes effect
Part of #9881. Add effect immediate or next_process and keep rea_restart_required, restart_required, and restartRequired as compatibility aliases.
2026-11-10 22:16:39 +00:00
Cursor Agent 11839a2ef8 ci(limits): inventory numeric REA_ limits and check them
Part of #9881. Record every numeric REA_ limit and fail static checks when one is neither a runtime analysis setting nor a documented exception with a reason.
2026-11-10 22:12:59 +00:00
159 changed files with 3575 additions and 2438 deletions

No files matched your search

@@ -108,11 +108,13 @@ is not a recommendation to change it. Do not invent a setting. Host memory and
- Capacity, when the result is `resource_constraint` and `retryable` is true:
wait for the active admission to finish, then retry within the reported
limits. Do not restart REA to free the slot.
- `configuration_invalid`: correct the named setting in the environment that
starts REA (the shell or the MCP registration), then restart REA. Run
`rea doctor` to see the `runtime-configuration:` check. Re-run `rea setup`
for that client when the registration must carry the new setting, then
reconnect.
- `configuration_invalid`: when the error reports `update_mode: "runtime"`,
call `update_analysis_settings` for that setting. When it reports
`update_mode: "parameter"`, change that call's argument; the setter does not
accept it. Otherwise correct the named setting in the environment that starts
REA (the shell or the MCP registration) and start REA again. Run `rea doctor`
to see the `runtime-configuration:` check. Re-run `rea setup` for that client
when the registration must carry the new setting, then reconnect.
- `invalid_request`: correct the reported fields from the current schema. Do
not repeat the unchanged call.
@@ -536,8 +536,7 @@ the framing LF still fits. Cutter measures, signs, and writes a fitting
response in bounded chunks. The bridge searches only the newly read bytes
for the framing LF. The client unescapes each socket chunk into one response
string before parsing it, so this ceiling does not keep the paged wire frame
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. Set the variable in the environment that starts REA, restart REA,
and start Hopper or Cutter from that same environment. An out-of-band bridge
and another concatenated copy. Invalid values are configuration errors and do not fall back to 16777216. `bridge.max_request_bytes` can be changed at runtime with `update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`. Start Hopper or Cutter from that same environment so the bridge keeps the ceiling it started with until that bridge starts again. An out-of-band bridge
without the setting stays at 16777216 bytes. The client reports that mismatch
during discovery only after the hello proves a live authenticated bridge, and
does not send a status request or the command. A stale descriptor is ignored.
+2
View File
@@ -1,5 +1,7 @@
# Changelog
The next release changes analysis-setting output (#9885). `update_analysis_settings`, `inspect_analysis_settings`, and limit errors no longer include `rea_restart_required`, `restart_required`, `restartRequired`, or `effect`. Read `update_mode` (`runtime`, `restart`, or `parameter`) and `pending_restart`. `runtime` means the setter can change the active value. `restart` means this process keeps the value it started with. `parameter` means this call supplied the value, so `update_analysis_settings` cannot change it; artifact reader limits, web-network capture budgets, and an explicit `analysis_timeout_ms`, `max_heap_mb`, or `command_timeout_ms` use it. There is no compatibility alias. Release Please should copy this migration into that release's breaking-changes section.
## [6.8.0](https://github.com/morluto/rea/compare/rea-agents-6.7.0...rea-agents-6.8.0) (2026-10-25)
### Highlights
+5
View File
@@ -358,6 +358,11 @@ export const documentationSections = [
source: "design/limits-and-recovery.md",
route: "/design/limits-and-recovery",
},
{
text: "REA limit inventory",
source: "design/rea-limit-inventory.md",
route: "/design/rea-limit-inventory",
},
{
text: "MCP tool design",
source: "development/tool-design.md",
+1
View File
@@ -111,6 +111,7 @@ product pages. The VitePress documentation source is this `docs/` tree.
## Development and verification
- [Settings and recovery](design/limits-and-recovery.md)
- [REA limit inventory](design/rea-limit-inventory.md)
- [Flutter budget calibration](verification/flutter-budget-calibration.md)
- [Source-map budget calibration](verification/source-map-budget-calibration.md)
- [Source-location delivery qualification](verification/source-location-delivery.md)
+35 -13
View File
@@ -5,7 +5,9 @@ who owns each value, when an update takes effect, how providers enforce it, and
how to extend the system. The public tool contract is in
[MCP contracts](../reference/mcp-contracts.md#editable-analysis-settings); recorded runtime
checks and their limitations are in the
[verification report](../verification/runtime-analysis-settings.md).
[verification report](../verification/runtime-analysis-settings.md). The
[limit inventory](rea-limit-inventory.md) lists every numeric `REA_` limit, which
of them are runtime settings, and which stay restart-only.
## Problem and design boundary
@@ -129,12 +131,13 @@ Windows ChangeTime admission waits out a fresh timestamp before recording a
baseline. `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` (default 2000 ms, floor 1 ms,
maximum 60000 ms), `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` (default 25 ms,
floor 1 ms, maximum 1000 ms), and `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS`
(default 40, floor 1, maximum 1000) are launch settings. Unset keeps those
defaults. An invalid value is a configuration error: it names the setting, the
rejected value, source `setting`, the default, the floor, and the maximum, and
says to set a positive decimal integer in the environment that starts REA and
then restart REA. There is no runtime override. Setup forwards an explicit
selection. When the poll budget runs out, admission keeps the last observation
(default 40, floor 1, maximum 1000) are runtime settings. Unset keeps those
defaults. An invalid launch value is a configuration error: it names the
setting, the rejected value, source `setting`, the default, the floor, the
maximum, `update_mode: "runtime"`, and `pending_restart: true`. Change the
active value with `update_analysis_settings`. The launch seed must be a
positive decimal integer in that range. Setup forwards an explicit selection.
When the poll budget runs out, admission keeps the last observation
instead of failing the readable file. Raising a setting only changes how long
that wait can last. It does not change Windows ChangeTime itself.
@@ -255,8 +258,8 @@ are runtime-wide, not scoped to a target or an individual connection.
### Failure and retry
An ASAR admission error supplies `details.limit` with the exact setting,
effective value, units, bounds, scope, application point, restart requirement
and, when known, required bytes and value source. The agent can immediately call:
effective value, units, bounds, scope, application point, `update_mode`,
`pending_restart` and, when known, required bytes and value source. The agent can immediately call:
```json
{
@@ -273,13 +276,27 @@ that argument; raising a runtime default cannot override it.
The resource owner constructs `details.limit` from the registry plus the budget
it actually enforced. Metadata includes `agent_editable: true`,
`scope: "rea-runtime"`, and `rea_restart_required: false`. Optional
`scope: "rea-runtime"`, `update_mode`, and `pending_restart`. `update_mode`
is `runtime` when `update_analysis_settings` can change the active value,
`restart` when this process keeps the value it started with, and `parameter`
when this call supplied the value. An explicit `analysis_timeout_ms`,
`max_heap_mb`, or `command_timeout_ms`, plus artifact reader limits and
web-network capture budgets, use `parameter`. `pending_restart` is false when
that reported value is the one this process is using. Optional
`required_value`, `source`, and `parameter` fields are supplied only where known
or applicable. Do not infer a source because a value happens to equal a default.
Error copying, attribution, redaction, and delivery must preserve these facts.
Human-readable remediation explains the same failure; clients should not need
to parse that sentence to identify an editable setting.
This output is breaking. Results and errors no longer carry the previous
restart boolean or a single effect field, and there is no compatibility alias.
Read `update_mode` to see whether the setter can change the active value, and
`pending_restart` to see whether a configured value is waiting. A configuration
error for a Windows ChangeTime setting carries those two fields on
`details.settings`, not only in the constraint sentence. The removed field names
are listed in the changelog.
`scope` and `applies_at` describe a continuing caller, such as an MCP
connection. A one-shot invocation, such as a CLI command, has released its
provider session and exited its runtime before its caller reads the failure,
@@ -288,8 +305,10 @@ so its adapter projects with an explicit `"invocation"` caller lifetime.
`scope: "rea-invocation"`, `applies_at: "next_invocation"` and the launch
`environment` seed when one exists. `agent_editable: true` still holds, because
the caller chooses that environment value or per-call `parameter` for the new
invocation; `rea_restart_required: false` means no running REA process needs a
restart. A producer keeps guidance that holds only while its caller retains the
invocation; `update_mode: "runtime"` and `pending_restart: false` mean the
reported value is active in this process. `update_mode: "restart"` means
`update_analysis_settings` does not change it. `update_mode: "parameter"` means
this call supplied the value. A producer keeps guidance that holds only while its caller retains the
session, such as "the Ghidra session remains usable" or a same-session runtime
update, in `continuingCallerGuidance`; the shared presentation adds it only for
a continuing caller. The producer's own message and remediation must stay true
@@ -499,7 +518,10 @@ own `expanded_bytes` controls its independently enforced bundle expansion.
Failures preserve `details.reader_limit` with the exact parameter, effective
value, units, supported range, required value when known, next-call scope and
`can_increase`. When `can_increase` is true, raise that field and retry the same
`can_increase`. `update_mode` is `parameter`: the caller selects the value on
the next call, and `update_analysis_settings` does not accept it.
`pending_restart` is false. Web-network capture budgets use the same mode.
When `can_increase` is true, raise that field and retry the same
complete artifact. At the supported maximum, or when the required representation
exceeds it, the failure is not retryable by increasing that budget; inspect the
declared dimensions and input validity, or explicitly choose a suitable reader.
+230
View File
@@ -0,0 +1,230 @@
# REA limit inventory
Audit for [#9881](https://github.com/morluto/rea/issues/9881). Counts below are
from this revision of `src`. A numeric `REA_` limit is a name under `src` (tests
excluded) that carries a byte, time, count, heap, budget, deadline, or maximum.
`scripts/verify/rea-numeric-limits.mjs` fails the static checks when one of those
names is neither an environment seed of
[`ANALYSIS_SETTINGS`](https://github.com/morluto/rea/blob/main/src/domain/analysisSettingDefinitions.ts) nor an
entry in [`numericLimitRegistry.ts`](https://github.com/morluto/rea/blob/main/src/domain/numericLimitRegistry.ts)
with a reason.
`update_mode` is `runtime` when `update_analysis_settings` can change the active
value, `restart` when this process keeps the value it started with, and
`parameter` when this call supplied the value.
`pending_restart` is true only when a configured value is not the one this
process is using. A rejected Windows ChangeTime launch value is not active, so
that failure reports `pending_restart: true`. Every other value reported here
is the one in force, so `pending_restart` is false. Prose that only says to
restart REA is marked `prose`. `mcp_server_restart_required` is a
server-identity alignment state in `src/serverIdentity.ts`, not a setting field.
Three numeric limits are absent from this tree and are being moved into the
runtime system on their own PRs. This audit does not change them:
| Name | Tracking |
| -------------------------------------------- | -------- |
| `REA_NTFS_MFT_MAX_FILE_BYTES` | #9868 |
| `REA_REGF_HIVE_MAX_FILE_BYTES` | #9865 |
| `REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES` | #9836 |
When those names land in `src`, the static check requires them to be analysis
settings. They are not on the restart-only list.
## Counts
| Category | Count |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------: |
| Runtime analysis settings | 28 (25 have an `REA_` seed; 3 are per-call parameters only) |
| Per-call numeric `REA_` limits still read from the launch environment | 0 |
| Restart-only numeric `REA_` limits | 15 |
| Numeric `REA_` names discovered in `src` | 40 |
| Non-numeric `REA_` settings kept restart-only | 53 caller settings, plus process-protocol names that are not caller settings |
| Hardcoded ceilings left for follow-up | grouped below; none converted in the inventory change |
## Runtime analysis settings
Already adjustable with `update_analysis_settings`. Each call reads the snapshot
accepted for that operation. Launch source is `default` or `setting`; a runtime
override is `source: "runtime"`. The structured field is `update_mode: "runtime"` and `pending_restart: false`.
`applies_at` is the finer point
(`next_operation`, `next_worker`, or `next_startup`).
| Setting | Environment seed | Owner | Default | Floor | Maximum | Read |
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------- | ---------------: | -------------------------------------: | --------------------- |
| `asar.max_header_bytes` | `REA_ASAR_MAX_HEADER_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 268435456 | per operation |
| `asar.max_listing_bytes` | `REA_ASAR_MAX_LISTING_BYTES` | `src/domain/analysisResourceLimits.ts` | 67108864 | 1 | `Number.MAX_SAFE_INTEGER` | per operation |
| `ghidra.value_flow_max_operations` | `REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS` | `src/domain/analysisResourceLimits.ts` | 3000 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_inputs_per_operation` | `REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION` | `src/domain/analysisResourceLimits.ts` | 64 | 1 | 2147483647 | per operation |
| `ghidra.value_flow_max_edges` | `REA_GHIDRA_VALUE_FLOW_MAX_EDGES` | `src/domain/analysisResourceLimits.ts` | 12000 | 1 | 2147483647 | per operation |
| `ghidra.decompile_max_payload_mib` | `REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB` | `src/domain/analysisResourceLimits.ts` | unset (`null`, unbounded payload policy) | 1 | 2047 | per operation |
| `ghidra.startup_timeout_ms` | `REA_GHIDRA_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 330000 | 1 | 2147483647 | next provider startup |
| `hopper.startup_timeout_ms` | `REA_HOPPER_STARTUP_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | next provider startup |
| `ghidra.decompile_timeout_seconds` | `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | `src/domain/analysisResourceLimits.ts` | unset (`null`, decompile stays unbounded) | 1 | `MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` | per operation |
| `jadx.operation_timeout_ms` | `REA_JADX_OPERATION_TIMEOUT_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | per operation |
| `regex.matching_deadline_ms` | `REA_REGEX_MATCHING_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 5000 | 1 | 2147483647 | per operation |
| `regex.worker_heap_request_mib` | `REA_REGEX_WORKER_HEAP_REQUEST_MIB` | `src/domain/analysisResourceLimits.ts` | 128 | 7 | 16384 | next worker |
| `capture.decoder_old_generation_mib` | `REA_CAPTURE_DECODER_OLD_GENERATION_MIB` | `src/domain/analysisResourceLimits.ts` | 192 | semi-space floor | 16384 | next worker |
| `javascript.analysis_timeout_ms` | none (`analysis_timeout_ms`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | 1 | 2147483647 | per operation |
| `javascript.max_heap_mb` | none (`max_heap_mb`) | `src/domain/javascript/javascriptAnalysisResourceControls.ts` | owner default | owner floor | owner maximum | next worker |
| `wasm.command_timeout_ms` | none (`command_timeout_ms`) | `src/domain/wasm/wasmArtifact.ts` | owner default | 1 | owner maximum | per operation |
| `native.matching_representation_bytes` | `REA_NATIVE_MATCHING_REPRESENTATION_BYTES` | `src/domain/analysisResourceLimits.ts` | 100663296 | 1 | 1073741824 | per operation |
An out-of-range `update_analysis_settings` value is rejected by the setting's
schema. Invalid launch seeds that the global environment parser accepts as
provider-local stay inspectable with `source: "invalid"`.
## Per-call limits in the runtime system
These are checked when the work runs. Each keeps its `REA_` seed, default,
floor, and maximum. The accepted operation snapshot supplies the value.
`update_mode` is `runtime` and `pending_restart` is false for the value in
force. An update outside the range is rejected. A hit names the setting and
reports the current value, source, and range. A rejected Windows ChangeTime
launch value is not active, so that failure reports `pending_restart: true`.
| Environment | Owner | Default | Floor | Maximum | Applies | Fields |
| ------------------------------------------ | ------------------------------------------- | ----------------------: | ----: | ---------: | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| `REA_BRIDGE_MAX_REQUEST_BYTES` | `src/config/bridgeMaxRequestBytes.ts` | 16777216 | 7464 | 536870887 | each bridge request, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_DEBUGGER_COMMAND_DEADLINE_MS` | `src/domain/analysisResourceLimits.ts` | 120000 | 1 | 2147483647 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES` | `src/domain/analysisResourceLimits.ts` | 16777216 | 1 | 89303718 | each command, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_HOPPER_CALLER_SCAN_BUDGET_MS` | `src/domain/analysisResourceLimits.ts` | 2000 | 1 | 2147483647 | each caller scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_JEB_MCP_TIMEOUT_MS` | `src/jeb/JebMcpConnection.ts` | 60000 (MCP SDK default) | 1 | 2147483647 | discovery and each tools/call, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_KUNA_PROJECT_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 1800000 | 1 | 2147483647 | each project export and whole-program scan, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_KUNA_TIMEOUT_MS` | `src/kuna/KunaConfiguration.ts` | 120000 | 1 | 2147483647 | each function decompile, from the accepted operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_PWNTOOLS_MAX_INPUT_BYTES` | `src/native/pwntools/PwntoolsInputLimit.ts` | 33554432 | 1 | 1073741824 | each pwntools decode, unless the call passes `producer_limits.input_bytes`; otherwise the operation snapshot | `update_mode: "runtime"`, `pending_restart: false` |
| `REA_WINDOWS_CHANGE_TIME_DRIFT_MS` | `src/config/windowsChangeTimeSettle.ts` | 2000 | 1 | 60000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch reports `pending_restart: true` |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS` | `src/config/windowsChangeTimeSettle.ts` | 25 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch reports `pending_restart: true` |
| `REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS` | `src/config/windowsChangeTimeSettle.ts` | 40 | 1 | 1000 | each ChangeTime observation, from the accepted operation snapshot | `update_mode: "runtime"`; invalid launch reports `pending_restart: true` |
## Restart-only numeric limits
These stay out of `update_analysis_settings`.
| Environment | Owner | Default | Floor | Maximum | Read | Why restart-only |
| ----------------------------------- | -------------------------------------- | ------------------: | -------: | ------------------------: | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `REA_DEBUGGER_MAX_PENDING_COMMANDS` | `src/domain/analysisResourceLimits.ts` | 16 | 1 | 256 | debugger manager construction | Queue capacity of the manager created at startup. `update_mode: "restart"` and `pending_restart: false`. |
| `REA_DEBUGGER_MAX_SESSIONS` | `src/domain/analysisResourceLimits.ts` | 8 | 1 | 64 | debugger manager construction | Session-pool size of the manager created at startup. `update_mode: "restart"` and `pending_restart: false`. |
| `REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS` | `src/domain/hopperStartupFailure.ts` | 10000 | 1 | 2147483647 | Linux private-display adapter start | The helper reads the process environment. Prose restart. |
| `REA_JADX_ACTIVE_PROCESSOR_COUNT` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | 2147483647 | JADX JVM launch | `-XX:ActiveProcessorCount` is fixed for that JVM. Prose on invalid values. |
| `REA_JADX_HEAP_MIB` | `src/android/JadxConfiguration.ts` | unset (JVM default) | 1 | `Number.MAX_SAFE_INTEGER` | JADX JVM launch | `-Xmx` is fixed for that JVM. Prose on invalid values. |
| `REA_KUNA_CACHE_MAX_BYTES` | `src/kuna/KunaConfiguration.ts` | 4294967296 | 1048576 | `Number.MAX_SAFE_INTEGER` | Kuna cache construction | The cache is sized once for the provider. Prose restart. |
| `REA_KUNA_JOBS` | `src/kuna/KunaConfiguration.ts` | 1 | 1 | 32 | Kuna run-slot construction | Worker-pool width for the provider. Prose restart. |
| `REA_MCP_DOCTOR_DEADLINE_MS` | `src/config/mcpStartupBudgets.ts` | 30000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_FIRST_CATALOG_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 20000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_INITIALIZE_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 15000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_MCP_MAX_INPUT_ELEMENTS` | `src/config/mcpInputBudget.ts` | 1024 | 1 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Argument admission for the server process. Prose restart. |
| `REA_MCP_MAX_RESPONSE_BYTES` | `src/config/mcpResponseBudget.ts` | 10485760 when unset | 10485760 | `Number.MAX_SAFE_INTEGER` | MCP server creation | Stdio receive buffer and the paired client buffer. Prose restart. |
| `REA_MCP_TOOLS_LIST_BUDGET_MS` | `src/config/mcpStartupBudgets.ts` | 5000 | 1 | 2147483647 | MCP server startup | Startup phase of the MCP server itself. Prose. |
| `REA_RECONNECT_TIMEOUT_SECONDS` | `src/hopper/BridgeLauncher.ts` | 30 | 30 | 30 | written into the Hopper bridge helper | Not a caller setting. It is `HOPPER_SHUTDOWN_TIMEOUT_MS / 1000`. |
| `REA_SWIFTC_COMPILE_TIMEOUT_MS` | `src/config/providerStartupTimeout.ts` | 120000 | 1 | 2147483647 | Darwin ownership-helper reader construction | Captured at process start for that helper compile. Prose restart. |
## Restart-only non-numeric settings
No floor or maximum. They are read from the launch environment (startup). None of them set `effect`. Recovery text tells the caller to change the launch
environment, which takes effect on the next process. They stay
restart-only because they choose a command, a binary, a filesystem root, the
browser sandbox, secret patterns, the provider, or the shape of the MCP server
or logger.
| Name | Owner | Why |
| ------------------------------------ | -------------------------------------------------- | ------------------------------------------- |
| `REA_ADB_PATH` | `src/adb/AdbConfiguration.ts` | Caller-selected executable path. |
| `REA_AGENT_ENV_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_ANALYSIS_PROVIDER` | `src/config/environment.ts` | Provider choice for the process. |
| `REA_APKTOOL_COMMAND` | `src/apktool/ApktoolConfiguration.ts` | Caller-selected command. |
| `REA_BINWALK_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_BROWSER_EXECUTABLE` | `src/application/CapabilityInventory.ts` | Caller-selected browser binary. |
| `REA_BROWSER_NO_SANDBOX` | `src/application/SetupRegistrationEnvironment.ts` | Browser sandbox policy. |
| `REA_CAPINFOS_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_CUTTER_BRIDGE_DIR` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem root for the Cutter bridge. |
| `REA_DARWIN_MODULE_CACHE_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Filesystem path for the Swift module cache. |
| `REA_DARWIN_SWIFTC_PATH` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected compiler path. |
| `REA_DCONF_COMMAND` | `src/desktop/DconfDatabaseReader.ts` | Caller-selected command. |
| `REA_DOCKER_COMMAND` | `src/application/DesktopBuildDoctor.ts` | Caller-selected command. |
| `REA_ELECTRON_CAPTURE_ARGV` | `src/browser/PlaywrightElectronActiveProvider.ts` | Caller-selected process arguments. |
| `REA_EVM_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_FIRMWARE_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_GDB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected debugger binary. |
| `REA_GHIDRA_CHECKPOINT_DIR` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_COMPILER_SPEC_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_DISABLED_ANALYZERS` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_LANGUAGE_ID` | `src/config/environment.ts` | Provider analysis choice. |
| `REA_GHIDRA_NATIVEAOT_JAR` | `src/config/environment.ts` | Caller-selected binary path. |
| `REA_GHIDRA_PROGRAM_NAME` | `src/config/environment.ts` | Provider project selection. |
| `REA_GHIDRA_PROJECT_PATH` | `src/config/environment.ts` | Filesystem root. |
| `REA_GHIDRA_SEED_FILE` | `src/config/environment.ts` | Filesystem path. |
| `REA_IDA_MCP_CONFIG` | `src/config/environment.ts` | Filesystem path of the IDA MCP config. |
| `REA_ILSPY_CMD_PATH` | `src/config/environment.ts` | Caller-selected executable path. |
| `REA_JADX_MCP_JAR` | `src/android/JadxConfiguration.ts` | Caller-selected binary path. |
| `REA_JAVASCRIPT_PRLIMIT_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_JEB_MCP_URL` | `src/config/environment.ts` | Provider endpoint. |
| `REA_KUNA_CACHE_DIR` | `src/kuna/KunaConfiguration.ts` | Filesystem root. |
| `REA_KUNA_COMMAND` | `src/kuna/KunaConfiguration.ts` | Caller-selected executable path. |
| `REA_LOG_LEVEL` | `src/config/environment.ts` | Logger configuration for the process. |
| `REA_MANAGED_LINUX_HOPPER_LAUNCHER` | `src/application/HopperLauncherDiscovery.ts` | Caller-selected launcher path. |
| `REA_MCP_INPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_OUTPUT_SCHEMA_PROFILE` | `src/config/environment.ts` | MCP server schema shape. |
| `REA_MCP_TOOL_CATALOG_PROFILE` | `src/config/environment.ts` | MCP server catalog shape. |
| `REA_MCP_TOOL_FAMILIES` | `src/config/environment.ts` | Which tools the MCP server registers. |
| `REA_MITMDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_OBJDUMP_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_PWNDBG_GDB` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected debugger binary. |
| `REA_PWNDBG_GDBINIT` | `src/native/pwndbg/PwndbgCoreContext.ts` | Caller-selected file path. |
| `REA_PWNDBG_VENV_PATH` | `src/native/pwndbg/PwndbgCoreContext.ts` | Filesystem root. |
| `REA_PWNTOOLS_PYTHON` | `src/native/pwntools/PwntoolsDecoder.ts` | Caller-selected interpreter path. |
| `REA_REFERENCE_SECRET_PATTERNS_JSON` | `src/config/environment.ts` | Secret-pattern policy. |
| `REA_RIZIN_COMMAND` | `src/rizin/RizinDebugSessionManager.ts` | Caller-selected debugger binary. |
| `REA_SIGROK_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_SIGROK_PRLIMIT_COMMAND` | `src/signals/SigrokRecordingProvider.ts` | Caller-selected command. |
| `REA_TSHARK_COMMAND` | `src/pcap/PcapConfiguration.ts` | Caller-selected command. |
| `REA_TSHARK_PRLIMIT_COMMAND` | `src/pcap/PcapNativeBound.ts` | Caller-selected command. |
| `REA_UNBLOB_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_WABT_BIN_DIRECTORY` | `src/wasm/WabtArtifactProvider.ts` | Filesystem root of the WABT tools. |
| `REA_WAKARU_COMMAND` | `src/application/SetupRegistrationEnvironment.ts` | Caller-selected command. |
| `REA_WORKFLOW_PROVIDER` | `src/application/DirectAnalysis.ts` | Provider choice. |
| `REA_XPROP_COMMAND` | `src/application/desktop/DesktopCaptureFailure.ts` | Caller-selected command. |
Names that look like `REA_` but are process protocol, not caller settings:
`REA_BUILD_COMMIT`, `REA_BUILD_COMPUTER_REA_ARCHIVE`, `REA_INSTALL_SCRIPT`,
`REA_OWNS_PROCESS_LIFETIME`, `REA_PAYLOAD`, `REA_PROCESS_RUN_ID`, `REA_RUN_ID`,
`REA_SOCKET`, `REA_STATE_FORMAT`, `REA_TARGET_PATH`, `REA_TOKEN`, bridge markers
such as `REA_COMMAND_COMPLETE_`, and the `ERR_REA_WINDOWS_NATIVE` code.
`GHIDRA_INSTALL_DIR`, `JAVA_HOME`, `HOPPER_LAUNCHER_PATH`, `HOPPER_TARGET_PATH`,
`HOPPER_TARGET_KIND`, and `HOPPER_LOADER_ARGS_JSON` are launch configuration
without the `REA_` prefix. Paths and the provider choice among them stay
restart-only for the same reason as the table above.
## Hardcoded ceilings left for follow-up
These are checked per call and tell the caller this release has no runtime
override. They are not `REA_` settings, so the numeric-limit check does not
list them. Each family has its own owner. Turning one into a setting needs that
owner's default, floor, maximum, and an error that carries the setting id,
value, source, and range. Format and protocol ceilings that the
[limits guide](limits-and-recovery.md) already calls fixed stay fixed. In
particular, JSON nesting stops at `MAX_JSON_DEPTH` (1000) and the debugger
control-reply budget stays separate from
`REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`.
| Family | Examples | Follow-up |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| Native structural input ceilings | PE resources, imports, load config, certificates, exports, initializers, binary hardening, Windows shortcuts | One setting per ceiling, or a shared input budget only where the owners match. |
| Native plist | `MAX_NATIVE_PLIST_BYTES`, decode bytes, nesting depth | Separate from JSON depth. |
| SQLite inspection | concurrent inspections, input bytes, diagnostic bytes | Pool width stays restart-only if it sizes the worker set; byte budgets can become settings. |
| Producer command allowances | fixed command deadline and retained output where the text says there is no runtime override | Distinct from per-call `producer_limits`. |
| Hopper caller-scan retention and page allowances | `HOPPER_CALLER_SCAN_RETENTION_BYTES`, `HOPPER_CALLER_PAGE_ALLOWANCE_BYTES` | Explicitly not raised by the scan time budget. |
| Rizin frame protocol | `MAX_FRAME_BYTES`, queued frames | Protocol bounds, not caller budgets. |
| Debugger detach and control replies | `DETACH_TIMEOUT_MS`, `DEBUGGER_CONTROL_REPLY_BYTES` | Ownership deadlines, not the shared command deadline. |
| Web network capture nesting | `max_nesting_depth` already has a per-call parameter | The fixed depth maximum is the follow-up, not the parameter. |
## Static check
`npm run lint` runs `scripts/verify/rea-numeric-limits.mjs` after
dependencies are installed. `scripts/verify/architecture-guards.mjs` runs the
same check when `typescript6` is already installed; the pre-install CI step
cannot. The check scans `src` excluding tests,
keeps names that are numeric limits, and requires each name to be an
`environment` on an analysis setting or a registry entry with a reason.
The registry accepts only `restart-only`. A limit checked on each call has to
be an analysis setting. The check rejects a `per-call` registry entry.
+4 -2
View File
@@ -196,5 +196,7 @@ The lane compiles a small source-owned fixture with the host C compiler and veri
Failed runs also report process-lineage evidence and exit unsuccessfully. If provider or transport cleanup is unconfirmed, the report says `cleanup: "incomplete"` and records `retained_workspace`; that directory is preserved for investigation and cleanup retries.
Doctor reports an invalid Kuna cache, job count, or timeout setting with that
setting's correction. Correct or unset the named setting and restart REA; these
configuration rejections do not require replacing the selected Kuna executable.
setting's correction. Cache size and job count stay with the process that
started them. The two timeouts can be changed at runtime with
`update_analysis_settings`. These configuration rejections do not require
replacing the selected Kuna executable.
+2 -1
View File
@@ -65,7 +65,8 @@ escaping, before allocating the serialized request or sending it. The framing
newline is excluded. An oversized request returns `resource_constraint` with
the effective setting, its source, and the exact payload byte count.
Set the value in the environment that starts REA, then restart REA to apply it.
`bridge.max_request_bytes` can be changed at runtime with
`update_analysis_settings`. The launch seed is `REA_BRIDGE_MAX_REQUEST_BYTES`.
This setting is enforced by the REA client; the Java bridge has no corresponding
request ceiling.
+1 -1
View File
@@ -148,7 +148,7 @@
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries && node scripts/verify/rea-numeric-limits.mjs",
"lint:details": "oxlint && npm run verify:module-boundaries",
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
"knip": "knip --cache",
+21
View File
@@ -1,3 +1,5 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -193,4 +195,23 @@ if (failures.length > 0) {
console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`);
process.exit(1);
}
// CI runs this file before `npm ci`. The numeric-limit parser needs
// typescript6, so that check waits until dependencies exist. `npm run lint`
// runs it after install.
const typescript6 = new URL(
"../../node_modules/typescript6/package.json",
import.meta.url,
);
if (!existsSync(typescript6)) {
console.log(
"Numeric REA_ limit check waits until dependencies are installed.",
);
} else {
const numericLimits = spawnSync(
process.execPath,
[fileURLToPath(new URL("./rea-numeric-limits.mjs", import.meta.url))],
{ stdio: "inherit" },
);
if (numericLimits.status !== 0) process.exit(numericLimits.status ?? 1);
}
console.log(`Architecture guards passed across ${files.length} source files.`);
+23
View File
@@ -0,0 +1,23 @@
/** Relative path of the registry. Its text is a declaration, not a producer. */
export const NUMERIC_LIMIT_DECLARATION: "src/domain/numericLimitRegistry.ts";
/** Limits that must become analysis settings and cannot be registry exceptions. */
export const REQUIRED_RUNTIME_LIMITS: readonly [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
/** True for a numeric `REA_` limit, including the documented endpoint-candidate count. */
export function isNumericLimit(name: string): boolean;
/** Producer paths for numeric limits. Registry text, comments, and allowlists are omitted. */
export function discoverNumericLimitUses(
files: readonly { readonly path: string; readonly source: string }[],
): Map<string, string>;
/** Why a tracked limit is not an analysis setting, when it is present. */
export function requiredRuntimeLimitFailure(
name: string,
presence: { readonly inRuntime: boolean; readonly inRegistry: boolean },
): string | undefined;
+401
View File
@@ -0,0 +1,401 @@
import { readFile, readdir } from "node:fs/promises";
import { join, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import ts from "typescript6";
// Every numeric REA_ limit under src must be an analysis setting or a
// restart-only exception with a reason and an update_mode. A per-call limit
// belongs in ANALYSIS_SETTINGS. The three limits moving on #9868, #9865, and
// #9836 are not in this tree; once their names appear here they must join
// ANALYSIS_SETTINGS. REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES is numeric
// even though its name has no byte or time suffix.
const root = fileURLToPath(new URL("../../", import.meta.url));
const definitionsPath = join(root, "src/domain/analysisSettingDefinitions.ts");
const registryPath = join(root, "src/domain/numericLimitRegistry.ts");
const listFiles = async (dir) => {
const output = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
output.push(...(await listFiles(path)));
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".fixture.ts")
)
output.push(path);
}
return output;
};
/**
* Count-shaped limits whose names do not use a byte, time, or maximum suffix.
* `REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES` is the runtime setting on #9836.
*/
const DOCUMENTED_COUNT_LIMITS = new Set([
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
]);
/** Registry text is a declaration, not a producer that uses the limit. */
export const NUMERIC_LIMIT_DECLARATION = "src/domain/numericLimitRegistry.ts";
/**
* These limits are moving onto runtime analysis settings on their own PRs.
* A producer or a registry entry cannot satisfy that requirement.
*/
export const REQUIRED_RUNTIME_LIMITS = [
"REA_NTFS_MFT_MAX_FILE_BYTES",
"REA_REGF_HIVE_MAX_FILE_BYTES",
"REA_WEB_BUNDLE_ENDPOINT_LITERAL_CANDIDATES",
];
const unwrapExpression = (node) => {
let current = node;
while (
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isParenthesizedExpression(current) ||
ts.isTypeAssertionExpression(current) ||
ts.isNonNullExpression(current)
)
current = current.expression;
return current;
};
const literalText = (node) =>
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
? node.text
: undefined;
/** Same-file const strings and `setting` fields, so `environment[name]` still resolves. */
const stringBindings = (sourceFile) => {
const bindings = new Map();
const visit = (node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.initializer !== undefined
) {
const initializer = unwrapExpression(node.initializer);
const literal = literalText(initializer);
if (literal !== undefined) bindings.set(node.name.text, literal);
if (ts.isObjectLiteralExpression(initializer)) {
for (const property of initializer.properties) {
if (
!ts.isPropertyAssignment(property) ||
(!ts.isIdentifier(property.name) &&
!ts.isStringLiteral(property.name))
)
continue;
const value = literalText(unwrapExpression(property.initializer));
if (value !== undefined)
bindings.set(`${node.name.text}.${property.name.text}`, value);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return bindings;
};
const boundLimitName = (node, bindings) => {
const expression = unwrapExpression(node);
const literal = literalText(expression);
if (literal !== undefined) return literal;
if (ts.isIdentifier(expression)) return bindings.get(expression.text);
if (
ts.isPropertyAccessExpression(expression) &&
ts.isIdentifier(expression.expression) &&
(ts.isIdentifier(expression.name) || ts.isStringLiteral(expression.name))
)
return bindings.get(
`${expression.expression.text}.${expression.name.text}`,
);
return undefined;
};
export const isNumericLimit = (name) =>
DOCUMENTED_COUNT_LIMITS.has(name) ||
/(?:_BYTES|_MS|_MIB|_SECONDS|_POLLS|_JOBS|_ELEMENTS|_COUNT|_SESSIONS|_COMMANDS)$/u.test(
name,
) ||
/_MAX_/u.test(name) ||
/_(?:TIMEOUT|DEADLINE|BUDGET)(?:_|$)/u.test(name) ||
/_HEAP(?:_|$)/u.test(name);
const limitName = (text) =>
text !== undefined && /^REA_[A-Z0-9_]+$/u.test(text) && isNumericLimit(text)
? text
: undefined;
const remember = (discovered, path, name) => {
const limit = limitName(name);
if (limit === undefined || discovered.has(limit)) return;
discovered.set(limit, path);
};
/**
* Producers are syntax, not text. A read is a property or element access,
* including a const string used as the element, a string argument to a call,
* an object-binding key (rename and default included), an `in` check, or an
* object key that a spread can copy. Comments and array literals are not reads.
*/
export const discoverNumericLimitUses = (files) => {
const discovered = new Map();
for (const file of files) {
const relativePath = file.path.split(sep).join("/");
if (relativePath === NUMERIC_LIMIT_DECLARATION) continue;
const sourceFile = ts.createSourceFile(
relativePath,
file.source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const bindings = stringBindings(sourceFile);
const visit = (node) => {
if (
ts.isPropertyAccessExpression(node) ||
ts.isPropertyAccessChain?.(node)
)
remember(discovered, relativePath, node.name.text);
if (ts.isElementAccessExpression(node))
remember(
discovered,
relativePath,
boundLimitName(node.argumentExpression, bindings),
);
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
for (const argument of node.arguments ?? [])
remember(
discovered,
relativePath,
boundLimitName(argument, bindings),
);
}
if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.InKeyword
)
remember(discovered, relativePath, boundLimitName(node.left, bindings));
if (
ts.isBindingElement(node) &&
node.dotDotDotToken === undefined &&
ts.isObjectBindingPattern(node.parent)
) {
const key = node.propertyName ?? node.name;
if (
ts.isIdentifier(key) ||
ts.isStringLiteral(key) ||
ts.isNumericLiteral(key)
)
remember(discovered, relativePath, key.text);
}
if (
(ts.isPropertyAssignment(node) ||
ts.isShorthandPropertyAssignment(node)) &&
(ts.isIdentifier(node.name) ||
ts.isStringLiteral(node.name) ||
ts.isNumericLiteral(node.name))
)
remember(discovered, relativePath, node.name.text);
if (ts.isVariableDeclaration(node) && node.initializer !== undefined)
remember(
discovered,
relativePath,
literalText(unwrapExpression(node.initializer)),
);
if (ts.isPropertyAssignment(node))
remember(
discovered,
relativePath,
literalText(unwrapExpression(node.initializer)),
);
if (
ts.isTemplateExpression(node) ||
ts.isNoSubstitutionTemplateLiteral(node)
) {
const chunks = ts.isNoSubstitutionTemplateLiteral(node)
? [node.text]
: [
node.head.text,
...node.templateSpans.map((span) => span.literal.text),
];
for (const chunk of chunks)
for (const token of chunk.match(/REA_[A-Z0-9_]+/gu) ?? [])
remember(discovered, relativePath, token);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
return discovered;
};
/** Tracked limits join ANALYSIS_SETTINGS. The exception registry cannot classify them. */
export const requiredRuntimeLimitFailure = (
name,
{ inRuntime, inRegistry },
) => {
if (!REQUIRED_RUNTIME_LIMITS.includes(name)) return undefined;
if (inRuntime && !inRegistry) return undefined;
return `${name} must join ANALYSIS_SETTINGS; numericLimitRegistry cannot classify it`;
};
const namesIn = (source, pattern) =>
new Set([...source.matchAll(pattern)].map((match) => match[1]));
const direct =
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (direct) {
const failures = [];
const definitions = await readFile(definitionsPath, "utf8");
const registry = await readFile(registryPath, "utf8");
const runtime = namesIn(definitions, /environment:\s*"(REA_[A-Z0-9_]+)"/gu);
if (registry.includes('policy: "per-call"'))
failures.push(
"per-call limits belong in ANALYSIS_SETTINGS, not numericLimitRegistry.ts",
);
const declaredNames = [
...registry.matchAll(/name:\s*"(REA_[A-Z0-9_]+)"/gu),
].map((match) => match[1]);
const entries = [
...registry.matchAll(
/name:\s*"(REA_[A-Z0-9_]+)"\s*,\s*update_mode:\s*"(runtime|restart)"\s*,\s*policy:\s*"(restart-only)"\s*,\s*read:\s*"(startup)"\s*,\s*reason:\s*"((?:\\.|[^"\\])*)"/gu,
),
];
const listed = new Map();
for (const match of entries) {
const [, name, updateMode, policy, read, reason] = match;
if (listed.has(name)) failures.push(`${name} is listed twice`);
listed.set(name, { updateMode, policy, read, reason });
if (reason.trim().length < 40)
failures.push(`${name} needs a reason that states why`);
if (updateMode !== "restart")
failures.push(
`${name} is outside ANALYSIS_SETTINGS, so its update_mode must be restart`,
);
if (runtime.has(name))
failures.push(
`${name} is both a runtime setting and on the exception list`,
);
if (read !== "startup")
failures.push(`${name} is restart-only but is not read at startup`);
}
for (const name of declaredNames) {
if (!listed.has(name))
failures.push(`${name} is registered without update_mode`);
}
const settingBodies = [
...definitions.matchAll(/\n {2}"[^"]+": \{([\s\S]*?)\n {2}\},/gu),
];
if (settingBodies.length === 0)
failures.push("ANALYSIS_SETTINGS did not parse into setting bodies");
for (const [, body] of settingBodies) {
if (!/updateMode:\s*"(?:runtime|restart)"/u.test(body))
failures.push("an analysis setting is missing updateMode");
}
const discovered = discoverNumericLimitUses(
await Promise.all(
(await listFiles(join(root, "src"))).map(async (path) => ({
path: relative(root, path),
source: await readFile(path, "utf8"),
})),
),
);
for (const [name, file] of [...discovered].sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
)) {
if (!runtime.has(name) && !listed.has(name))
failures.push(
`${name} in ${file} is neither an analysis setting nor docs/design/rea-limit-inventory.md via src/domain/numericLimitRegistry.ts`,
);
}
for (const name of listed.keys()) {
if (!discovered.has(name))
failures.push(`${name} is registered but no longer appears in src`);
}
const present = new Set([...discovered.keys(), ...listed.keys(), ...runtime]);
for (const name of REQUIRED_RUNTIME_LIMITS) {
if (!present.has(name)) continue;
const failure = requiredRuntimeLimitFailure(name, {
inRuntime: runtime.has(name),
inRegistry: listed.has(name),
});
if (failure !== undefined) failures.push(failure);
}
const parameterOwners = [
"src/artifacts/ArtifactReaderLimits.ts",
"src/domain/artifactReaderLimits.ts",
"src/domain/webNetworkCapture.ts",
];
for (const relativePath of parameterOwners) {
const source = await readFile(join(root, relativePath), "utf8");
if (/update_mode:\s*"runtime"/u.test(source))
failures.push(
`${relativePath} is a per-call parameter, so update_mode cannot be runtime`,
);
if (!/update_mode:\s*"parameter"/u.test(source))
failures.push(`${relativePath} must report update_mode parameter`);
}
const forbidden =
/(?<![A-Za-z0-9_])(?:rea_restart_required|restartRequired|restart_required)(?![A-Za-z0-9_])|effect:\s*"(?:immediate|next_process)"|SettingChangeEffect/gu;
// Docs and verification receipts keep the field names they recorded.
// Source, tests, skills, and scripts cannot reintroduce those names.
const scanRoots = ["src", "tests", ".agents", "scripts"].map((dir) =>
join(root, dir),
);
const skipDir = new Set([
"node_modules",
"dist",
".turbo",
".cache",
"rea-numeric-limits.mjs",
]);
const listText = async (dir) => {
const output = [];
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return output;
}
for (const entry of entries) {
if (skipDir.has(entry.name)) continue;
const path = join(dir, entry.name);
if (entry.isDirectory()) output.push(...(await listText(path)));
else if (/\.(?:ts|tsx|mjs|js|md|json|py|yml|yaml)$/u.test(entry.name))
output.push(path);
}
return output;
};
for (const dir of scanRoots) {
for (const path of await listText(dir)) {
if (path.endsWith(`${sep}rea-numeric-limits.mjs`)) continue;
const source = await readFile(path, "utf8");
const hit = source.match(forbidden);
if (hit)
failures.push(
`${relative(root, path).split(sep).join("/")} still uses ${[...new Set(hit)].join(", ")}`,
);
}
}
if (failures.length > 0) {
console.error(`Numeric REA_ limit check failed:\n${failures.join("\n")}`);
process.exit(1);
}
console.log(
`Numeric REA_ limits: ${runtime.size} runtime, ${listed.size} restart-only, ${discovered.size} discovered.`,
);
}
+2 -1
View File
@@ -36,7 +36,8 @@ it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", ()
applies_at: "next_operation",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
});
expect(projected).toMatchObject({
+176 -2
View File
@@ -1,5 +1,16 @@
import { expect, it } from "vitest";
import { AnalysisSettings } from "./AnalysisSettings.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
applyWindowsChangeTimeSnapshot,
resolveWindowsChangeTimeSettle,
} from "../config/windowsChangeTimeSettle.js";
import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js";
import { resolveBridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import { resolveCaptureDecoderHeap } from "../config/captureDecoderHeap.js";
import { resolveRegexMatchingDeadline } from "../config/regexMatchingDeadline.js";
import { resolveRegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
runtimeAnalysisConfig,
runtimeAsarHeaderAdmission,
@@ -134,7 +145,13 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
store
.inspect({ provider: "ghidra", operation: "search_strings" })
.settings.map(({ setting }) => setting),
).toEqual(["ghidra.startup_timeout_ms"]);
).toEqual(["ghidra.startup_timeout_ms", "bridge.max_request_bytes"]);
for (const operation of ["list_cutter_sessions", "cutter_command"] as const)
expect(
store
.inspect({ provider: "cutter", operation })
.settings.map(({ setting }) => setting),
).toContain("bridge.max_request_bytes");
expect(
store
.inspect({ provider: "ida", operation: "search_strings" })
@@ -144,7 +161,23 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
store
.inspect({ operation: "inventory_artifact" })
.settings.map(({ setting }) => setting),
).toEqual(["asar.max_listing_bytes", "asar.max_header_bytes"]);
).toEqual([
"asar.max_listing_bytes",
"asar.max_header_bytes",
"windows.change_time_drift_ms",
"windows.change_time_settle_poll_ms",
"windows.change_time_settle_max_polls",
]);
for (const operation of [
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
] as const)
expect(
store
.inspect({ provider: "kuna", operation })
.settings.map(({ setting }) => setting),
).toContain("kuna.project_timeout_ms");
expect(
store.update({
changes: [
@@ -192,3 +225,144 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp
).toBe(true);
expect(config.ghidraDecompileTimeoutSeconds).toBe(7);
});
it("rejects an out-of-range per-call limit and reads an accepted value from the operation snapshot", () => {
const store = new AnalysisSettings({});
const rejected = store.update({
changes: [
{ setting: "windows.change_time_drift_ms", action: "set", value: 0 },
],
});
expect(rejected.ok).toBe(false);
if (!rejected.ok)
expect(projectAnalysisError(rejected.error)).toMatchObject({
code: "invalid_request",
});
expect(
store.update({
changes: [
{ setting: "bridge.max_request_bytes", action: "set", value: 1 },
],
}).ok,
).toBe(false);
expect(
store.update({
changes: [
{
setting: "windows.change_time_settle_max_polls",
action: "set",
value: 1001,
},
],
}).ok,
).toBe(false);
const accepted = store.update({
changes: [
{ setting: "windows.change_time_drift_ms", action: "set", value: 5000 },
{ setting: "pwntools.max_input_bytes", action: "set", value: 65536 },
{ setting: "bridge.max_request_bytes", action: "set", value: 33554432 },
],
});
expect(accepted.ok).toBe(true);
if (!accepted.ok) return;
expect(accepted.value).toMatchObject({
pending_restart: false,
});
const parsed = parseConfig({});
if (!parsed.ok) throw parsed.error;
const live = runtimeAnalysisConfig(parsed.value, store.read);
store.run(() => {
const windows = resolveWindowsChangeTimeSettle({});
if (!windows.ok) throw windows.error;
expect(applyWindowsChangeTimeSnapshot(windows.value)).toMatchObject({
driftMs: 5000,
driftSource: "runtime",
pollSource: "default",
});
expect(pwntoolsInputBytes({})).toMatchObject({
configured: true,
bytes: 65536,
source: "runtime",
});
expect(live.bridgeMaxRequestBytes).toMatchObject({
maximumBytes: 33554432,
source: "runtime",
});
expect(live.hopperStartupTimeoutSource).toBe("default");
});
expect(pwntoolsInputBytes({})).toMatchObject({
bytes: 32 * 1024 * 1024,
source: "default",
});
});
it("reads an accepted override before an invalid launch seed", () => {
const resolved = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"bridge.max_request_bytes": 8192,
"capture.decoder_old_generation_mib": 64,
"regex.matching_deadline_ms": 250,
"regex.worker_heap_request_mib": 128,
"asar.max_header_bytes": 8192,
"asar.max_listing_bytes": 4096,
},
},
() => ({
bridge: resolveBridgeMaxRequestBytes({
REA_BRIDGE_MAX_REQUEST_BYTES: "nope",
}),
capture: resolveCaptureDecoderHeap({
REA_CAPTURE_DECODER_OLD_GENERATION_MIB: "0",
}),
deadline: resolveRegexMatchingDeadline({
REA_REGEX_MATCHING_DEADLINE_MS: "nope",
}),
heap: resolveRegexWorkerHeapRequest({
REA_REGEX_WORKER_HEAP_REQUEST_MIB: "1",
}),
asar: runtimeAsarHeaderAdmission({
REA_ASAR_MAX_HEADER_BYTES: "nope",
REA_ASAR_MAX_LISTING_BYTES: "0",
}),
}),
);
expect(resolved.bridge).toMatchObject({
maximumBytes: 8192,
source: "runtime",
});
expect(resolved.capture).toMatchObject({
oldGenerationMiB: 64,
source: "runtime",
});
expect(resolved.deadline).toMatchObject({
deadlineMs: 250,
source: "runtime",
});
expect(resolved.heap).toMatchObject({ heapMiB: 128, source: "runtime" });
expect(resolved.asar).toMatchObject({
maximumBytes: 8192,
source: "runtime",
listing: { maximumBytes: 4096, source: "runtime" },
});
});
it("keeps the Hopper startup deadline source through a runtime override", () => {
const parsed = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: "45000" });
if (!parsed.ok) throw parsed.error;
const store = new AnalysisSettings({});
const live = runtimeAnalysisConfig(parsed.value, store.read);
expect(live.hopperStartupTimeoutSource).toBe("setting");
expect(
store.update({
changes: [
{ setting: "hopper.startup_timeout_ms", action: "set", value: 8000 },
],
}).ok,
).toBe(true);
store.run(() => {
expect(live.hopperStartupTimeoutMs).toBe(8000);
expect(live.hopperStartupTimeoutSource).toBe("runtime");
});
});
+7 -2
View File
@@ -1,3 +1,4 @@
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import { AsyncLocalStorage } from "node:async_hooks";
@@ -42,7 +43,9 @@ export class AnalysisSettings {
readonly read = (): AnalysisSettingsSnapshot =>
this.#accepted.getStore() ?? this.#current;
run<Value>(operation: () => Value): Value {
return this.#accepted.run(this.#current, operation);
return this.#accepted.run(this.#current, () =>
runWithAnalysisOperationSnapshot(this.#current, operation),
);
}
inspect(input: z.infer<typeof inspectAnalysisSettingsInputSchema> = {}) {
return {
@@ -134,7 +137,7 @@ export class AnalysisSettings {
};
}),
active_work_changed: false as const,
rea_restart_required: false as const,
pending_restart: false as const,
});
}
#describe(setting: AnalysisSettingId) {
@@ -156,6 +159,8 @@ export class AnalysisSettings {
minimum: definition.minimum,
maximum: definition.maximum,
applies_at: definition.appliesAt,
update_mode: definition.updateMode,
pending_restart: false as const,
provider: definition.provider,
operations: [...definition.operations],
...(definition.environment === undefined
@@ -1,6 +1,7 @@
import { discoverJavaScriptPreloadPaths } from "./JavaScriptPreloadDiscovery.js";
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
import type { AnalysisSettingValues } from "../../domain/analysisSettings.js";
import { selectedAnalysisSetting } from "../../domain/analysisSettingDefinitions.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import {
DEFAULT_ASAR_HEADER_ADMISSION,
@@ -181,18 +182,24 @@ export const reconstructJavaScriptArtifact = async (
? {}
: { fileNodes: input.max_semantic_nodes_per_file }),
});
const heap = selectedAnalysisSetting(
input.max_heap_mb,
overrides?.["javascript.max_heap_mb"],
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
);
const timeout = selectedAnalysisSetting(
input.analysis_timeout_ms,
overrides?.["javascript.analysis_timeout_ms"],
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
);
const sourceLimits = {
...(input.semantic_precision === undefined
? {}
: { semanticPrecision: input.semantic_precision }),
heapMb:
input.max_heap_mb ??
overrides?.["javascript.max_heap_mb"] ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
timeoutMs:
input.analysis_timeout_ms ??
overrides?.["javascript.analysis_timeout_ms"] ??
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
heapMb: heap.value,
heapSource: heap.source,
timeoutMs: timeout.value,
timeoutSource: timeout.source,
};
const preloads = await discoverJavaScriptPreloadPaths(
files,
@@ -10,7 +10,6 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
@@ -40,7 +39,6 @@ export const projectSemanticEvents = (
source: eventEmitterNode(context, operation),
target: event,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -50,7 +48,6 @@ export const projectSemanticEvents = (
source: event,
target: listener,
relation,
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
@@ -480,8 +480,10 @@ export const createJavaScriptSemanticGraphProjection = (
limit,
);
}
const remainingTreeBudget = Math.max(0, nodeCeiling - state.nodes.size);
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
@@ -491,14 +493,6 @@ export const createJavaScriptSemanticGraphProjection = (
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
else {
truncatedFiles += 1;
noteSemanticCeilingReached(
nodeCeilingsReached,
remainingTreeBudget,
fileNodeCeiling,
);
}
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
@@ -10,7 +10,6 @@ import {
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
@@ -136,7 +135,7 @@ export const projectSemanticRequests = (
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request, requestNodes);
else projectResponseConsumer(context, operation, request);
if (operation.resolution !== "complete")
addDataUnknown({
context,
@@ -225,31 +224,16 @@ const projectResponseConsumer = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
response: JavaScriptSemanticGraphNode,
requestNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const authority = builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
);
const evidence =
authority.evidence ??
inferredSemanticEvidenceAt(context.file, operation.location);
for (const requestId of operation.linkedRequestIds)
addSemanticGraphRelation(context.state, {
source: requestNodes.get(requestId),
target: response,
relation: "consumed-by",
...authority,
evidence,
properties: { method: operation.method },
});
addSemanticGraphRelation(context.state, {
source: response,
target: context.callSiteAt(operation.location),
relation: "consumed-by",
...authority,
evidence,
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
properties: { method: operation.method },
});
};
@@ -14,10 +14,7 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
@@ -50,7 +47,6 @@ export function* projectSemanticObjects(
...ends,
relation: OBJECT_RELATIONS[operation.kind],
resolution: resolved ? "resolved" : "candidate",
evidence: inferredSemanticEvidenceAt(context.file, operation.location),
});
if (!resolved)
addObjectUnknown(context, operation, target.slot ?? occurrence, target);
@@ -340,37 +340,3 @@ it("bounds reads within unmodeled nested operator operands", () => {
);
expect(trace.status).not.toBe("found");
});
it.each([1, 2])(
"reports unavailable-module node omission only when total capacity %s rejects it",
(totalNodes) => {
const projection = createJavaScriptSemanticGraphProjection({
totalNodes,
fileNodes: 10,
});
const retained = source("retained.js", "");
projection.projectFile(retained, analyzeJavaScriptSemantics(""));
projection.recordUnavailableFile(
source("unavailable.js"),
"Source unavailable",
null,
"incomplete-module",
);
const graph = projection.finish(digest, application);
expect(graph.nodes).toHaveLength(totalNodes);
expect(graph.unknowns).toHaveLength(12);
expect(graph.coverage).toMatchObject({
status: "partial",
truncated: totalNodes === 1,
omitted_nodes: null,
});
if (totalNodes === 1) {
expect(graph.coverage.limits).toContainEqual({
name: "max_semantic_nodes",
value: 1,
unit: "items",
});
expect(graph.limitations.join("\n")).toContain("max_semantic_nodes is 1");
} else expect(graph.coverage.limits).toEqual([]);
},
);
+2
View File
@@ -15,6 +15,8 @@ export type WasmArtifactExecution = Omit<AnalysisExecution, "subject"> & {
/** Effective operation budget, separate from caller-authored Evidence parameters. */
export interface WasmArtifactExecutionOptions extends ExecutionOptions {
readonly commandTimeoutMs?: number;
/** Selection owner for `commandTimeoutMs`. Absent when the caller did not record it. */
readonly commandTimeoutSource?: "default" | "runtime" | "request" | undefined;
}
/** Replaceable offline WASM inspection engine, independent of target execution and network clients. */
@@ -240,6 +240,7 @@ it("gives cancellation precedence over malformed raw input", async () => {
it("uses runtime defaults only for omitted budgets, preserving explicit input and reset", async () => {
const settings = new AnalysisSettings({});
const accepted: number[] = [];
const sources: (string | undefined)[] = [];
const authored: (number | undefined)[] = [];
const service = new WasmArtifactService(
{
@@ -248,6 +249,7 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
const value = wasmArtifactFixture();
value.tool_profile.timeout_ms = options!.commandTimeoutMs!;
accepted.push(options!.commandTimeoutMs!);
sources.push(options!.commandTimeoutSource);
authored.push(input.command_timeout_ms);
return ok(wasmArtifactExecution(value));
},
@@ -275,6 +277,7 @@ it("uses runtime defaults only for omitted budgets, preserving explicit input an
});
expect((await settings.run(() => service.inspect({ path }))).ok).toBe(true);
expect(accepted).toEqual([123000, 42000, 30000]);
expect(sources).toEqual(["runtime", "request", "default"]);
expect(authored).toEqual([undefined, 42000, undefined]);
});
+9 -6
View File
@@ -1,4 +1,5 @@
import type { AnalysisSettingsReader } from "../../domain/analysisSettings.js";
import { selectedAnalysisSetting } from "../../domain/analysisSettingDefinitions.js";
import { constants as bufferConstants } from "node:buffer";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
@@ -78,13 +79,15 @@ export class WasmArtifactService {
),
);
}
const timeout =
input.value.command_timeout_ms ??
this.settings?.().overrides["wasm.command_timeout_ms"] ??
DEFAULT_WASM_COMMAND_TIMEOUT_MS;
const timeout = selectedAnalysisSetting(
input.value.command_timeout_ms,
this.settings?.().overrides["wasm.command_timeout_ms"],
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
);
const inspected = await this.provider.inspect(input.value, {
...options,
commandTimeoutMs: timeout,
commandTimeoutMs: timeout.value,
commandTimeoutSource: timeout.source,
});
if (!inspected.ok) return inspected;
const execution = inspected.value;
@@ -108,7 +111,7 @@ export class WasmArtifactService {
!report.success ||
report.data.artifact.path !== input.value.path ||
execution.subject.path !== input.value.path ||
report.data.tool_profile.timeout_ms !== timeout ||
report.data.tool_profile.timeout_ms !== timeout.value ||
report.data.tool_profile.output_budget_bytes !==
wasmResourceLimits(input.value).outputBytes ||
!wasmArtifactMatchesSubject(report.data.artifact, execution.subject) ||
@@ -54,7 +54,8 @@ it("preserves adjustable reader admission facts and gives cleanup priority", ()
required_value: 2,
unit: "count",
applies_at: "next_operation",
rea_restart_required: false,
update_mode: "parameter",
pending_restart: false,
},
},
});
+2 -1
View File
@@ -40,7 +40,8 @@ export const artifactReaderLimitFailure = (
applies_at: "next_operation",
agent_editable: true,
can_increase: canIncrease,
rea_restart_required: false,
update_mode: "parameter" as const,
pending_restart: false as const,
},
});
};
+24
View File
@@ -2,6 +2,7 @@ import type { Stats } from "node:fs";
import { expect, it } from "vitest";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { settleChangeTimeBaseline } from "../filesystem/ChangeTimeBaseline.js";
const DRIFT_MS = 2_000;
@@ -229,3 +230,26 @@ it("throws the caller's typed cancellation rather than the raw reason", async ()
}),
).rejects.toBeInstanceOf(Cancelled);
});
it("uses a Windows settle override before rejecting an invalid launch seed", async () => {
const observed = await runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"windows.change_time_drift_ms": 50,
"windows.change_time_settle_poll_ms": 10,
"windows.change_time_settle_max_polls": 4,
},
},
() =>
settleChangeTimeBaseline(async () => statsAt(1_000), {
environment: {
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "nope",
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "0",
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1001",
},
platform: "linux",
}),
);
expect(observed.ctimeMs).toBe(1_000);
});
@@ -89,7 +89,8 @@ it.each(["max_input_bytes", "max_reply_bytes", "max_nesting_depth"] as const)(
parameter: control,
effective_value: selected,
source: "request",
rea_restart_required: false,
update_mode: "parameter",
pending_restart: false,
},
},
});
+2 -2
View File
@@ -569,8 +569,8 @@ const modules = {
},
}),
),
cutter: service(({ providerEnvironment }) =>
createCutterBridgeService(providerEnvironment),
cutter: service(({ providerEnvironment, settings }) =>
createCutterBridgeService(providerEnvironment, { read: settings }),
),
browserObservation: capability(
({ options }) => options.browserObservation,
+46 -24
View File
@@ -3,6 +3,7 @@ import { parseJebMcpTimeoutMs } from "../config/environment.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { err } from "../domain/result.js";
import type { JebMcpConnectionFactory } from "../jeb/JebMcpConnection.js";
import { JebProvider } from "../jeb/JebProvider.js";
@@ -10,7 +11,8 @@ import { JebProvider } from "../jeb/JebProvider.js";
/**
* Construct one JEB provider bound to the caller-selected MCP endpoint. An
* unusable endpoint makes only JEB unavailable; every other CLI command and
* MCP tool is composed alongside it.
* MCP tool is composed alongside it. An invalid launch timeout stays
* unavailable until the accepted operation supplies jeb.mcp_timeout_ms.
*/
export const createJebAnalysisProvider = (
environment: Readonly<Record<string, string | undefined>> = process.env,
@@ -19,29 +21,49 @@ export const createJebAnalysisProvider = (
const requestTimeout = parseJebMcpTimeoutMs(
environment.REA_JEB_MCP_TIMEOUT_MS,
);
if (!requestTimeout.ok) return unconfiguredJebProvider(requestTimeout.error);
try {
return new JebProvider({
environment,
...(requestTimeout.value === undefined
? {}
: {
requestTimeoutMs: requestTimeout.value,
discoveryTimeoutMs: requestTimeout.value,
}),
...(connectionFactory === undefined ? {} : { connectionFactory }),
});
} catch (cause: unknown) {
if (!(cause instanceof AnalysisInputError)) throw cause;
const constraint =
typeof cause.cause === "string" ? cause.cause : cause.message;
return unconfiguredJebProvider(
new ConfigurationError("Invalid REA environment configuration", {
cause,
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
}),
);
}
const build = (): JebAnalysisPort => {
try {
return new JebProvider({
environment,
...(requestTimeout.ok && requestTimeout.value !== undefined
? {
requestTimeoutMs: requestTimeout.value,
discoveryTimeoutMs: requestTimeout.value,
}
: {}),
...(connectionFactory === undefined ? {} : { connectionFactory }),
});
} catch (cause: unknown) {
if (!(cause instanceof AnalysisInputError)) throw cause;
const constraint =
typeof cause.cause === "string" ? cause.cause : cause.message;
return unconfiguredJebProvider(
new ConfigurationError("Invalid REA environment configuration", {
cause,
settings: [{ setting: "REA_JEB_MCP_URL", constraint }],
}),
);
}
};
if (requestTimeout.ok) return build();
const rejected = unconfiguredJebProvider(requestTimeout.error);
let provider: JebAnalysisPort | undefined;
const live = (): JebAnalysisPort => (provider ??= build());
const overrideActive = (): boolean =>
currentAnalysisOperationSnapshot()?.overrides["jeb.mcp_timeout_ms"] !==
undefined;
return {
inspectAvailability: (signal) =>
overrideActive()
? live().inspectAvailability(signal)
: rejected.inspectAvailability(signal),
close: () =>
provider === undefined ? Promise.resolve() : provider.close(),
execute: (request, options) =>
overrideActive()
? live().execute(request, options)
: rejected.execute(request, options),
};
};
const unconfiguredJebProvider = (
+11
View File
@@ -141,7 +141,18 @@ describe("runtime configuration", () => {
expect(empty.value.hopperTargetPath).toBeUndefined();
expect(empty.value.analysisProvider).toBe("auto");
expect(empty.value.hopperStartupTimeoutMs).toBe(120_000);
expect(empty.value.hopperStartupTimeoutSource).toBe("default");
}
const hopperTimeout = parseConfig({
REA_HOPPER_STARTUP_TIMEOUT_MS: "45000",
});
expect(hopperTimeout).toMatchObject({
ok: true,
value: {
hopperStartupTimeoutMs: 45_000,
hopperStartupTimeoutSource: "setting",
},
});
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
+23
View File
@@ -17,6 +17,17 @@ import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js";
import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js";
import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js";
import { parseNativeMatchingAllowance } from "./nativeMatchingAllowance.js";
import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js";
import {
debuggerCommandDeadlineMsSchema,
debuggerMaxCommandOutputBytesSchema,
} from "./debuggerSessionLimits.js";
import {
windowsChangeTimeDriftMsSchema,
windowsChangeTimeSettleMaxPollsSchema,
windowsChangeTimeSettlePollMsSchema,
} from "./windowsChangeTimeSettle.js";
import { pwntoolsInputBytesSchema } from "../native/pwntools/PwntoolsInputLimit.js";
const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
{
REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema,
@@ -33,6 +44,18 @@ const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema,
REA_CAPTURE_DECODER_OLD_GENERATION_MIB:
captureDecoderOldGenerationMiBSchema,
REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema,
REA_DEBUGGER_COMMAND_DEADLINE_MS: debuggerCommandDeadlineMsSchema,
REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: debuggerMaxCommandOutputBytesSchema,
REA_HOPPER_CALLER_SCAN_BUDGET_MS: providerStartupTimeoutSchema,
REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_KUNA_PROJECT_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_KUNA_TIMEOUT_MS: providerStartupTimeoutSchema,
REA_PWNTOOLS_MAX_INPUT_BYTES: pwntoolsInputBytesSchema,
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema,
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: windowsChangeTimeSettlePollMsSchema,
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS:
windowsChangeTimeSettleMaxPollsSchema,
};
export const launchAnalysisSetting = (
id: AnalysisSettingId,
+18
View File
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { analysisSettingDefinition } from "../domain/analysisSettingDefinitions.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { GHIDRA_OPERATIONS } from "../ghidra/GhidraProviderMetadata.js";
import { HOPPER_OPERATIONS } from "../hopper/HopperProviderMetadata.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import {
@@ -19,6 +22,21 @@ const symbolName = "\u0001".repeat(1024);
const session = "00000000-0000-4000-8000-000000000001";
const token = "a".repeat(64);
it("advertises the bridge ceiling for every bridge-backed operation", () => {
const advertised = new Set(
analysisSettingDefinition("bridge.max_request_bytes").operations,
);
const required = [
...HOPPER_OPERATIONS,
...GHIDRA_OPERATIONS,
"list_cutter_sessions",
"cutter_command",
];
expect(required.filter((operation) => !advertised.has(operation))).toEqual(
[],
);
});
it("keeps the 16 MiB ceiling when the setting is unset", () => {
expect(parseBridgeMaxRequestBytes(undefined)).toEqual({
ok: true,
+43 -18
View File
@@ -1,13 +1,24 @@
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
} from "../domain/analysisResourceLimits.js";
export const BRIDGE_MAX_REQUEST_BYTES_SETTING = "REA_BRIDGE_MAX_REQUEST_BYTES";
/** Request payload ceiling used when the setting is unset. Today's fixed constant. */
export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
export {
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
};
/**
* Smallest ceiling that still admits the largest bounded bridge request.
@@ -22,10 +33,7 @@ export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
* Comments, search text, and Cutter command text are unbounded; this ceiling
* is what limits them. The floor stays below the 16 MiB default so a caller
* can lower the ceiling.
*/
export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
/**
*
* Largest configurable request, in encoded payload bytes.
*
* The ceiling is the final JSON payload, not the raw command. Callers measure
@@ -39,15 +47,11 @@ export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
* then parses that string. A fitting response does not keep the paged wire
* frame, a second concatenated copy, and an outer JSON string.
*/
const NODE_MAX_STRING_LENGTH = 536_870_888;
const FRAMING_LF_CODE_UNITS = 1;
export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES =
NODE_MAX_STRING_LENGTH - FRAMING_LF_CODE_UNITS;
/** Effective bridge request ceiling and where its value came from. */
export interface BridgeMaxRequestBytes {
readonly maximumBytes: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
}
export const DEFAULT_BRIDGE_MAX_REQUEST: BridgeMaxRequestBytes = {
@@ -99,6 +103,9 @@ export const parseBridgeMaxRequestBytes = (
export const resolveBridgeMaxRequestBytes = (
environment: Readonly<Record<string, string | undefined>>,
): BridgeMaxRequestBytes => {
const override = currentAnalysisSettingOverride("bridge.max_request_bytes");
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
const parsed = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
@@ -107,16 +114,22 @@ export const resolveBridgeMaxRequestBytes = (
};
/** How to read and change the effective bridge request ceiling. */
export const bridgeRequestLimit = (limit: BridgeMaxRequestBytes) =>
analysisSettingLimit(
"bridge.max_request_bytes",
limit.maximumBytes,
limit.source,
);
export const bridgeMaxRequestBytesRecovery = (
limit: BridgeMaxRequestBytes,
peer: BridgePeer = "Hopper",
): string =>
[
`The effective ${BRIDGE_MAX_REQUEST_BYTES_SETTING} value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}).`,
`Set ${BRIDGE_MAX_REQUEST_BYTES_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}, then restart REA.`,
`The effective bridge.max_request_bytes value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}). It can be changed at runtime with update_analysis_settings. The launch seed is ${BRIDGE_MAX_REQUEST_BYTES_SETTING}.`,
peer === "Ghidra"
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies ${BRIDGE_MAX_REQUEST_BYTES_SETTING} to Ghidra requests.`
: "Restart Hopper or Cutter from that same environment so the bridge uses this ceiling.",
? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies bridge.max_request_bytes to Ghidra requests.`
: "The Hopper or Cutter bridge keeps the ceiling it started with until that bridge starts again.",
].join(" ");
const limitFacts = (
@@ -142,7 +155,11 @@ export const bridgeRequestOversizeError = (
"transport",
`${refusal} ${recovery}`,
{ ...limitFacts(limit), payload_bytes: payloadBytes },
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
@@ -163,7 +180,11 @@ export const bridgeRequestLimitMismatchError = (
bridge_max_request_bytes: bridgeBytes,
client_max_request_bytes: limit.maximumBytes,
},
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
@@ -177,6 +198,10 @@ export const bridgeResponseOversizeError = (
"transport",
`Cutter bridge response exceeded its framed transport limit of ${String(limit.maximumBytes)} bytes. ${recovery}`,
limitFacts(limit),
{ remediationAction: recovery, retryable: false },
{
remediationAction: recovery,
retryable: false,
limit: bridgeRequestLimit(limit),
},
);
};
+3 -3
View File
@@ -103,11 +103,10 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
/floor 8 MiB.*maximum 16384 MiB/iu,
);
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toMatch(/restart REA/iu);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toContain(
"capture.decoder_old_generation_mib",
);
expect(projected.remediation.action).toMatch(/without a REA restart/iu);
expect(projected.remediation.action).toMatch(
/removes inherited NODE_OPTIONS/iu,
);
@@ -125,7 +124,8 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
unit: "MiB",
applies_at: "next_worker",
agent_editable: true,
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
old_generation_heap_mib: 256,
+7 -1
View File
@@ -6,6 +6,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const CAPTURE_DECODER_OLD_GENERATION_SETTING =
@@ -60,6 +61,11 @@ export const parseCaptureDecoderHeap = (
export const resolveCaptureDecoderHeap = (
environment: Readonly<Record<string, string | undefined>>,
): CaptureDecoderHeap => {
const override = currentAnalysisSettingOverride(
"capture.decoder_old_generation_mib",
);
if (override !== undefined)
return { oldGenerationMiB: override, source: "runtime" };
const parsed = parseCaptureDecoderHeap(
environment[CAPTURE_DECODER_OLD_GENERATION_SETTING],
);
@@ -71,7 +77,7 @@ export const resolveCaptureDecoderHeap = (
export const captureDecoderHeapRecovery = (heap: CaptureDecoderHeap): string =>
[
`The effective ${CAPTURE_DECODER_OLD_GENERATION_SETTING} value is ${String(heap.oldGenerationMiB)} MiB (source ${heap.source}; default ${String(DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB, floor ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB, maximum ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB).`,
`Set ${CAPTURE_DECODER_OLD_GENERATION_SETTING} in the environment that starts REA to a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)}, then restart REA. In a running MCP server, use update_analysis_settings with setting capture.decoder_old_generation_mib and action set instead; the new value applies to new work without a REA restart.`,
`capture.decoder_old_generation_mib can be changed at runtime with update_analysis_settings. The launch seed is ${CAPTURE_DECODER_OLD_GENERATION_SETTING}, a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)}.`,
`On Node.js the decoder passes --max-old-space-size=${String(heap.oldGenerationMiB)} and removes inherited NODE_OPTIONS, so additional host memory and NODE_OPTIONS do not raise this heap. The ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB semi-space stays fixed.`,
"On Bun the V8 flags are ignored. Windows limits the decoder's committed memory to this value plus a 512 MiB reserve. Other Bun hosts enforce no decoder heap limit.",
].join(" ");
+34 -1
View File
@@ -15,6 +15,7 @@ import {
} from "../domain/analysisResourceLimits.js";
import { GdbSessionManager } from "../gdb/GdbSessionManager.js";
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { parseEnvironment } from "./environment.js";
import {
DEBUGGER_COMMAND_DEADLINE_SETTING,
@@ -146,6 +147,37 @@ it.each([
},
);
it("applies runtime debugger overrides before rejecting invalid launch values", () => {
const parsed = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"debugger.command_deadline_ms": 25,
"debugger.max_command_output_bytes": 4096,
},
},
() =>
parseDebuggerSessionLimits({
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "nope",
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]: "0",
}),
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.commandDeadline.source).toBe("default");
expect(parsed.value.maxCommandOutputBytes.source).toBe("default");
const sessions = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: { "debugger.command_deadline_ms": 25 },
},
() =>
parseDebuggerSessionLimits({
[DEBUGGER_MAX_SESSIONS_SETTING]: "nope",
}),
);
expect(sessions.ok).toBe(false);
});
it("names the session setting on a Rizin capacity refusal", async () => {
const manager = new RizinDebugSessionManager(
{ [DEBUGGER_MAX_SESSIONS_SETTING]: "1" },
@@ -171,7 +203,8 @@ it("names the session setting on a Rizin capacity refusal", async () => {
minimum: MINIMUM_DEBUGGER_MAX_SESSIONS,
maximum: MAXIMUM_DEBUGGER_MAX_SESSIONS,
unit: "sessions",
restart_required: true,
update_mode: "restart",
pending_restart: false,
max_sessions: 1,
active_or_reserved_sessions: 1,
},
+25 -8
View File
@@ -1,3 +1,5 @@
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import type { AnalysisSettingValues } from "../domain/analysisSettings.js";
import {
DEBUGGER_CONTROL_REPLY_BYTES,
DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
@@ -34,7 +36,7 @@ export const DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING =
export interface DebuggerAdmissionLimit {
readonly setting: string;
readonly value: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
readonly defaultValue: number;
readonly minimum: number;
readonly maximum: number;
@@ -148,6 +150,12 @@ export interface DebuggerSessionLimits {
readonly maxCommandOutputBytes: DebuggerAdmissionLimit;
}
const RUNTIME_DEBUGGER_OVERRIDES = {
[DEBUGGER_COMMAND_DEADLINE_SETTING]: "debugger.command_deadline_ms",
[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]:
"debugger.max_command_output_bytes",
} as const satisfies Partial<Record<string, keyof AnalysisSettingValues>>;
const parseOne = (
admission: DebuggerAdmissionLimit,
schema: ReturnType<typeof positiveDecimalIntegerSchema>,
@@ -156,13 +164,22 @@ const parseOne = (
): Result<DebuggerAdmissionLimit, ConfigurationError> => {
if (value === undefined) return ok(admission);
const parsed = schema.safeParse(value);
return parsed.success
? ok({ ...admission, value: parsed.data, source: "setting" })
: err(
new ConfigurationError(constraint, {
settings: [{ setting: admission.setting, constraint }],
}),
);
if (parsed.success)
return ok({ ...admission, value: parsed.data, source: "setting" });
const override =
RUNTIME_DEBUGGER_OVERRIDES[
admission.setting as keyof typeof RUNTIME_DEBUGGER_OVERRIDES
];
if (
override !== undefined &&
currentAnalysisOperationSnapshot()?.overrides[override] !== undefined
)
return ok(admission);
return err(
new ConfigurationError(constraint, {
settings: [{ setting: admission.setting, constraint }],
}),
);
};
/** Resolve every shared debugger cap from one environment. Invalid values are refused. */
+33 -5
View File
@@ -6,7 +6,10 @@ import { z } from "zod";
import { mcpInputBudgetSchema } from "./mcpInputBudget.js";
import { isAbsolute } from "node:path";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
ConfigurationError,
type ConfigurationSettingIssue,
} from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import { analysisProviderSelectorSchema } from "../domain/providerSelection.js";
import {
@@ -165,6 +168,31 @@ const environmentSchema = z.object({
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
});
/** Copy producer-owned update facts from a Zod issue without inventing them. */
const configurationSettingFromIssue = (issue: {
readonly path: readonly PropertyKey[];
readonly message: string;
readonly code: string;
readonly params?: Readonly<Record<string, unknown>> | undefined;
}): ConfigurationSettingIssue => {
const setting = String(issue.path[0] ?? "environment");
const constraint = issue.message;
if (issue.code !== "custom" || issue.params === undefined)
return { setting, constraint };
const updateMode = issue.params.update_mode;
const pendingRestart = issue.params.pending_restart;
return {
setting,
constraint,
...(updateMode === "runtime" || updateMode === "restart"
? { update_mode: updateMode }
: {}),
...(typeof pendingRestart === "boolean"
? { pending_restart: pendingRestart }
: {}),
};
};
/** Parse the JEB-only timeout at its isolated provider-composition boundary. */
export const parseJebMcpTimeoutMs = (
value: string | undefined,
@@ -229,10 +257,10 @@ export const parseEnvironmentValues = (
const parsed = environmentSchema.safeParse(literals);
if (!parsed.success) {
// Zod messages name the constraint, never the rejected value.
const settings = parsed.error.issues.map((issue) => ({
setting: String(issue.path[0] ?? "environment"),
constraint: issue.message,
}));
// A producer may attach update_mode and pending_restart on a custom issue.
const settings = parsed.error.issues.map((issue) =>
configurationSettingFromIssue(issue),
);
return err(
new ConfigurationError("Invalid REA environment configuration", {
cause: parsed.error,
+12 -18
View File
@@ -13,6 +13,7 @@ import {
import { parseConfig } from "./parseConfig.js";
import {
HOPPER_CALLER_SCAN_BUDGET_SETTING,
hopperCallerScanLimit,
hopperCallerScanRecovery,
} from "./hopperCallerScanBudget.js";
@@ -57,21 +58,16 @@ it("explains the time budget and that fixed byte allowances have no override", (
const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" });
if (!parsed.ok) throw parsed.error;
const budget = parsed.value.hopperCallerScanBudget;
const time = hopperCallerScanRecovery(
facts({ stop: { code: "work_budget", budget_ms: 250 } }),
budget,
);
expect(time).toContain(`${HOPPER_CALLER_SCAN_BUDGET_SETTING} is 250 ms`);
expect(time).toContain(
`The default is ${String(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS)} ms`,
);
expect(time).toContain(
`from ${String(MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS)} to ${String(MAX_NODE_TIMER_DELAY_MS)} ms`,
);
expect(time).toContain("no runtime override");
expect(time).toContain(
`${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`,
);
expect(hopperCallerScanLimit(budget)).toMatchObject({
setting: "hopper.caller_scan_budget_ms",
effective_value: 250,
source: "setting",
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
maximum: MAX_NODE_TIMER_DELAY_MS,
update_mode: "runtime",
pending_restart: false,
});
expect(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS).toBe(2000);
const page = hopperCallerScanRecovery(
facts({
stop: {
@@ -82,9 +78,7 @@ it("explains the time budget and that fixed byte allowances have no override", (
budget,
);
expect(page).toContain("no runtime override");
expect(page).toContain(
`${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`,
);
expect(page).toContain("hopper.caller_scan_budget_ms does not raise them");
expect(page).not.toContain("is 250 ms");
expect(
hopperCallerScanRecovery(facts({ stop: { code: "complete" } }), budget),
+15 -4
View File
@@ -1,3 +1,4 @@
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import {
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
MAX_NODE_TIMER_DELAY_MS,
@@ -16,7 +17,7 @@ export const HOPPER_CALLER_SCAN_BUDGET_SETTING =
export interface HopperCallerScanBudget {
readonly setting: typeof HOPPER_CALLER_SCAN_BUDGET_SETTING;
readonly milliseconds: number;
readonly source: "default" | "setting";
readonly source: "default" | "setting" | "runtime";
readonly defaultMilliseconds: number;
readonly floorMilliseconds: number;
readonly maximumMilliseconds: number;
@@ -52,7 +53,15 @@ export const hopperCallerScanBudgetForMilliseconds = (
milliseconds,
);
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. ${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them.`;
const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. hopper.caller_scan_budget_ms does not raise them.`;
/** Structured facts for the scan time budget. The sentence is display text. */
export const hopperCallerScanLimit = (budget: HopperCallerScanBudget) =>
analysisSettingLimit(
"hopper.caller_scan_budget_ms",
budget.milliseconds,
budget.source,
);
/** Environment recovery for a caller-scan limit. Domain facts stay separate. */
export const hopperCallerScanRecovery = (
@@ -64,7 +73,9 @@ export const hopperCallerScanRecovery = (
facts.stop.code === "retention_budget" ||
!facts.instructions_complete ||
facts.native_callers_complete === false;
if (facts.stop.code === "work_budget")
return `${budget.setting} is ${String(budget.milliseconds)} ms (source: ${budget.source}). The default is ${String(budget.defaultMilliseconds)} ms. Allowed values are integers from ${String(budget.floorMilliseconds)} to ${String(budget.maximumMilliseconds)} ms. Set it in the environment that starts REA and restart REA. ${fixedAllowanceRecovery}`;
if (facts.stop.code === "work_budget") {
const limit = hopperCallerScanLimit(budget);
return `hopper.caller_scan_budget_ms is ${String(limit.effective_value)} ms (source ${limit.source ?? "unknown"}; default ${String(budget.defaultMilliseconds)} ms, floor ${String(limit.minimum)} ms, maximum ${String(limit.maximum)} ms). It can be changed at runtime with update_analysis_settings. The launch seed is ${budget.setting}. ${fixedAllowanceRecovery}`;
}
return fixedLimit ? fixedAllowanceRecovery : "";
};
+4
View File
@@ -196,6 +196,10 @@ export const parseConfig = (
homeDirectoryFromEnvironment(environment, process.platform),
),
hopperStartupTimeoutMs: env.REA_HOPPER_STARTUP_TIMEOUT_MS,
hopperStartupTimeoutSource:
environment.REA_HOPPER_STARTUP_TIMEOUT_MS === undefined
? "default"
: "setting",
hopperCallerScanBudget: hopperCallerScanBudgetFromEnvironment(
environment.REA_HOPPER_CALLER_SCAN_BUDGET_MS,
env.REA_HOPPER_CALLER_SCAN_BUDGET_MS,
+3 -3
View File
@@ -49,7 +49,8 @@ it("keeps the 5000 ms deadline when the setting is unset", () => {
applies_at: "next_operation",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
matching_deadline_ms: DEFAULT_REGEX_MATCHING_DEADLINE.deadlineMs,
@@ -65,8 +66,7 @@ it("keeps the 5000 ms deadline when the setting is unset", () => {
expect(projected.remediation.action).toContain("source default");
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toContain("regex.matching_deadline_ms");
expect(projected.remediation.action).toMatch(/restart REA/i);
expect(projected.remediation.action).toMatch(/without a REA restart/i);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toMatch(
/heap does not raise this deadline/i,
);
+5 -1
View File
@@ -5,6 +5,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const REGEX_MATCHING_DEADLINE_SETTING = "REA_REGEX_MATCHING_DEADLINE_MS";
@@ -57,6 +58,9 @@ export const parseRegexMatchingDeadline = (
export const resolveRegexMatchingDeadline = (
environment: Readonly<Record<string, string | undefined>>,
): RegexMatchingDeadline => {
const override = currentAnalysisSettingOverride("regex.matching_deadline_ms");
if (override !== undefined)
return { deadlineMs: override, source: "runtime" };
const parsed = parseRegexMatchingDeadline(
environment[REGEX_MATCHING_DEADLINE_SETTING],
);
@@ -70,6 +74,6 @@ export const regexMatchingDeadlineRecovery = (
): string =>
[
`The effective ${REGEX_MATCHING_DEADLINE_SETTING} value is ${String(deadline.deadlineMs)} ms (source ${deadline.source}; default ${String(DEFAULT_REGEX_MATCHING_DEADLINE_MS)} ms, floor 1 ms, maximum ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)} ms).`,
`Set ${REGEX_MATCHING_DEADLINE_SETTING} in the environment that starts REA to a positive decimal integer from 1 to ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)}, then restart REA. In a running MCP server, use update_analysis_settings with setting regex.matching_deadline_ms and action set instead; the new value applies to new work without a REA restart.`,
`regex.matching_deadline_ms can be changed at runtime with update_analysis_settings. The launch seed is ${REGEX_MATCHING_DEADLINE_SETTING}, a positive decimal integer from 1 to ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)}.`,
"The worker old-generation heap does not raise this deadline.",
].join(" ");
+3 -3
View File
@@ -56,7 +56,8 @@ it("keeps the 128 MiB request when the setting is unset", () => {
applies_at: "next_worker",
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
},
reported_limits: {
requested_worker_heap_mib: DEFAULT_REGEX_WORKER_HEAP_REQUEST.heapMiB,
@@ -76,8 +77,7 @@ it("keeps the 128 MiB request when the setting is unset", () => {
);
expect(projected.remediation.action).toContain("--max-old-space-size");
expect(projected.remediation.action).toContain("NODE_OPTIONS");
expect(projected.remediation.action).toMatch(/restart REA/i);
expect(projected.remediation.action).toMatch(/without a REA restart/i);
expect(projected.remediation.action).toContain("launch seed");
expect(projected.remediation.action).toMatch(/overrides this request/i);
});
+6 -1
View File
@@ -6,6 +6,7 @@ import {
import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
export const REGEX_WORKER_HEAP_REQUEST_SETTING =
@@ -59,6 +60,10 @@ export const parseRegexWorkerHeapRequest = (
export const resolveRegexWorkerHeapRequest = (
environment: Readonly<Record<string, string | undefined>>,
): RegexWorkerHeapRequest => {
const override = currentAnalysisSettingOverride(
"regex.worker_heap_request_mib",
);
if (override !== undefined) return { heapMiB: override, source: "runtime" };
const parsed = parseRegexWorkerHeapRequest(
environment[REGEX_WORKER_HEAP_REQUEST_SETTING],
);
@@ -72,6 +77,6 @@ export const regexWorkerHeapRequestRecovery = (
): string =>
[
`The effective ${REGEX_WORKER_HEAP_REQUEST_SETTING} value is ${String(request.heapMiB)} MiB (source ${request.source}; default ${String(DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB, floor ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB, maximum ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB).`,
`Set ${REGEX_WORKER_HEAP_REQUEST_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} to ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)}, then restart REA. In a running MCP server, use update_analysis_settings with setting regex.worker_heap_request_mib and action set instead; the new value applies to new work without a REA restart.`,
`regex.worker_heap_request_mib can be changed at runtime with update_analysis_settings. The launch seed is ${REGEX_WORKER_HEAP_REQUEST_SETTING}, a decimal integer from ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} to ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)}.`,
"A positive Node --max-old-space-size, including NODE_OPTIONS, overrides this request and sets the effective heap. That flag is not limited by this setting's floor or maximum. Raising host memory alone does not raise the request.",
].join(" ");
+61 -7
View File
@@ -1,4 +1,5 @@
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
import { currentAnalysisSettingOverride } from "../domain/analysisOperationSnapshot.js";
import type { ConfigurationError } from "../domain/configurationErrors.js";
import { ok, type Result } from "../domain/result.js";
import type { AppConfig } from "./types.js";
@@ -7,8 +8,12 @@ import {
DEFAULT_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION,
DEFAULT_GHIDRA_VALUE_FLOW_MAX_EDGES,
} from "../domain/analysisResourceLimits.js";
import { parseAsarListingAdmission } from "./asarListingAdmission.js";
import {
parseAsarListingAdmission,
DEFAULT_ASAR_LISTING_ADMISSION,
} from "./asarListingAdmission.js";
import {
DEFAULT_ASAR_HEADER_ADMISSION,
parseAsarHeaderAdmission,
type AsarHeaderAdmission,
} from "./asarHeaderAdmission.js";
@@ -29,6 +34,8 @@ export const runtimeAnalysisConfig = (
if (read === undefined) return config;
const deadline = config.regexMatchingDeadline;
const heap = config.regexWorkerHeapRequest;
const scan = config.hopperCallerScanBudget;
const bridge = config.bridgeMaxRequestBytes;
return {
...config,
get ghidraStartupTimeoutMs() {
@@ -43,6 +50,11 @@ export const runtimeAnalysisConfig = (
config.hopperStartupTimeoutMs
);
},
get hopperStartupTimeoutSource() {
return read().overrides["hopper.startup_timeout_ms"] === undefined
? config.hopperStartupTimeoutSource
: "runtime";
},
get ghidraDecompileTimeoutSeconds() {
const value = read().overrides["ghidra.decompile_timeout_seconds"];
return value === undefined
@@ -104,6 +116,31 @@ export const runtimeAnalysisConfig = (
: "runtime";
},
},
hopperCallerScanBudget: {
...scan,
get milliseconds() {
return (
read().overrides["hopper.caller_scan_budget_ms"] ?? scan.milliseconds
);
},
get source() {
return read().overrides["hopper.caller_scan_budget_ms"] === undefined
? scan.source
: "runtime";
},
},
bridgeMaxRequestBytes: {
get maximumBytes() {
return (
read().overrides["bridge.max_request_bytes"] ?? bridge.maximumBytes
);
},
get source() {
return read().overrides["bridge.max_request_bytes"] === undefined
? bridge.source
: "runtime";
},
},
};
};
export const runtimeAsarHeaderAdmission = (
@@ -113,25 +150,42 @@ export const runtimeAsarHeaderAdmission = (
const parsed = parseAsarHeaderAdmission(
environment.REA_ASAR_MAX_HEADER_BYTES,
);
if (!parsed.ok) throw parsed.error;
const headerOverride =
read?.().overrides["asar.max_header_bytes"] ??
currentAnalysisSettingOverride("asar.max_header_bytes");
if (!parsed.ok && headerOverride === undefined) throw parsed.error;
const listing = parseAsarListingAdmission(
environment.REA_ASAR_MAX_LISTING_BYTES,
);
if (!listing.ok) throw listing.error;
const baseline = parsed.value;
const listingOverride =
read?.().overrides["asar.max_listing_bytes"] ??
currentAnalysisSettingOverride("asar.max_listing_bytes");
if (!listing.ok && listingOverride === undefined) throw listing.error;
const baseline = parsed.ok ? parsed.value : DEFAULT_ASAR_HEADER_ADMISSION;
const listingBaseline = listing.ok
? listing.value
: DEFAULT_ASAR_LISTING_ADMISSION;
return read === undefined
? { ...baseline, listing: listing.value }
? {
maximumBytes: headerOverride ?? baseline.maximumBytes,
source: headerOverride === undefined ? baseline.source : "runtime",
listing: {
maximumBytes: listingOverride ?? listingBaseline.maximumBytes,
source:
listingOverride === undefined ? listingBaseline.source : "runtime",
},
}
: {
listing: {
get maximumBytes() {
return (
read().overrides["asar.max_listing_bytes"] ??
listing.value.maximumBytes
listingBaseline.maximumBytes
);
},
get source() {
return read().overrides["asar.max_listing_bytes"] === undefined
? listing.value.source
? listingBaseline.source
: "runtime";
},
},
+2
View File
@@ -41,6 +41,8 @@ export interface AppConfig {
readonly ilspyCmdPath: string | undefined;
readonly hopperLauncherPath: string;
readonly hopperStartupTimeoutMs: number;
/** Whether `hopperStartupTimeoutMs` came from the launch environment. */
readonly hopperStartupTimeoutSource: "default" | "setting" | "runtime";
/** Cooperative per-operation budget for forward caller reconciliation. */
readonly hopperCallerScanBudget: HopperCallerScanBudget;
readonly hopperTargetPath: string | undefined;
+44 -8
View File
@@ -2,11 +2,13 @@ import { expect, it } from "vitest";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import { parseEnvironment } from "./environment.js";
import {
WINDOWS_CHANGE_TIME_DRIFT_MS,
WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
applyWindowsChangeTimeSnapshot,
resolveWindowsChangeTimeSettle,
} from "./windowsChangeTimeSettle.js";
@@ -27,7 +29,7 @@ const requiredFacts = (
expect(message).toContain(String(fact.defaultValue));
expect(message).toContain(String(fact.floor));
expect(message).toContain(String(fact.maximum));
expect(message).toContain("restart REA");
expect(message).toContain("positive decimal integer");
};
it("keeps today's settle defaults when the settings are unset", () => {
@@ -98,17 +100,51 @@ it.each([
const message = resolved.error.settings[0]?.constraint;
if (message === undefined) throw new Error("Missing configuration guidance");
requiredFacts(message, { ...bound, currentValue: value, source: "setting" });
expect(resolved.error.settings).toEqual([
{ setting: bound.setting, constraint: message },
]);
const facts = {
setting: bound.setting,
constraint: message,
update_mode: "runtime" as const,
pending_restart: true as const,
};
expect(resolved.error.settings).toEqual([facts]);
expect(projectAnalysisError(resolved.error)).toMatchObject({
code: "configuration_invalid",
details: { settings: [{ setting: bound.setting, constraint: message }] },
details: { settings: [facts] },
});
const startup = parseEnvironment({ [bound.setting]: value });
if (startup.ok) throw new Error(`Startup accepted ${bound.setting}=${value}`);
expect(startup.error.settings).toContainEqual({
setting: bound.setting,
constraint: message,
expect(startup.error.settings).toContainEqual(facts);
expect(projectAnalysisError(startup.error).details).toMatchObject({
settings: [facts],
});
});
it("applies a Windows settle override before rejecting the launch value", () => {
const limits = runWithAnalysisOperationSnapshot(
{
revision: 1,
overrides: {
"windows.change_time_drift_ms": 50,
"windows.change_time_settle_poll_ms": 10,
"windows.change_time_settle_max_polls": 4,
},
},
() => {
const resolved = resolveWindowsChangeTimeSettle({
REA_WINDOWS_CHANGE_TIME_DRIFT_MS: "nope",
REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: "0",
REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: "1001",
});
if (!resolved.ok) throw resolved.error;
return applyWindowsChangeTimeSnapshot(resolved.value);
},
);
expect(limits).toMatchObject({
driftMs: 50,
driftSource: "runtime",
pollMs: 10,
pollSource: "runtime",
maxPolls: 4,
maxPollsSource: "runtime",
});
});
+68 -20
View File
@@ -1,5 +1,14 @@
import { z } from "zod";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import {
DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
} from "../domain/analysisResourceLimits.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -19,18 +28,18 @@ interface WindowsChangeTimeBound {
*/
export const WINDOWS_CHANGE_TIME_DRIFT_MS = {
setting: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS",
defaultValue: 2_000,
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
floor: 1,
maximum: 60_000,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
unit: "ms",
} as const satisfies WindowsChangeTimeBound;
/** Delay between ChangeTime observations. Unset keeps the 25 ms poll. */
export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = {
setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS",
defaultValue: 25,
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
floor: 1,
maximum: 1_000,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
unit: "ms",
} as const satisfies WindowsChangeTimeBound;
@@ -40,9 +49,9 @@ export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = {
*/
export const WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = {
setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS",
defaultValue: 40,
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
floor: 1,
maximum: 1_000,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
unit: "polls",
} as const satisfies WindowsChangeTimeBound;
@@ -55,17 +64,19 @@ export interface WindowsChangeTimeSettingFailure {
readonly floor: number;
readonly maximum: number;
readonly unit: "ms" | "polls";
readonly restartRequired: true;
readonly update_mode: "runtime";
/** The rejected environment value is not the value this process is using. */
readonly pending_restart: true;
}
/** Effective settle budget and whether each part came from the default or a setting. */
export interface WindowsChangeTimeSettleLimits {
readonly driftMs: number;
readonly driftSource: "default" | "setting";
readonly driftSource: "default" | "setting" | "runtime";
readonly pollMs: number;
readonly pollSource: "default" | "setting";
readonly pollSource: "default" | "setting" | "runtime";
readonly maxPolls: number;
readonly maxPollsSource: "default" | "setting";
readonly maxPollsSource: "default" | "setting" | "runtime";
}
const windowsChangeTimeSettingFailure = (
@@ -79,14 +90,15 @@ const windowsChangeTimeSettingFailure = (
floor: bound.floor,
maximum: bound.maximum,
unit: bound.unit,
restartRequired: true,
update_mode: "runtime",
pending_restart: true,
});
/** Name the rejected value, its source, the allowed range, and the required restart. */
/** Name the rejected value, its source, and the allowed range. */
const windowsChangeTimeSettingMessage = (
failure: WindowsChangeTimeSettingFailure,
): string =>
`The effective ${failure.setting} value is ${failure.currentValue} ${failure.unit} (source ${failure.source}; default ${String(failure.defaultValue)} ${failure.unit}, floor ${String(failure.floor)} ${failure.unit}, maximum ${String(failure.maximum)} ${failure.unit}). Set ${failure.setting} in the environment that starts REA to a positive decimal integer from ${String(failure.floor)} to ${String(failure.maximum)}, then restart REA.`;
`The effective ${failure.setting} value is ${failure.currentValue} ${failure.unit} (source ${failure.source}; default ${String(failure.defaultValue)} ${failure.unit}, floor ${String(failure.floor)} ${failure.unit}, maximum ${String(failure.maximum)} ${failure.unit}). windows change-time settings can be changed at runtime with update_analysis_settings. The launch seed ${failure.setting} must be a positive decimal integer from ${String(failure.floor)} to ${String(failure.maximum)}. The rejected value is not active.`;
const inRange = (bound: WindowsChangeTimeBound, value: string): boolean => {
if (!decimalInteger.test(value)) return false;
@@ -100,11 +112,14 @@ export const windowsChangeTimeSettingSchema = (bound: WindowsChangeTimeBound) =>
.string()
.superRefine((value, context) => {
if (inRange(bound, value)) return;
const failure = windowsChangeTimeSettingFailure(bound, value);
context.addIssue({
code: "custom",
message: windowsChangeTimeSettingMessage(
windowsChangeTimeSettingFailure(bound, value),
),
message: windowsChangeTimeSettingMessage(failure),
params: {
update_mode: failure.update_mode,
pending_restart: failure.pending_restart,
},
});
})
.transform(Number);
@@ -117,6 +132,14 @@ export const windowsChangeTimeSettlePollMsSchema =
export const windowsChangeTimeSettleMaxPollsSchema =
windowsChangeTimeSettingSchema(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS);
const WINDOWS_CHANGE_TIME_OVERRIDES = {
[WINDOWS_CHANGE_TIME_DRIFT_MS.setting]: "windows.change_time_drift_ms",
[WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.setting]:
"windows.change_time_settle_poll_ms",
[WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.setting]:
"windows.change_time_settle_max_polls",
} as const;
const readBound = (
bound: WindowsChangeTimeBound,
environment: Readonly<Record<string, string | undefined>>,
@@ -125,11 +148,15 @@ const readBound = (
const value = environment[bound.setting];
if (value === undefined) return undefined;
const parsed = windowsChangeTimeSettingSchema(bound).safeParse(value);
if (!parsed.success) {
failures.push(windowsChangeTimeSettingFailure(bound, value));
if (parsed.success) return parsed.data;
const override =
WINDOWS_CHANGE_TIME_OVERRIDES[
bound.setting as keyof typeof WINDOWS_CHANGE_TIME_OVERRIDES
];
if (currentAnalysisOperationSnapshot()?.overrides[override] !== undefined)
return undefined;
}
return parsed.data;
failures.push(windowsChangeTimeSettingFailure(bound, value));
return undefined;
};
/** Resolve the settle budget from one environment. Invalid values are refused. */
@@ -160,6 +187,8 @@ export const resolveWindowsChangeTimeSettle = (
settings: failures.map((failure) => ({
setting: failure.setting,
constraint: windowsChangeTimeSettingMessage(failure),
update_mode: failure.update_mode,
pending_restart: failure.pending_restart,
})),
},
),
@@ -173,3 +202,22 @@ export const resolveWindowsChangeTimeSettle = (
maxPollsSource: maxPolls === undefined ? "default" : "setting",
});
};
/** Apply the accepted operation snapshot. An explicit caller budget is left alone. */
export const applyWindowsChangeTimeSnapshot = (
limits: WindowsChangeTimeSettleLimits,
): WindowsChangeTimeSettleLimits => {
const overrides = currentAnalysisOperationSnapshot()?.overrides;
if (overrides === undefined) return limits;
const driftMs = overrides["windows.change_time_drift_ms"];
const pollMs = overrides["windows.change_time_settle_poll_ms"];
const maxPolls = overrides["windows.change_time_settle_max_polls"];
return {
driftMs: driftMs ?? limits.driftMs,
driftSource: driftMs === undefined ? limits.driftSource : "runtime",
pollMs: pollMs ?? limits.pollMs,
pollSource: pollMs === undefined ? limits.pollSource : "runtime",
maxPolls: maxPolls ?? limits.maxPolls,
maxPollsSource: maxPolls === undefined ? limits.maxPollsSource : "runtime",
};
};
+3 -1
View File
@@ -48,6 +48,8 @@ const descriptor = z.strictObject({
minimum: z.number(),
maximum: z.number(),
applies_at: z.enum(["next_operation", "next_worker", "next_startup"]),
update_mode: z.enum(["runtime", "restart"]),
pending_restart: z.boolean(),
provider: z.string(),
operations: z.array(z.string()),
environment: z.string().optional(),
@@ -83,7 +85,7 @@ export const updateAnalysisSettingsResultSchema =
}),
),
active_work_changed: z.literal(false),
rea_restart_required: z.literal(false),
pending_restart: z.literal(false),
});
export const ANALYSIS_SETTINGS_TOOL_CONTRACTS = [
{
+49 -13
View File
@@ -46,12 +46,16 @@ import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import {
BRIDGE_MAX_REQUEST_BYTES_SETTING,
bridgeRequestLimitMismatchError,
bridgeRequestOversizeError,
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
resolveBridgeMaxRequestBytes,
parseBridgeMaxRequestBytes,
type BridgeMaxRequestBytes,
} from "../config/bridgeMaxRequestBytes.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js";
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
const descriptorSchema = z.object({
...cutterSessionSchema.shape,
@@ -254,11 +258,15 @@ export interface WindowsCutterDescriptorReader {
interface CutterBridgeClientOptions {
readonly platform?: NodeJS.Platform;
readonly windowsPrivateReader?: WindowsCutterDescriptorReader;
/** Accepted runtime overrides. The operation snapshot wins when one is bound. */
readonly read?: AnalysisSettingsReader;
}
/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */
export class CutterBridgeClient {
readonly #maxRequestBytes: BridgeMaxRequestBytes;
readonly #launchRequestBytes: BridgeMaxRequestBytes | undefined;
readonly #launchRequestError: ConfigurationError | undefined;
readonly #read: AnalysisSettingsReader | undefined;
readonly #directory: string;
readonly #descriptorResources = new ArtifactResourceScope();
readonly #platform: NodeJS.Platform;
@@ -269,7 +277,16 @@ export class CutterBridgeClient {
environment: Readonly<NodeJS.ProcessEnv> = process.env,
options: CutterBridgeClientOptions = {},
) {
this.#maxRequestBytes = resolveBridgeMaxRequestBytes(environment);
const launchRequest = parseBridgeMaxRequestBytes(
environment[BRIDGE_MAX_REQUEST_BYTES_SETTING],
);
this.#launchRequestBytes = launchRequest.ok
? launchRequest.value
: undefined;
this.#launchRequestError = launchRequest.ok
? undefined
: launchRequest.error;
this.#read = options.read;
const configured = environment.REA_CUTTER_BRIDGE_DIR;
const localAppData = environment.LOCALAPPDATA;
this.#platform = options.platform ?? process.platform;
@@ -305,6 +322,23 @@ export class CutterBridgeClient {
);
}
/** Ceiling for this call. A bound operation keeps the value it accepted. */
#requestLimit(): BridgeMaxRequestBytes {
const snapshot = currentAnalysisOperationSnapshot();
const override =
snapshot === undefined
? this.#read?.().overrides["bridge.max_request_bytes"]
: snapshot.overrides["bridge.max_request_bytes"];
if (override !== undefined)
return { maximumBytes: override, source: "runtime" };
if (this.#launchRequestBytes === undefined)
throw (
this.#launchRequestError ??
new ConfigurationError("Invalid REA environment configuration")
);
return this.#launchRequestBytes;
}
async retryDescriptorCleanup(): Promise<void> {
try {
await this.#descriptorResources.retryCleanup();
@@ -324,6 +358,7 @@ export class CutterBridgeClient {
async listSessions(
options: CutterDiscoveryOptions = {},
): Promise<CutterBridgeDiscovery> {
this.#requestLimit();
const { signal } = options;
const mismatches: number[] = [];
const loaded = await this.#readDescriptors("list_cutter_sessions", signal);
@@ -340,7 +375,7 @@ export class CutterBridgeClient {
assertBridgeActive(signal, "list_cutter_sessions");
const advertised =
descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES;
if (advertised !== this.#maxRequestBytes.maximumBytes) {
if (advertised !== this.#requestLimit().maximumBytes) {
// A crashed process can leave the previous ceiling on disk. Count
// the mismatch only after the hello proves a live authenticated
// bridge. The probe does not send a status request.
@@ -352,7 +387,7 @@ export class CutterBridgeClient {
const status = responseSchema.safeParse(
await this.#request(
descriptor,
serializeRequest(this.#maxRequestBytes, descriptor, {
serializeRequest(this.#requestLimit(), descriptor, {
kind: "status",
session_id: descriptor.session_id,
}),
@@ -409,7 +444,7 @@ export class CutterBridgeClient {
if (mismatches.length > 0)
throw bridgeRequestLimitMismatchError(
"list_cutter_sessions",
this.#maxRequestBytes,
this.#requestLimit(),
mismatches[0] ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
"Cutter",
"No status request was sent to the mismatched session.",
@@ -430,22 +465,23 @@ export class CutterBridgeClient {
readonly json: boolean;
readonly signal?: AbortSignal;
}): Promise<CutterBridgeExecution> {
this.#requestLimit();
try {
assertBridgeActive(input.signal, "cutter_command");
const descriptor = await this.#find(input.sessionId, input.signal);
assertBridgeActive(input.signal, "cutter_command");
const advertised =
descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES;
if (advertised !== this.#maxRequestBytes.maximumBytes)
if (advertised !== this.#requestLimit().maximumBytes)
throw bridgeRequestLimitMismatchError(
"cutter_command",
this.#maxRequestBytes,
this.#requestLimit(),
advertised,
"Cutter",
);
const requestId = randomUUID();
const commandRequest = serializeRequest(
this.#maxRequestBytes,
this.#requestLimit(),
descriptor,
{
kind: "command",
@@ -632,7 +668,7 @@ export class CutterBridgeClient {
): Promise<z.infer<typeof responseSchema>> {
const raw = await this.#request(
descriptor,
serializeRequest(this.#maxRequestBytes, descriptor, {
serializeRequest(this.#requestLimit(), descriptor, {
kind: "status",
session_id: descriptor.session_id,
}),
@@ -681,7 +717,7 @@ export class CutterBridgeClient {
try {
const raw = await this.#request(
descriptor,
serializeRequest(this.#maxRequestBytes, descriptor, {
serializeRequest(this.#requestLimit(), descriptor, {
kind: "ack",
request_id: requestId,
}),
@@ -962,7 +998,7 @@ export class CutterBridgeClient {
host: descriptor.host,
port: descriptor.port,
});
const frames = new CutterBridgeResponseFrames(this.#maxRequestBytes);
const frames = new CutterBridgeResponseFrames(this.#requestLimit());
let settled = false;
const settle = (
outcome:
@@ -1037,7 +1073,7 @@ export class CutterBridgeClient {
host: descriptor.host,
port: descriptor.port,
});
const frames = new CutterBridgeResponseFrames(this.#maxRequestBytes);
const frames = new CutterBridgeResponseFrames(this.#requestLimit());
let completion: (() => void) | undefined;
let requestSent = false;
let authenticated = false;
+16 -1
View File
@@ -1512,7 +1512,8 @@ describe("analysis error projection: caller lifetime", () => {
minimum: limit.minimum,
maximum: limit.maximum,
agent_editable: true,
rea_restart_required: false,
update_mode: "runtime",
pending_restart: false,
applies_at: "next_invocation",
scope: "rea-invocation",
environment: "REA_REGEX_MATCHING_DEADLINE_MS",
@@ -1529,11 +1530,25 @@ describe("analysis error projection: caller lifetime", () => {
expect.objectContaining({
setting: "wasm.command_timeout_ms",
source: "request",
update_mode: "parameter",
parameter: "command_timeout_ms",
applies_at: "next_invocation",
scope: "rea-invocation",
}),
);
expect(
analysisSettingLimit("wasm.command_timeout_ms", 100, "runtime"),
).toMatchObject({ source: "runtime", update_mode: "runtime" });
expect(
analysisSettingLimit("javascript.max_heap_mb", 128, "request"),
).toMatchObject({
source: "request",
update_mode: "parameter",
parameter: "max_heap_mb",
});
expect(
analysisSettingLimit("javascript.analysis_timeout_ms", 1, "default"),
).toMatchObject({ source: "default", update_mode: "runtime" });
expect(
projectAnalysisError(error, "invocation").details?.limit,
).not.toHaveProperty("environment");
+9 -3
View File
@@ -1044,9 +1044,15 @@ const lifecycleErrorDetails = (
}
if (error instanceof ConfigurationError && error.settings.length > 0)
return {
settings: error.settings.map(({ setting, constraint }) => ({
setting,
constraint,
settings: error.settings.map((issue) => ({
setting: issue.setting,
constraint: issue.constraint,
...(issue.update_mode === undefined
? {}
: { update_mode: issue.update_mode }),
...(issue.pending_restart === undefined
? {}
: { pending_restart: issue.pending_restart }),
})),
};
if (error instanceof BinaryTargetError)
+30
View File
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type {
AnalysisSettingValues,
AnalysisSettingsSnapshot,
} from "./analysisSettings.js";
const snapshots = new AsyncLocalStorage<AnalysisSettingsSnapshot>();
/**
* Bind the snapshot accepted for one operation. Providers and filesystem
* helpers read it at the point they enforce a limit. A later update does not
* change work already inside this call.
*/
export const runWithAnalysisOperationSnapshot = <Value>(
snapshot: AnalysisSettingsSnapshot,
operation: () => Value,
): Value => snapshots.run(snapshot, operation);
/** The snapshot for the operation on this async stack, when one was accepted. */
export const currentAnalysisOperationSnapshot = ():
| AnalysisSettingsSnapshot
| undefined => snapshots.getStore();
/** Accepted override for one setting, when this operation has a snapshot. */
export const currentAnalysisSettingOverride = <
Setting extends keyof AnalysisSettingValues,
>(
setting: Setting,
): AnalysisSettingValues[Setting] | undefined =>
currentAnalysisOperationSnapshot()?.overrides[setting];
+25
View File
@@ -191,3 +191,28 @@ export const MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES = Math.floor(
* a caller cap below this budget must not truncate those required replies.
*/
export const DEBUGGER_CONTROL_REPLY_BYTES = 16 * 1024 * 1024;
/** Hopper, Ghidra, and Cutter request payload ceiling. Same bound as the bridge owner. */
export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464;
export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES = 536_870_888 - 1;
/** MCP client SDK default request timeout. JEB uses it when the setting is unset. */
export const DEFAULT_JEB_MCP_TIMEOUT_MS = 60_000;
/** Kuna project export and whole-program scans. */
export const DEFAULT_KUNA_PROJECT_TIMEOUT_MS = 30 * 60 * 1000;
/** One Kuna function decompile. */
export const DEFAULT_KUNA_FUNCTION_TIMEOUT_MS = 120_000;
/** Pwntools artifact admission when producer_limits.input_bytes is omitted. */
export const DEFAULT_PWNTOOLS_MAX_INPUT_BYTES = 32 * 1024 * 1024;
export const MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES = 1024 * 1024 * 1024;
/** Windows ChangeTime settle window, poll, and poll count. */
export const DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS = 2_000;
export const MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS = 60_000;
export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = 25;
export const MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = 1_000;
export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = 40;
export const MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = 1_000;
+250 -1
View File
@@ -29,6 +29,28 @@ import {
DEFAULT_JADX_OPERATION_TIMEOUT_MS,
DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES,
MAXIMUM_NATIVE_MATCHING_REPRESENTATION_BYTES,
DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
DEFAULT_JEB_MCP_TIMEOUT_MS,
DEFAULT_KUNA_FUNCTION_TIMEOUT_MS,
DEFAULT_KUNA_PROJECT_TIMEOUT_MS,
DEFAULT_PWNTOOLS_MAX_INPUT_BYTES,
MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES,
DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
} from "./analysisResourceLimits.js";
import {
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
@@ -46,6 +68,7 @@ interface SettingDefinition {
readonly maximum: number;
readonly unit: "bytes" | "ms" | "seconds" | "MiB" | "count";
readonly appliesAt: "next_operation" | "next_worker" | "next_startup";
readonly updateMode: "runtime" | "restart";
readonly provider: string;
readonly relatedProviders?: readonly string[];
readonly operations: readonly string[];
@@ -60,6 +83,7 @@ const timer = {
/** Metadata aggregates existing owner definitions; no environment mutation or engine discovery. */
export const ANALYSIS_SETTINGS = {
"ghidra.decompile_max_payload_mib": {
updateMode: "runtime",
environment: "REA_GHIDRA_DECOMPILE_MAX_PAYLOAD_MIB",
defaultValue: null,
minimum: 1,
@@ -77,6 +101,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"asar.max_listing_bytes": {
updateMode: "runtime",
environment: "REA_ASAR_MAX_LISTING_BYTES",
defaultValue: DEFAULT_ASAR_MAX_LISTING_BYTES,
minimum: 1,
@@ -100,6 +125,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.value_flow_max_operations": {
updateMode: "runtime",
environment: "REA_GHIDRA_VALUE_FLOW_MAX_OPERATIONS",
defaultValue: DEFAULT_GHIDRA_VALUE_FLOW_MAX_OPERATIONS,
minimum: 1,
@@ -114,6 +140,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.value_flow_max_inputs_per_operation": {
updateMode: "runtime",
environment: "REA_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION",
defaultValue: DEFAULT_GHIDRA_VALUE_FLOW_MAX_INPUTS_PER_OPERATION,
minimum: 1,
@@ -128,6 +155,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.value_flow_max_edges": {
updateMode: "runtime",
environment: "REA_GHIDRA_VALUE_FLOW_MAX_EDGES",
defaultValue: DEFAULT_GHIDRA_VALUE_FLOW_MAX_EDGES,
minimum: 1,
@@ -142,6 +170,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"asar.max_header_bytes": {
updateMode: "runtime",
environment: "REA_ASAR_MAX_HEADER_BYTES",
defaultValue: DEFAULT_ASAR_MAX_HEADER_BYTES,
minimum: 1,
@@ -165,6 +194,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.startup_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_GHIDRA_STARTUP_TIMEOUT_MS",
defaultValue: DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
@@ -185,6 +215,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"hopper.startup_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_HOPPER_STARTUP_TIMEOUT_MS",
defaultValue: DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
@@ -205,6 +236,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"ghidra.decompile_timeout_seconds": {
updateMode: "runtime",
environment: "REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS",
defaultValue: null,
minimum: 1,
@@ -220,6 +252,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"jadx.operation_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_JADX_OPERATION_TIMEOUT_MS",
defaultValue: DEFAULT_JADX_OPERATION_TIMEOUT_MS,
@@ -233,6 +266,7 @@ export const ANALYSIS_SETTINGS = {
],
},
"regex.matching_deadline_ms": {
updateMode: "runtime",
...timer,
environment: "REA_REGEX_MATCHING_DEADLINE_MS",
defaultValue: DEFAULT_REGEX_MATCHING_DEADLINE_MS,
@@ -242,6 +276,7 @@ export const ANALYSIS_SETTINGS = {
operations: ["search_strings", "search_procedures"],
},
"regex.worker_heap_request_mib": {
updateMode: "runtime",
environment: "REA_REGEX_WORKER_HEAP_REQUEST_MIB",
defaultValue: DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB,
minimum: MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB,
@@ -253,6 +288,7 @@ export const ANALYSIS_SETTINGS = {
operations: ["search_strings", "search_procedures"],
},
"capture.decoder_old_generation_mib": {
updateMode: "runtime",
environment: "REA_CAPTURE_DECODER_OLD_GENERATION_MIB",
defaultValue: DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB,
minimum: CAPTURE_DECODER_SEMI_SPACE_MIB,
@@ -263,6 +299,7 @@ export const ANALYSIS_SETTINGS = {
operations: ["inspect_web_network_capture"],
},
"javascript.analysis_timeout_ms": {
updateMode: "runtime",
...timer,
defaultValue: JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
provider: "rea-javascript-application",
@@ -270,6 +307,7 @@ export const ANALYSIS_SETTINGS = {
parameter: "analysis_timeout_ms",
},
"javascript.max_heap_mb": {
updateMode: "runtime",
defaultValue: JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
minimum: JAVASCRIPT_WORKER_HEAP_MIB_MINIMUM,
maximum: JAVASCRIPT_WORKER_HEAP_MIB_MAXIMUM,
@@ -280,6 +318,7 @@ export const ANALYSIS_SETTINGS = {
parameter: "max_heap_mb",
},
"wasm.command_timeout_ms": {
updateMode: "runtime",
...timer,
defaultValue: DEFAULT_WASM_COMMAND_TIMEOUT_MS,
maximum: MAX_WASM_COMMAND_TIMEOUT_MS,
@@ -288,6 +327,7 @@ export const ANALYSIS_SETTINGS = {
parameter: "command_timeout_ms",
},
"native.matching_representation_bytes": {
updateMode: "runtime",
environment: "REA_NATIVE_MATCHING_REPRESENTATION_BYTES",
defaultValue: DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES,
minimum: 1,
@@ -297,6 +337,197 @@ export const ANALYSIS_SETTINGS = {
provider: "rea-function-comparison",
operations: ["match_native_functions"],
},
"bridge.max_request_bytes": {
updateMode: "runtime",
environment: "REA_BRIDGE_MAX_REQUEST_BYTES",
defaultValue: DEFAULT_BRIDGE_MAX_REQUEST_BYTES,
minimum: MINIMUM_BRIDGE_MAX_REQUEST_BYTES,
maximum: MAXIMUM_BRIDGE_MAX_REQUEST_BYTES,
unit: "bytes",
appliesAt: "next_operation",
provider: "hopper",
relatedProviders: ["ghidra", "cutter"],
// Every Hopper bridge operation, every Ghidra bridge operation, and the
// Cutter bridge calls. bridgeMaxRequestBytes.test.ts fails when a registry
// gains an operation that is missing here.
operations: [
"address_name",
"comment",
"current_address",
"current_procedure",
"current_document",
"goto_address",
"inline_comment",
"list_bookmarks",
"list_documents",
"list_names",
"list_procedures",
"list_segments",
"list_strings",
"next_address",
"prev_address",
"procedure_address",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"read_function_instructions",
"read_bytes",
"address_to_file_offset",
"procedure_references",
"procedure_pseudo_code",
"resolve_containing_procedure",
"search_procedures",
"search_strings",
"set_address_name",
"set_addresses_names",
"set_bookmark",
"set_comment",
"set_inline_comment",
"unset_bookmark",
"xrefs",
"analyze_function",
"binary_overview",
"inspect_native_load_image",
"search_bytes",
"annotate_native_function",
"inspect_native_data_type",
"inspect_native_instruction",
"resolve_native_call_targets",
"list_cutter_sessions",
"cutter_command",
],
},
"debugger.command_deadline_ms": {
updateMode: "runtime",
...timer,
environment: "REA_DEBUGGER_COMMAND_DEADLINE_MS",
defaultValue: DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS,
minimum: MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
maximum: MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS,
provider: "gdb",
relatedProviders: ["rizin"],
operations: [
"start_gdb_session",
"gdb_console",
"start_rizin_debug_session",
"rizin_debug_command",
],
},
"debugger.max_command_output_bytes": {
updateMode: "runtime",
environment: "REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES",
defaultValue: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
minimum: MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
maximum: MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES,
unit: "bytes",
appliesAt: "next_operation",
provider: "gdb",
relatedProviders: ["rizin"],
operations: ["gdb_console", "rizin_debug_command"],
},
"hopper.caller_scan_budget_ms": {
updateMode: "runtime",
...timer,
environment: "REA_HOPPER_CALLER_SCAN_BUDGET_MS",
defaultValue: DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS,
minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS,
provider: "hopper",
operations: [
"procedure_callers",
"analyze_function",
"procedure_references",
],
},
"jeb.mcp_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_JEB_MCP_TIMEOUT_MS",
defaultValue: DEFAULT_JEB_MCP_TIMEOUT_MS,
provider: "jeb",
operations: [
"inspect_jeb_client",
"open_jeb_project",
"list_jeb_units",
"decompile_jeb_item",
],
},
"kuna.project_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_KUNA_PROJECT_TIMEOUT_MS",
defaultValue: DEFAULT_KUNA_PROJECT_TIMEOUT_MS,
provider: "kuna",
operations: [
"list_strings",
"search_strings",
"list_procedures",
"search_procedures",
"xrefs",
"procedure_callers",
"procedure_callees",
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
],
},
"kuna.function_timeout_ms": {
updateMode: "runtime",
...timer,
environment: "REA_KUNA_TIMEOUT_MS",
defaultValue: DEFAULT_KUNA_FUNCTION_TIMEOUT_MS,
provider: "kuna",
operations: [
"procedure_pseudo_code",
"procedure_assembly",
"analyze_function",
],
},
"pwntools.max_input_bytes": {
updateMode: "runtime",
environment: "REA_PWNTOOLS_MAX_INPUT_BYTES",
defaultValue: DEFAULT_PWNTOOLS_MAX_INPUT_BYTES,
minimum: 1,
maximum: MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES,
unit: "bytes",
appliesAt: "next_operation",
provider: "pwntools",
operations: ["inspect_binary_layout", "inspect_recorded_crash"],
parameter: "input_bytes",
},
"windows.change_time_drift_ms": {
updateMode: "runtime",
environment: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS",
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS,
minimum: 1,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS,
unit: "ms",
appliesAt: "next_operation",
provider: "rea-artifact-graph",
operations: ["inventory_artifact", "inspect_artifact", "extract_artifact"],
},
"windows.change_time_settle_poll_ms": {
updateMode: "runtime",
environment: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS",
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
minimum: 1,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
unit: "ms",
appliesAt: "next_operation",
provider: "rea-artifact-graph",
operations: ["inventory_artifact", "inspect_artifact", "extract_artifact"],
},
"windows.change_time_settle_max_polls": {
updateMode: "runtime",
environment: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS",
defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
minimum: 1,
maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
unit: "count",
appliesAt: "next_operation",
provider: "rea-artifact-graph",
operations: ["inventory_artifact", "inspect_artifact", "extract_artifact"],
},
} as const satisfies Record<AnalysisSettingId, SettingDefinition>;
export const analysisSettingIds = Object.keys(
ANALYSIS_SETTINGS,
@@ -317,6 +548,23 @@ export const analysisSettingValueSchema = (id: AnalysisSettingId) => {
? number.nullable()
: number;
};
/**
* Caller argument, then the accepted runtime override, then the built-in
* default. The third value is that default, not a launch-environment seed.
*/
export const selectedAnalysisSetting = (
request: number | undefined,
override: number | undefined,
fallback: number,
): {
readonly value: number;
readonly source: "default" | "runtime" | "request";
} => {
if (request !== undefined) return { value: request, source: "request" };
if (override !== undefined) return { value: override, source: "runtime" };
return { value: fallback, source: "default" };
};
/** Project one owner's accepted budget, including the explicit per-call precedence. */
export const analysisSettingLimit = (
setting: AnalysisSettingId,
@@ -334,7 +582,8 @@ export const analysisSettingLimit = (
applies_at: definition.appliesAt,
agent_editable: true,
scope: "rea-runtime",
rea_restart_required: false,
update_mode: source === "request" ? "parameter" : definition.updateMode,
pending_restart: false,
...(definition.parameter === undefined
? {}
: { parameter: definition.parameter }),
+25 -1
View File
@@ -17,6 +17,17 @@ export interface AnalysisSettingValues {
readonly "javascript.max_heap_mb": number;
readonly "wasm.command_timeout_ms": number;
readonly "native.matching_representation_bytes": number;
readonly "bridge.max_request_bytes": number;
readonly "debugger.command_deadline_ms": number;
readonly "debugger.max_command_output_bytes": number;
readonly "hopper.caller_scan_budget_ms": number;
readonly "jeb.mcp_timeout_ms": number;
readonly "kuna.project_timeout_ms": number;
readonly "kuna.function_timeout_ms": number;
readonly "pwntools.max_input_bytes": number;
readonly "windows.change_time_drift_ms": number;
readonly "windows.change_time_settle_poll_ms": number;
readonly "windows.change_time_settle_max_polls": number;
}
export type AnalysisSettingId = keyof AnalysisSettingValues;
export interface AnalysisSettingsSnapshot {
@@ -26,6 +37,14 @@ export interface AnalysisSettingsSnapshot {
/** Injected by the runtime owner; reads the accepted operation's immutable snapshot. */
export type AnalysisSettingsReader = () => AnalysisSettingsSnapshot;
/**
* Whether `update_analysis_settings` can change the active value.
* `runtime` applies inside this process. `restart` stays at the value this
* process already started with. A limit fact uses `parameter` when this call
* supplied the value; the setting definition itself stays `runtime`.
*/
export type AnalysisSettingUpdateMode = "runtime" | "restart";
/** Limit facts supplied by the failing owner, never inferred from error prose. */
export interface AnalysisSettingLimit {
readonly setting: AnalysisSettingId;
@@ -37,7 +56,12 @@ export interface AnalysisSettingLimit {
readonly applies_at: "next_operation" | "next_worker" | "next_startup";
readonly agent_editable: true;
readonly scope: "rea-runtime";
readonly rea_restart_required: false;
readonly update_mode: AnalysisSettingUpdateMode | "parameter";
/**
* True only when a configured value is not the one this process is using.
* The reported effective value is active, so limit errors leave this false.
*/
readonly pending_restart: false;
readonly required_value?: number;
readonly parameter?: string;
}
+2 -1
View File
@@ -191,7 +191,8 @@ export interface ArtifactReaderLimitFact {
readonly applies_at: "next_operation";
readonly agent_editable: true;
readonly can_increase: boolean;
readonly rea_restart_required: false;
readonly update_mode: "parameter";
readonly pending_restart: false;
}
/** Runtime representation ceilings differ from aggregate plaintext-work budgets. */
+14 -3
View File
@@ -7,6 +7,10 @@ import {
export interface ConfigurationSettingIssue {
readonly setting: string;
readonly constraint: string;
/** Whether `update_analysis_settings` can change this setting, when the producer owns that fact. */
readonly update_mode?: "runtime" | "restart";
/** True when the rejected value is configured but not active in this process. */
readonly pending_restart?: boolean;
}
/** Rejected settings retained for diagnostics; their values are not. */
@@ -24,9 +28,16 @@ export class ConfigurationError extends AnalysisError {
constructor(message: string, options?: ConfigurationErrorOptions) {
super(message, options);
this.remediationAction = options?.remediationAction;
this.settings = (options?.settings ?? []).map(
({ setting, constraint }) => ({ setting, constraint }),
);
this.settings = (options?.settings ?? []).map((issue) => ({
setting: issue.setting,
constraint: issue.constraint,
...(issue.update_mode === undefined
? {}
: { update_mode: issue.update_mode }),
...(issue.pending_restart === undefined
? {}
: { pending_restart: issue.pending_restart }),
}));
}
}
+1
View File
@@ -233,6 +233,7 @@ const redactFailure = (
...(error.remediationAction === undefined
? {}
: { remediationAction: text(error.remediationAction) }),
...(error.limit === undefined ? {} : { limit: error.limit }),
});
if (error instanceof AnalysisAccessDeniedError)
return new AnalysisAccessDeniedError(
@@ -650,32 +650,6 @@ it("connects exposed closures to IPC sends and text clipboard calls", () => {
expect(ipcPath?.steps.at(-1)).toBeDefined();
expect(ipcFinding?.location).toEqual(ipcPath?.steps.at(-1)?.location);
});
it("uses a defineProperty value that replaces an exported sender", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
2(module) { module.exports = require("electron"); },
9(module,exports,load) {
const electron=load(2),helper=load(5);
electron.contextBridge.exposeInMainWorld("desktop", {copy:helper.sender("defined:copy")});
},
5(module,exports,load) {
const electron=load(2);
exports.sender=function(){return ()=>{};};
Object.defineProperty(exports,"sender",{value:function(channel){return (...args)=>send(channel,...args);}});
function send(channel,...args){electron.ipcRenderer.send(channel,...args);}
}
};
function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; }
load(9);
})();`);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
entry: "desktop.copy",
channel: "defined:copy",
terminal: "electron.ipcRenderer.send",
}),
);
});
it("resolves a helper factory listed after its bundle consumer", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules = {
@@ -1,11 +1,7 @@
import * as t from "@babel/types";
import type { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
} from "./javascriptAstValues.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import {
resolveSemanticBindingState,
@@ -289,13 +285,8 @@ const moduleObjectContained = (
state: JavaScriptSemanticAnalysisState,
): boolean => {
let contained = true;
const writtenTargets = new Set<t.Node>();
traverseJavaScriptValueAst(loader.body, state.erasedSyntaxNodes, {
enter: (node, parent, readAncestors) => {
const target = semanticWriteSiteTarget(node);
if (target !== null)
for (const leaf of semanticWriteTargets(target))
writtenTargets.add(leaf);
if (!contained || !t.isIdentifier(node) || parent === null) return;
const ancestors = readAncestors();
if (semanticIdentifierRole(node, parent, () => ancestors) === null)
@@ -303,7 +294,7 @@ const moduleObjectContained = (
if (bindingOf(state, node) !== moduleId) return;
contained =
!ancestors.some((ancestor) => t.isFunction(ancestor)) &&
moduleUse(node, parent, dispatch, writtenTargets.has(parent));
moduleUse(node, [parent, ancestors.at(-2)], dispatch);
},
});
return contained;
@@ -311,18 +302,21 @@ const moduleObjectContained = (
const moduleUse = (
node: t.Identifier,
parent: t.Node,
[parent, grandparent]: readonly [t.Node, t.Node | undefined],
dispatch: t.CallExpression,
written: boolean,
): boolean => {
if (parent === dispatch) return dispatch.arguments.includes(node);
if (!t.isMemberExpression(parent) || parent.object !== node) return false;
const key = semanticStaticPropertyKey(parent.property, parent.computed);
const written =
(t.isAssignmentExpression(grandparent) && grandparent.left === parent) ||
t.isUpdateExpression(grandparent) ||
t.isUnaryExpression(grandparent, { operator: "delete" });
if (written) return key !== null && key !== "exports";
if (key !== "exports") return false;
// `module.exports` passed to the factory, or read after the dispatch ran.
return (
dispatch.arguments.some((argument) => argument === parent) ||
grandparent === dispatch ||
(parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY)
);
};
@@ -10,54 +10,9 @@ import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import {
createGlobalPathReader,
createGlobalReferenceReader,
} from "./javascriptSemanticAmbientWrites.js";
import { createGlobalReferenceReader } from "./javascriptSemanticAmbientWrites.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
it.each([
[
"await value; Object.prototype.then=null; await value;",
["unmodified", "null"],
],
[
"Object.prototype.then=function(resolve){resolve()}; await value;",
["function"],
],
["if(flag) Object.prototype.then=null; await value;", ["unknown"]],
["mutate(Object.prototype); await value;", ["unknown"]],
["await value; mutate(Object.prototype);", ["unmodified"]],
["Array.prototype.then=null; await value;", ["unmodified"]],
] as const)(
"reads intrinsic property facts at implicit await sites: %s",
(source, expected) => {
const parsed = parseJavaScriptSource(source, "app.mjs");
if (parsed === null) throw new Error("Expected implicit read fixture");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const read = createGlobalPathReader(state, ["Object", "prototype", "then"]);
const statuses: string[] = [];
traverseJavaScriptAst(parsed.program, {
enter(node) {
if (!t.isAwaitExpression(node)) return;
const fact = read(node);
statuses.push(
fact.status === "replacement"
? t.isNullLiteral(fact.value)
? "null"
: t.isFunction(fact.value)
? "function"
: "other"
: fact.status,
);
},
});
expect(statuses).toEqual(expected);
},
);
it("keeps wrapper module aliases distinct from global-object module properties", () => {
const parsed = parseJavaScriptSource(
`
@@ -351,11 +306,6 @@ it.each([
"Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
["replacement"],
],
[
"an escaped global owner",
"mutate(Object);inspect(Object.defineProperty);",
["unknown"],
],
[
"a namespaced global",
"inspect(globalThis.Object.defineProperty);globalThis.Object.defineProperty=()=>{};inspect(globalThis.Object.defineProperty);",
@@ -902,92 +902,37 @@ export const createGlobalReferenceReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((callee: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
return namespace === undefined
? { status: "unrelated" }
: read(callee, namespace);
};
};
/** Read an implicit intrinsic property at its actual source site, without inventing a callee. */
export const createGlobalPathReader = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node) => GlobalReferenceRead) => {
const read = createGlobalPathRead(state, path);
return (site) => read(site, null);
};
const createGlobalPathRead = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
): ((site: t.Node, namespace: string | null) => GlobalReferenceRead) => {
const selected = ["global", ...path];
let writesGeneration = -1;
let escapesGeneration = -1;
let generation = -1;
const byNamespace = new Map<
string | null,
{
writes: typeof state.ambientWriteEffects;
escapes: typeof state.ambientEscapeEffects;
}
typeof state.ambientWriteEffects
>();
return (site, namespace) => {
const covered = (paths: typeof state.ambientWrites): boolean =>
paths.covers(selected) ||
(namespace !== null && paths.covers(["global", namespace]));
const writesCovered = covered(state.ambientWrites);
const escapesCovered = covered(state.ambientEscapes);
if (!writesCovered && !escapesCovered) return { status: "unmodified" };
return (callee) => {
const namespace = globalReferenceNamespace(callee, state, path);
if (namespace === undefined) return { status: "unrelated" };
if (
writesGeneration !== state.ambientWriteEffects.length ||
escapesGeneration !== state.ambientEscapeEffects.length
) {
writesGeneration = state.ambientWriteEffects.length;
escapesGeneration = state.ambientEscapeEffects.length;
!state.ambientWrites.covers(selected) &&
(namespace === null || !state.ambientWrites.covers(["global", namespace]))
)
return { status: "unmodified" };
if (generation !== state.ambientWriteEffects.length) {
generation = state.ambientWriteEffects.length;
byNamespace.clear();
}
let effects = byNamespace.get(namespace);
if (effects === undefined) {
const matches = (
effect: (typeof state.ambientWriteEffects)[number],
): boolean =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace]));
effects = {
writes: state.ambientWriteEffects.filter(matches),
escapes: state.ambientEscapeEffects.filter(matches),
};
effects = state.ambientWriteEffects.filter(
(effect) =>
ambientEffectCovers(effect, selected) ||
(namespace !== null &&
ambientEffectCovers(effect, ["global", namespace])),
);
byNamespace.set(namespace, effects);
}
// The call that escapes a receiver contains the callee being identified.
// That escape cannot have replaced the callee before the call selects it.
const escapePrecedesRead = effects.escapes.some((effect) => {
const escape = effect.node;
if (
escape !== undefined &&
escape !== site &&
typeof escape.start === "number" &&
typeof escape.end === "number" &&
typeof site.start === "number" &&
typeof site.end === "number" &&
site.start >= escape.start &&
site.end <= escape.end
)
return false;
return semanticEffectTiming(site, escape, state) !== "after";
});
if (
(escapesCovered && effects.escapes.length === 0) ||
escapePrecedesRead ||
(writesCovered && effects.writes.length === 0)
)
return { status: "unknown" };
const active = effects.writes.filter(
(effect) => semanticEffectTiming(site, effect.node, state) !== "after",
if (effects.length === 0) return { status: "unknown" };
const active = effects.filter(
(effect) => semanticEffectTiming(callee, effect.node, state) !== "after",
);
if (active.length === 0) return { status: "unmodified" };
if (
@@ -998,57 +943,47 @@ const createGlobalPathRead = (
)
)
return { status: "unknown" };
return globalPathReplacement(state, path, site, active);
let replacement: t.AssignmentExpression | undefined;
for (const effect of active) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
callee,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
active.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
};
};
/** Select a definite replacement only when every competing write is proven earlier. */
const globalPathReplacement = (
state: JavaScriptSemanticAnalysisState,
path: readonly [string, ...string[]],
site: t.Node,
effects: typeof state.ambientWriteEffects,
): GlobalReferenceRead => {
let replacement: t.AssignmentExpression | undefined;
for (const effect of effects) {
const write = effect.node;
const writeNamespace = t.isAssignmentExpression(write)
? globalReferenceNamespace(write.left, state, path)
: undefined;
if (
t.isAssignmentExpression(write, { operator: "=" }) &&
writeNamespace !== undefined &&
(writeNamespace === null ||
!state.ambientWrites.covers(["global", writeNamespace])) &&
semanticDefiniteEffectPrecedesReference(
write,
site,
state.parentsByNode,
) &&
(replacement === undefined ||
semanticDefiniteEffectPrecedesReference(
replacement,
write,
state.parentsByNode,
))
)
replacement = write;
}
if (replacement === undefined) return { status: "unknown" };
const confirmed = replacement;
if (
effects.some(
(effect) =>
effect.node !== confirmed &&
semanticEffectTiming(confirmed, effect.node, state) !== "before" &&
(effect.node === undefined ||
!semanticDefiniteEffectPrecedesReference(
effect.node,
confirmed,
state.parentsByNode,
)),
)
)
return { status: "unknown" };
return { status: "replacement", value: confirmed.right };
};
@@ -249,20 +249,3 @@ it("does not initialize a class self name during computed-key evaluation", () =>
expect(ir.callSites[0]).toBeDefined();
expect(ir.callSites[0]?.resolution).not.toBe("exact");
});
it.each(["__proto__", '"__proto__"', "['__proto__']"])(
"distinguishes prototype setters from own data keys in loader-write scoping: %s",
(key) => {
const ir = analyze(`
with ({require: local, [Symbol.unscopables]: {${key}: {require: true}}}) {
require = replacement;
}
const selected = require("node:fs");
`);
const selected = onlyBinding(ir, "selected");
if (key.startsWith("[")) {
// A computed key is an own data property, so it supplies no inherited exclusion.
expect(selected.provenance.status).toBe("module");
} else expect(selected.provenance.status).not.toBe("module");
},
);
@@ -1,11 +1,7 @@
import { describe, expect, it } from "vitest";
import { runInNewContext } from "node:vm";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
onlyBinding,
onlyCallable,
} from "./javascriptSemanticAnalysis.fixture.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
const resultValue = (body: string) =>
onlyCallable(
@@ -13,128 +9,6 @@ const resultValue = (body: string) =>
"result",
).returnSites[0]?.value;
it.each([
[
"own getter",
"const value = { count: 1, get then() { this.count = 2; } };",
2,
],
[
"own method",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"function-valued slot",
"const value = { count: 1, then: function(resolve) { this.count = 2; resolve(); } };",
2,
],
[
"getter returning a method",
"const value = { count: 1, get then() { return function(resolve) { this.count = 2; resolve(); }; } };",
2,
],
[
"inherited getter",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"inherited method",
"const proto = { then(resolve) { this.count = 2; resolve(); } }; const value = { __proto__: proto, count: 1 };",
2,
],
[
"class method",
"class Value { count = 1; then(resolve) { this.count = 2; resolve(); } } const value = new Value();",
2,
],
[
"stored method",
"const value = { count: 1 }; value.then = function(resolve) { this.count = 2; resolve(); };",
2,
],
[
"method stored on a copied shared child",
"const source = { child: { count: 1 } }; const box = { ...source }; source.child.then = function(resolve) { this.count = 2; resolve(); }; const value = box.child;",
2,
],
[
"copied method retained after source replacement",
"const source = { count: 1, then(resolve) { this.count = 2; resolve(); } }; const value = { ...source }; source.then = null;",
2,
],
["inert object", "const value = { count: 1 };", 1],
["noncallable then", "const value = { count: 1, then: null };", 1],
[
"noncallable getter result",
"const value = { count: 1, get then() { return 3; } };",
1,
],
[
"own noncallable shadow",
"const proto = { get then() { this.count = 2; } }; const value = { __proto__: proto, count: 1, then: null };",
1,
],
[
"method replaced before await",
"const value = { count: 1, then(resolve) { this.count = 2; resolve(); } }; value.then = null;",
1,
],
] as const)(
"accounts for thenable effects at await: %s",
async (_kind, declaration, expected) => {
const body = `${declaration} await value; return value.count;`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it("keeps a then slot captured before a later top-level source write", async () => {
const body = `
const source = { count: 1 };
const value = { ...source };
source.then = function(resolve) { this.count = 2; resolve(); };
await value;
const observed = value.count;
`;
expect(
await runInNewContext(`(async () => { ${body} return observed; })()`),
).toBe(1);
expect(
onlyBinding(analyzeJavaScriptSemantics(body), "observed").value,
).toEqual({ status: "literal", value: 1 });
});
it.each([
"const value = { count: 1, then: external };",
"const value = { count: 1, then: null }; value.then = external;",
])("keeps an externally supplied then callable possible: %s", async (slot) => {
const body = `${slot} await value; return value.count;`;
for (const external of [
null,
function (this: { count: number }, resolve: () => void) {
this.count = 2;
resolve();
},
]) {
expect(
await runInNewContext(`(async () => { ${body} })()`, { external }),
).toBe(external === null ? 1 : 2);
}
const ir = analyzeJavaScriptSemantics(
`export async function result(external) { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
["field = shared;", "declare field: { mode: string };", "new Derived()"],
[
@@ -167,83 +41,6 @@ it.each([
},
);
it("retains then invocation effects through a getter-invoked async callable", async () => {
const body = `
const value = {
count: 1,
get start() { (async () => { await this; })(); return null; },
then(resolve) { this.count = 2; resolve(); },
};
await value.start;
return value.count;
`;
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(2);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe(
"unknown",
);
});
it.each([
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { count: 1, then: null }; await value; return value.count;",
1,
],
[
"Object.prototype.then = function(resolve) { this.count = 2; resolve(); }; const value = { __proto__: null, count: 1 }; await value; return value.count;",
1,
],
[
"Array.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; value.then = null; await value; return value[0];",
1,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.then = null; const value = [1]; await value; return value[0];",
1,
],
[
"Object.prototype.then = null; const value = { count: 1 }; await value; return value.count;",
1,
],
[
"Object.defineProperty(Object.prototype, 'then', { get() { this.count = 2; } }); const value = { count: 1 }; await value; return value.count;",
2,
],
[
"Array.prototype.__proto__ = { then(resolve) { this[0] = 2; resolve(); } }; const value = [1]; await value; return value[0];",
2,
],
[
"Object.prototype.then = function(resolve) { this[0] = 2; resolve(); }; Array.prototype.__proto__ = null; const value = [1]; await value; return value[0];",
1,
],
] as const)(
"retains source-written prototype then invocation: %s",
async (body, expected) => {
expect(await runInNewContext(`(async () => { ${body} })()`)).toBe(expected);
const ir = analyzeJavaScriptSemantics(
`export async function result() { ${body} }`,
);
const value = onlyCallable(ir, "result").returnSites[0]?.value;
if (expected === 2) expect(value?.status).toBe("unknown");
else expect(value).toEqual({ status: "literal", value: 1 });
},
);
it.each(["field;", "field = { mode: 'other' };"])(
"preserves a real derived field's shadowing: %s",
(field) => {
@@ -269,18 +269,3 @@ describe("JavaScript semantic analysis: configuration value escapes", () => {
expect(configurationReads(body)).toEqual([]);
});
});
it.each([
["if (flag) child = spawn('tool');", "partial"],
["child = spawn('tool');", "complete"],
] as const)(
"keeps child-process incoming parameter uncertainty after %s",
(assignment, resolution) => {
const ir = analyzeJavaScriptSemantics(
`import { spawn } from 'node:child_process'; function stop(child, flag) { ${assignment} child.kill(); }`,
);
expect(ir.childProcessInteractions).toEqual([
expect.objectContaining({ kind: "signal", resolution }),
]);
},
);
@@ -28,13 +28,6 @@ const eventsFor = (call: string) =>
}));
it.each([
["a missing on listener", `e.on("x")`],
["a missing once listener", `e.once("x")`],
["a missing addListener listener", `e.addListener("x")`],
["a missing prependListener listener", `e.prependListener("x")`],
["a missing prependOnceListener listener", `e.prependOnceListener("x")`],
["a missing removeListener listener", `e.removeListener("x")`],
["a missing off listener", `e.off("x")`],
["a numeric listener", `e.on("x", 1)`],
["a string listener", `e.on("x", "fn")`],
["a template-literal listener", 'e.on("x", `fn`)'],
@@ -22,54 +22,6 @@ const releases = (source: string) =>
resolution,
}));
it.each([
["net", "connect", "-0"],
["net", "createConnection", "-0.0"],
["tls", "connect", "-0"],
["net", "connect", "typeof 1.5"],
["net", "connect", "~(-1.5)"],
["tls", "connect", "void 0, { port: 443 }"],
] as const)(
"retains %s.%s(%s) and its socket release when the unary result is valid",
(module, method, argument) => {
expect(
operations(
`import socket from "node:${module}"; const handle = socket.${method}(${argument}); handle.destroy();`,
).map(({ kind, method, resolution }) => ({ kind, method, resolution })),
).toEqual([
{ kind: "acquire", method, resolution: "complete" },
{ kind: "release", method: "destroy", resolution: "complete" },
]);
},
);
it.each([
["connect", "void 0"],
["createConnection", "void value"],
["connect", "undefined"],
["connect", "void 0, { port: 443 }"],
] as const)(
"emits no net.%s acquisition or release for a missing first argument: %s",
(method, argument) => {
expect(
operations(
`import net from 'node:net'; const handle = net.${method}(${argument}); handle.destroy();`,
),
).toEqual([]);
},
);
it("retains unknown net argument positions and shadowed undefined values", () => {
for (const body of [
"function use(undefined) { const handle=net.connect(undefined);handle.destroy(); }",
"const handle=net.connect(...args, void 0);handle.destroy();",
])
expect(operations(`import net from 'node:net';${body}`)).toEqual([
expect.objectContaining({ kind: "acquire", method: "connect" }),
expect.objectContaining({ kind: "release", method: "destroy" }),
]);
});
const commonJsPromise = `const { open } = require("node:fs/promises"); const h = open("input.txt"); h.close();`;
it("keeps conditional release facts when the loader is unknown", () => {
@@ -246,17 +198,3 @@ it("keeps an awaited handle release uncertain after an opaque receiver write", (
),
).toEqual([{ method: "close", linked: 1, resolution: "partial" }]);
});
it.each([
["if (flag) handle = createReadStream('input.txt');", "partial"],
["handle = createReadStream('input.txt');", "complete"],
] as const)(
"preserves incoming parameter uncertainty before release: %s",
(assignment, resolution) => {
expect(
releases(
`import { createReadStream } from 'node:fs'; function release(handle, flag) { ${assignment} handle.destroy(); }`,
),
).toEqual([{ method: "destroy", linked: 1, resolution }]);
},
);
@@ -16,8 +16,7 @@ import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
* net.connect("") -> THREW ERR_SOCKET_BAD_PORT
* net.connect("/tmp/x.sock") -> Socket created (Unix socket path)
* net.connect({ port: 80 }) -> Socket created
* net.connect() / undefined / void 0 -> THREW ERR_MISSING_ARGS
* net.connect({}) / [] -> THREW ERR_MISSING_ARGS (undecided here)
* net.connect(undefined) / {} / [] -> THREW ERR_MISSING_ARGS (undecided here)
*/
const socketAcquisitionsFor = (call: string) =>
analyzeJavaScriptSemantics(
@@ -35,10 +34,6 @@ it.each([
["a boolean port", `net.connect(true)`],
["a null port", `net.connect(null)`],
["an empty path", `net.connect("")`],
["an omitted argument", `net.connect()`],
["an explicit undefined port", `net.connect(undefined)`],
["a void port", `net.connect(void 0)`],
["an omitted createConnection argument", `net.createConnection()`],
["a tls port above the range", `tls.connect(65536)`],
["a tls negative port", `tls.connect(-1)`],
["a tls NaN port", `tls.connect(NaN)`],
@@ -54,12 +49,8 @@ it.each([
["the highest port", `net.connect(65535)`, "connect"],
["a Unix socket path", `net.connect("/tmp/x.sock")`, "connect"],
["an options object", `net.connect({ port: 80 })`, "connect"],
["a computed argument", `net.connect(p)`, "connect"],
[
"a TLS port supplied by later options",
`tls.connect(void 0, { port: 443 })`,
"connect",
],
["an omitted argument", `net.connect()`, "connect"],
["an explicit undefined port", `net.connect(undefined)`, "connect"],
["a tls port", `tls.connect(80)`, "connect"],
["a createConnection port", `net.createConnection(80)`, "createConnection"],
] as const)("keeps the socket acquisition for %s", (_name, call, method) => {
@@ -16,6 +16,7 @@ import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js";
@@ -159,10 +160,7 @@ export const collectJavaScriptSemanticAsyncEffects = (
*
* A class expression passes Node's own `typeof` check, so it is kept.
*/
export const rejectsSemanticEventListener = (
listener: t.Node | undefined,
): boolean => {
if (listener === undefined) return true;
const rejectsEventListener = (listener: t.Node): boolean => {
if (t.isSpreadElement(listener)) return false;
const value = unwrapJavaScriptExpression(listener).node;
if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value))
@@ -206,9 +204,9 @@ const collectEvent = (
// provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the
// trailing `1`.
if (
EVENT_LISTENER_METHODS.includes(method) &&
!t.isSpreadElement(eventArgument) &&
rejectsSemanticEventListener(listenerArgument)
listenerArgument !== undefined &&
rejectsEventListener(listenerArgument)
)
return;
const emitter = emitterIdentity(member.object, state);
@@ -7,25 +7,6 @@ const analyze = (source: string) =>
`import { spawn } from 'node:child_process';\n${source}`,
);
it.each(
["on", "once", "addListener"].flatMap((method) =>
["", ", 1", ", null", ", {}", ", void 0"].map((listener) => ({
method,
listener,
})),
),
)(
"does not invent a child $method registration with rejected listener $listener",
({ method, listener }) => {
const ir = analyze(
`const child = spawn('worker'); child.${method}('exit'${listener});`,
);
expect(ir.childProcessSpawns).toHaveLength(1);
expect(ir.childProcessInteractions).toEqual([]);
expect(ir.eventOperations).toEqual([]);
},
);
it("keeps interactions before replacement resolved and later ownership uncertain", () => {
const ir = analyze(`
let child = spawn('old');
@@ -7,7 +7,6 @@ import type {
JavaScriptSemanticChildProcessSpawn,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import { rejectsSemanticEventListener } from "./javascriptSemanticAsyncEffects.js";
import {
createSemanticExpressionShapeResolver,
optionsAfterOptionalArgv,
@@ -226,11 +225,6 @@ const childInteraction = (
const method = semanticStaticPropertyName(member.property, member.computed);
const classification = classifyInteraction(method, node.arguments[0]);
if (classification === null) return null;
if (
classification.kind === "listener" &&
rejectsSemanticEventListener(node.arguments[1])
)
return null;
const binding = objectBinding(member.object, state);
const { candidates: linked, complete } = resolveChildCandidates(
member.object,
@@ -30,8 +30,6 @@ export interface SemanticPropertyReadSource
extends IterationSourceValue, SourceGuards {
readonly mutation: t.Node;
readonly lookup: "prototype" | "own";
/** The language protocol invokes a callable value read from this slot. */
readonly invoke?: true;
}
interface PatternSource extends IterationSourceValue {
@@ -339,7 +337,6 @@ const semanticSourceFacts = (
t.isAssignmentExpression(node) ||
t.isForOfStatement(node) ||
t.isSpreadElement(node) ||
t.isAwaitExpression(node) ||
t.isYieldExpression(node) ||
t.isCatchClause(node) ||
t.isFunction(node)
@@ -375,15 +372,11 @@ const semanticSourceFacts = (
mutation: node,
lookup: "own",
});
} else if (t.isAwaitExpression(node))
propertyReads.push({
node: node.argument,
projection: ["then"],
mutation: node,
lookup: "prototype",
invoke: true,
});
else if (t.isYieldExpression(node) && node.delegate && node.argument != null)
} else if (
t.isYieldExpression(node) &&
node.delegate &&
node.argument != null
)
add(sources, { node: node.argument, projection: [] }, node, {});
else if (
t.isCatchClause(node) &&
@@ -2,7 +2,6 @@ import * as t from "@babel/types";
import { semanticKnownSymbolKey } from "./javascriptSemanticPropertyKeys.js";
import {
createAmbientPathResolver,
createGlobalPathReader,
retainSemanticAmbientEffect,
} from "./javascriptSemanticAmbientWrites.js";
import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js";
@@ -38,7 +37,6 @@ import {
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticProjectedSourceFactsAtRead,
semanticCapturedSelectionsAfterPropertyRead,
semanticShiftCapturedSelections,
semanticNormalizeCapturedSelections,
type SemanticCapturedSelection,
@@ -51,7 +49,6 @@ import {
semanticClassAccessorReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
semanticNativePrototypeChain,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
@@ -125,55 +122,27 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Local methods use the same slots, prototype shadowing and array indices as aliases. */
const localPropertyMethods = (
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
const localAccessorMethods = (
root: t.Node,
path: PropertyPath,
accessor: "get" | "set" | "invoke",
accessor: "get" | "set",
state: JavaScriptSemanticAnalysisState,
keyMayExist: (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
lookup: "prototype" | "own" = "prototype",
): readonly t.Function[] | undefined => {
if (accessor !== "invoke" && !keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.Function>();
const pending: (MutationSelectionContext & {
node: t.Node;
path: PropertyPath;
})[] = [{ node: root, path, memberReadAt: root }];
): readonly (t.ObjectMethod | t.ClassMethod)[] | undefined => {
if (!keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.ObjectMethod | t.ClassMethod>();
const pending = [{ node: root, path }];
const examined = new WeakMap<t.Node, Set<string>>();
const nativeReaders = new Map<
string,
ReturnType<typeof createGlobalPathReader>
>();
const nativeRead = (
prototype: "Object" | "Array",
key: string,
at: t.Node,
) => {
const identity = JSON.stringify([prototype, key]);
let read = nativeReaders.get(identity);
if (read === undefined) {
read = createGlobalPathReader(state, [prototype, "prototype", key]);
nativeReaders.set(identity, read);
}
return read(at);
};
let selections = 0;
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor]!;
const node = unwrapJavaScriptExpression(current.node).node;
const key = JSON.stringify([
current.path,
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
const key = JSON.stringify(current.path);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
@@ -183,10 +152,7 @@ const localPropertyMethods = (
return undefined;
}
if (
t.isFunction(node) &&
(accessor === "invoke" ||
t.isObjectMethod(node) ||
t.isClassMethod(node)) &&
(t.isObjectMethod(node) || t.isClassMethod(node)) &&
current.path.length === 0
) {
methods.add(node);
@@ -194,42 +160,15 @@ const localPropertyMethods = (
}
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) {
if (accessor === "invoke") return undefined;
continue;
}
if (
accessor === "invoke" &&
!semanticBindingAtRead(binding, node, state).hasDefiniteInitializer
)
return undefined;
const selected = semanticProjectedSourceFactsAtRead(
binding,
node,
current.path,
state,
true,
current.memberReadAt,
current.capturedSelections,
false,
// A spread copy escapes the copied values. That must not make the
// source's own accessors unknown, or the next read escapes the
// receiver itself.
accessor === "invoke",
);
if (selected === undefined) return undefined;
for (const source of selected.sources)
if (binding === undefined) continue;
const selected = semanticBindingAtRead(binding, node, state).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
pending.push({
node: source.node,
path: source.path,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - current.path.length,
) ?? []),
...(source.capturedSelections ?? []),
]),
path: [...source.projection, ...current.path],
});
} else if (
t.isMemberExpression(node) ||
@@ -241,57 +180,15 @@ const localPropertyMethods = (
semanticStaticPropertyKey(node.property, node.computed),
...current.path,
],
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
1,
),
});
} else if (
current.path.length > 0 &&
(t.isObjectExpression(node) || t.isArrayExpression(node))
) {
const [selected, ...remaining] = current.path;
if (
accessor === "invoke" &&
lookup === "prototype" &&
!current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
) {
for (const prototype of semanticNativePrototypeChain(node, selected!)) {
if (selected === null || typeof selected === "object")
return undefined;
const at = current.memberReadAt ?? root;
const member = nativeRead(prototype, String(selected), at);
if (member.status === "unknown" || member.status === "unrelated")
return undefined;
if (member.status === "replacement") {
pending.push({
node: member.value,
path: remaining,
memberReadAt: at,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
break;
}
// A changed native link selects its retained source rather than the
// default next prototype. Null naturally ends that lookup.
const link = nativeRead(prototype, "__proto__", at);
if (link.status === "unknown" || link.status === "unrelated")
return undefined;
if (link.status === "replacement") {
pending.push({ ...current, node: link.value });
break;
}
}
}
const references = t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: remaining.length === 0 && accessor !== "invoke"
: remaining.length === 0
? semanticObjectAccessorReferences(
node,
selected!,
@@ -300,24 +197,11 @@ const localPropertyMethods = (
lookup,
)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references) {
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (
(t.isClass(node) || t.isNewExpression(node)) &&
current.path.length > 0
@@ -334,39 +218,23 @@ const localPropertyMethods = (
const references =
remaining.length > 0
? semanticClassPropertyReferences(selection, [selected!], state)
: accessor === "invoke"
? semanticClassPropertyReferences(selection, [selected!], state)
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
: lookup === "own"
? []
: semanticClassAccessorReferences(
selection,
selected!,
state,
accessor,
);
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
memberReadAt: current.memberReadAt,
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else {
const references = branchReferenceValues(node, current.path);
if (accessor === "invoke" && references.length === 0) {
const value = evaluateSemanticExpression(
node,
state,
current.memberReadAt,
);
if (!["literal", "union", "object", "array"].includes(value.status))
return undefined;
}
for (const source of references) pending.push({ ...current, ...source });
for (const source of branchReferenceValues(node, current.path))
pending.push(source);
}
}
return [...methods];
@@ -379,7 +247,6 @@ type AccessorEffectOperand =
readonly node: t.Node;
readonly path: PropertyPath;
readonly lookup: "prototype" | "own";
readonly invoke?: true;
readonly guards?: SemanticPropertyReadSource;
}
| {
@@ -466,7 +333,6 @@ const accessorEffectOperands = (
node: source.node,
path: source.projection,
lookup: source.lookup,
...(source.invoke ? { invoke: true } : {}),
guards: source,
});
if (
@@ -517,14 +383,13 @@ export function* collectSemanticMemberMutationsSteps(
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const propertyReadEffects = (
const getterEffects = (
root: t.Node,
path: PropertyPath,
lookup: "prototype" | "own" = "prototype",
invoke = false,
): readonly JavaScriptSemanticObjectReference[] => {
if (!invoke && !accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup, invoke }];
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [{ node: root, path, lookup }];
const examined = new Set<string>();
const effects: JavaScriptSemanticObjectReference[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
@@ -532,14 +397,12 @@ export function* collectSemanticMemberMutationsSteps(
node: selectedRoot,
path: selected,
lookup: selectedLookup,
invoke: selectedInvoke,
} = pending[cursor]!;
const identity = JSON.stringify([
selectedRoot.start,
selectedRoot.end,
selected,
selectedLookup,
selectedInvoke,
]);
if (examined.has(identity)) continue;
examined.add(identity);
@@ -547,20 +410,7 @@ export function* collectSemanticMemberMutationsSteps(
state.valueResourceLimits.add("alias-selections");
return [{ node: root, path: [] }];
}
const receiver = selected.slice(0, -1);
if (
selectedInvoke &&
(localPropertyMethods(
selectedRoot,
selected,
"invoke",
state,
accessorKeyMayExist,
selectedLookup,
)?.length ?? 1) > 0
)
effects.push({ node: selectedRoot, path: receiver });
const methods = localPropertyMethods(
const methods = localAccessorMethods(
selectedRoot,
selected,
"get",
@@ -568,12 +418,12 @@ export function* collectSemanticMemberMutationsSteps(
accessorKeyMayExist,
selectedLookup,
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
effects.push({ node: selectedRoot, path: receiver });
continue;
}
for (const method of methods) {
if (!t.isObjectMethod(method) && !t.isClassMethod(method)) continue;
const callables: t.Function[] = [method];
const visitedCallables = new Set<t.Function>();
for (let index = 0; index < callables.length; index++) {
@@ -591,8 +441,7 @@ export function* collectSemanticMemberMutationsSteps(
}
if (
operand.kind === "read" &&
((!operand.invoke &&
!accessorKeyMayExist(operand.path.at(-1), "get")) ||
(!accessorKeyMayExist(operand.path.at(-1), "get") ||
(operand.guards !== undefined &&
semanticReferenceSourcesInactive(operand.guards, state)))
)
@@ -616,7 +465,6 @@ export function* collectSemanticMemberMutationsSteps(
node: targetRoot,
path: [...target, ...operand.path],
lookup: operand.lookup,
invoke: operand.invoke === true,
});
}
}
@@ -1266,7 +1114,7 @@ export function* collectSemanticMemberMutationsSteps(
if (
origins.initializers.some(
(source) =>
(localPropertyMethods(
(localAccessorMethods(
source.node,
[...source.projection, ...selectedPath],
"set",
@@ -1748,7 +1596,7 @@ export function* collectSemanticMemberMutationsSteps(
source.kind === "reference-store" &&
source.target !== undefined &&
!state.escapingReferenceStores.has(source.target) &&
(localPropertyMethods(
(localAccessorMethods(
source.target.object,
[
semanticStaticPropertyKey(
@@ -1810,18 +1658,17 @@ export function* collectSemanticMemberMutationsSteps(
}
for (const source of semanticPropertyReadSources(node, parent, state)) {
if (semanticReferenceSourcesInactive(source, state)) continue;
for (const effect of propertyReadEffects(
for (const effect of getterEffects(
source.node,
source.projection,
source.lookup,
source.invoke === true,
))
markEscaped(effect.node, source.mutation, effect.path);
}
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// The write owner classifies direct and destructuring stores once.
if (!writeOnlyMembers.has(node))
for (const effect of propertyReadEffects(node.object, [
for (const effect of getterEffects(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(effect.node, node, effect.path);
@@ -4,7 +4,6 @@ import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
semanticClassPropertyReferences,
semanticNativePrototypeChain,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
@@ -37,37 +36,6 @@ const spelled = (
],
);
it.each([
["{}", "then", ["Object"]],
["{then:null}", "then", []],
["{then}", "then", []],
["{...{then:null}}", "then", []],
["{...unknown}", "then", ["Object"]],
["{__proto__:null}", "then", []],
["{__proto__:{}}", "then", []],
["{__proto__:0}", "then", ["Object"]],
["{__proto__:unknown}", "then", ["Object"]],
['{["__proto__"]:null}', "then", ["Object"]],
["[]", "then", ["Array", "Object"]],
["[]", "length", []],
["[value]", "0", []],
["[,value]", "0", ["Array", "Object"]],
["[value,...unknown]", "0", []],
["[...unknown,value]", "0", ["Array", "Object"]],
] as const)(
"retains possible native prototype lookup for %s[%s]",
(value, key, expected) => {
const parsed = parseJavaScriptSource(`const o=${value};`, "prototype.mjs");
const declaration = parsed?.program.body[0];
if (!t.isVariableDeclaration(declaration))
throw new Error("Expected declaration");
const node = declaration.declarations[0]?.init;
if (!t.isObjectExpression(node) && !t.isArrayExpression(node))
throw new Error("Expected literal");
expect(semanticNativePrototypeChain(node, key)).toEqual(expected);
},
);
it("resolves exact keys with source-order overrides, spreads and accessors", () => {
const fixture = objectAndState(
`const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`,
@@ -86,13 +54,14 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
["4", "[]"],
["base", '["missing"]'],
]);
// A wildcard keeps possible slots, excluding data replaced by a known spread.
// A wildcard key still scans every property.
expect(spelled(fixture, null)).toEqual([
["7", "[]"],
["{ b: 5 }", '[{"excludedKeys":["c"]}]'],
["proto", "[null]"],
["4", "[]"],
["3", "[]"],
["2", "[]"],
["base", '[{"excludedKeys":["a","b","c"]}]'],
]);
// Numeric keys use the same exact spelling as string keys.
@@ -101,112 +70,6 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
).toEqual([['"one"', "[]"]]);
});
it.each([1, "1"])(
"preserves inherited numeric property selection %j and own shadowing",
(key) => {
expect(
spelled(objectAndState("const o = { __proto__: prototype };"), key),
).toEqual([["prototype", JSON.stringify([String(key)])]]);
expect(
spelled(
objectAndState('const o = { __proto__: prototype, 1: "own" };'),
key,
),
).toEqual([['"own"', "[]"]]);
},
);
it.each([
[
"static",
"static method(){return 1;} method(){return 2;}",
"static method(){return 1;}",
],
[
"instance",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
[
"prototype",
"static method(){return 1;} method(){return 2;}",
"method(){return 2;}",
],
["static", "static method(){return 1;} static set method(value){}", null],
["static", "static method=child; static method(){return 1;}", "child"],
[
"static",
"static get method(){return child;} static method(){return 1;}",
"static method(){return 1;}",
],
[
"static",
"static method(){return 1;} static get method(){return child;}",
"child",
],
[
"static",
"static get method(){return child;} static method(){return 1;} static set method(value){}",
null,
],
] as const)(
"selects method-valued %s descriptors with runtime precedence: %s",
(kind, body, expected) => {
const source = `class Box { ${body} }`;
const parsed = parseJavaScriptSource(source, "methods.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[0];
if (!t.isClassDeclaration(owner)) throw new Error("Expected a class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
expect(
semanticClassPropertyReferences({ owner, kind }, ["method"], state).map(
({ node }) => source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(expected === null ? [] : [expected]);
},
);
it.each(["method(){return 1;}", "...{method: child}"])(
"keeps an intervening object data definition between accessor pairs: %s",
(definition) => {
expect(
spelled(
objectAndState(
`const o = { get method(){return child;} , ${definition}, set method(value){} };`,
),
"method",
),
).toEqual([]);
},
);
it.each(["static", "instance"] as const)(
"retains inherited %s field ownership for enumerable copies",
(kind) => {
const source = `class Base { ${kind === "static" ? "static" : ""} method=child; } class Box extends Base {}`;
const parsed = parseJavaScriptSource(source, "fields.mjs");
if (parsed === null) throw new Error("Expected a parsed class");
const owner = parsed.program.body[1];
if (!t.isClassDeclaration(owner)) throw new Error("Expected derived class");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const references = semanticClassPropertyReferences(
{ owner, kind, fieldsOnly: true },
["method"],
state,
);
expect(
references.map(({ node }) =>
source.slice(node.start ?? 0, node.end ?? 0),
),
).toEqual(["child"]);
expect(references[0]?.inherited).toBe(kind === "static" ? true : undefined);
},
);
it("preserves references through a small static getter permutation", () => {
const getters = Array.from(
{ length: 5 },
@@ -154,73 +154,96 @@ const classPropertyDescriptors = (
initializedBefore?: t.ClassProperty,
accessor: "get" | "set" = "get",
) => {
const isStatic = kind === "static";
const selectedAccessors: t.ClassMethod[] = [];
const fields: t.ClassProperty[] = [];
const methods: t.ClassMethod[] = [];
const accessors: t.ClassMethod[] = [];
const definitions: (t.ClassMethod | t.ClassProperty)[] = [];
const indexedMembers: {
readonly member: t.ClassMethod | t.ClassProperty;
readonly index: number;
readonly key: string | null;
}[] = [];
const before =
initializedBefore === undefined
? -1
: klass.body.body.indexOf(initializedBefore);
for (const [index, member] of klass.body.body.entries()) {
klass.body.body.forEach((member, index) => {
if (
(!t.isClassMethod(member) &&
!(kind === "static" && isRuntimeJavaScriptClassProperty(member))) ||
member.static !== (kind === "static") ||
(before >= 0 &&
isRuntimeJavaScriptClassProperty(member) &&
index >= before) ||
(propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state))
(t.isClassMethod(member) || t.isClassProperty(member)) &&
propertyName !== null &&
member.computed &&
semanticKnownSymbolKey(member.key, state)
)
continue;
definitions.push(member);
}
// Methods/accessors are installed before static fields execute, even when a
// field appears earlier in source. Instance fields are owned separately.
const ordered = [
...definitions.filter((member) => t.isClassMethod(member)),
...definitions.filter((member) => t.isClassProperty(member)),
];
const data = new Set<string>();
const getterNames = new Set<string>();
const setterNames = new Set<string>();
let inherits = true;
for (const member of ordered.reverse()) {
const key = semanticStaticPropertyKey(member.key, member.computed);
if (propertyName !== null && key !== null && key !== propertyName) continue;
if (propertyName !== null && key === propertyName) inherits = false;
const descriptor =
t.isClassMethod(member) &&
(member.kind === "get" || member.kind === "set")
? member.kind
: "data";
const selected = key ?? propertyName;
return;
if (
selected !== null &&
(data.has(selected) ||
(descriptor === "data"
? getterNames.has(selected) || setterNames.has(selected)
: (descriptor === "get" ? getterNames : setterNames).has(selected)))
) {
// A later accessor replaced this data descriptor, but the data definition
// still prevents an earlier accessor from pairing across it.
if (descriptor === "data" && key !== null) data.add(key);
continue;
}
if (t.isClassProperty(member)) fields.push(member);
else if (member.kind === "method") methods.push(member);
else if (member.kind === accessor) accessors.push(member);
if (key !== null)
(descriptor === "data"
? data
: descriptor === "get"
? getterNames
: setterNames
).add(key);
before >= 0 &&
index >= before &&
isRuntimeJavaScriptClassProperty(member)
)
return;
if (
(t.isClassMethod(member) ||
(kind === "static" && isRuntimeJavaScriptClassProperty(member))) &&
member.static === isStatic
)
indexedMembers.push({
member,
index,
key: semanticStaticPropertyKey(member.key, member.computed),
});
});
const matchingMembers =
propertyName === null
? []
: indexedMembers.filter(({ key }) => key === propertyName);
const uncertainAccessors = indexedMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } => {
const { member, key } = entry;
return (
t.isClassMethod(member) &&
member.kind === accessor &&
(propertyName === null || (member.computed && key === null))
);
},
);
const getters = matchingMembers.filter(
(entry): entry is typeof entry & { member: t.ClassMethod } =>
t.isClassMethod(entry.member) && entry.member.kind === accessor,
);
const getter = getters.at(-1);
const getterIndex = getter?.index ?? -1;
const laterDataMember = matchingMembers.some(
({ member, index }) =>
t.isClassProperty(member) ||
(t.isClassMethod(member) &&
member.kind === "method" &&
index > getterIndex),
);
const seenFields = new Set<string>();
for (const { member, key } of [...indexedMembers].reverse()) {
if (!t.isClassProperty(member)) continue;
if (key !== null && seenFields.has(key)) continue;
if (key !== null) seenFields.add(key);
if (propertyName === null || key === null || key === propertyName)
fields.push(member);
}
return { accessors, fields, methods, inherits };
if (getters.length > 0 && !laterDataMember && getter !== undefined)
selectedAccessors.push(getter.member);
for (const { member, index } of uncertainAccessors)
if (
!matchingMembers.some(
({ member: matching, index: matchingIndex }) =>
(t.isClassProperty(matching) ||
(t.isClassMethod(matching) &&
(matching.kind === "method" || matching.kind === accessor))) &&
matchingIndex > index,
)
)
selectedAccessors.push(member);
return {
accessors: selectedAccessors,
fields,
inherits: matchingMembers.length === 0,
};
};
export const semanticClassPropertyReferences = (
@@ -322,21 +345,9 @@ class ClassReferenceWalk {
? descriptors.fields
: [])
if (field.value != null)
this.#references.push({
node: field.value,
path: this.#path,
...(this.lookup.kind === "static" && klass !== this.lookup.owner
? { inherited: true }
: {}),
});
this.#references.push({ node: field.value, path: this.#path });
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.accessors)
this.#getter(klass, getter);
if (this.context.accessor === undefined && !this.lookup.fieldsOnly)
for (const method of descriptors.methods) {
const name = semanticStaticPropertyKey(method.key, method.computed);
if (name !== null && this.#fields.has(name)) continue;
this.#references.push({ node: method, path: this.#path });
}
// Only a definite own descriptor can suppress possible superclass results.
if (descriptors.inherits && klass.superClass != null)
for (const base of semanticClassCandidates(klass.superClass, this.state))
@@ -548,18 +559,6 @@ const expandGetterResult = (
type ObjectLiteralMember = t.ObjectExpression["properties"][number];
/** Only this colon-form definition selects an object literal's prototype. */
const isObjectPrototypeSetter = (
property: ObjectLiteralMember,
name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed),
): property is t.ObjectProperty =>
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__";
/** Property positions grouped by whether an exact key can reach them. */
interface ObjectPropertyIndex {
/** Spreads, computed keys and `__proto__` data entries reach every key. */
@@ -580,7 +579,13 @@ const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => {
const name = t.isSpreadElement(property)
? null
: semanticStaticPropertyKey(property.key, property.computed);
if (name === null || isObjectPrototypeSetter(property, name)) {
if (
name === null ||
(t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__")
) {
keyIndependent.push(index);
return;
}
@@ -666,9 +671,7 @@ class ObjectReferenceWalk {
readonly accessor?: "get" | "set",
readonly accessorLookup: "prototype" | "own" = "prototype",
) {
const [key, ...remaining] = path;
this.#key = typeof key === "number" ? String(key) : key;
this.#remaining = remaining;
[this.#key, ...this.#remaining] = path;
}
#spreadKey(): PropertyPath[number] {
@@ -688,29 +691,38 @@ class ObjectReferenceWalk {
#spread(property: t.SpreadElement): void {
const selected = this.#key ?? null;
if (typeof selected !== "object" && this.#overwritten.has(String(selected)))
return;
// Spread creates data properties; the source descriptor is not installed.
if (
this.accessor === undefined &&
(typeof selected === "object" || !this.#overwritten.has(String(selected)))
)
if (this.accessor === undefined)
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
capturedSelectionAt: property.argument,
ownEnumerable: true,
});
if (t.isObjectExpression(property.argument))
for (const name of objectExpressionDefinedKeys(property.argument)) {
this.#overwritten.add(name);
this.#data.add(name);
}
if (
selected !== null &&
typeof selected !== "object" &&
t.isObjectExpression(property.argument) &&
objectExpressionDefinesKey(property.argument, String(selected))
) {
this.#overwritten.add(String(selected));
this.#data.add(String(selected));
}
}
#prototype(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): boolean {
if (!isObjectPrototypeSetter(property, name)) return false;
if (
!t.isObjectProperty(property) ||
property.computed ||
property.shorthand ||
name !== "__proto__"
)
return false;
const selected = this.#key ?? null;
if (this.accessor !== undefined && this.accessorLookup === "own")
return true;
@@ -719,7 +731,7 @@ class ObjectReferenceWalk {
(selected === null ||
typeof selected === "object" ||
(typeof selected === "string" &&
!objectExpressionDefinedKeys(this.node).has(selected)))
!objectExpressionDefinesKey(this.node, selected)))
)
this.#references.push({
node: property.value,
@@ -807,10 +819,8 @@ class ObjectReferenceWalk {
? this.#data.has(name) || this.#accessors.has(name)
: this.#data.has(name) ||
(kind === "get" ? this.#getters : this.#setters).has(name))
) {
if (kind === "data") this.#data.add(name);
)
return;
}
this.#read(property, name);
if (name !== null) this.#record(name, kind);
}
@@ -829,12 +839,12 @@ const objectPropertyReferences = (
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(node, path, query).run();
const objectExpressionDefinedKeys = (
const objectExpressionDefinesKey = (
root: t.ObjectExpression,
): ReadonlySet<string> => {
key: string,
): boolean => {
const pending: t.ObjectExpression[] = [root];
const seen = new Set<t.ObjectExpression>();
const names = new Set<string>();
for (let cursor = 0; cursor < pending.length; cursor++) {
const object = pending[cursor];
if (object === undefined || seen.has(object)) continue;
@@ -846,11 +856,19 @@ const objectExpressionDefinedKeys = (
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name !== null && !isObjectPrototypeSetter(property, name))
names.add(name);
if (
name === key &&
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
)
return true;
}
}
return names;
return false;
};
export const semanticArrayPropertyReferences = (
@@ -906,44 +924,3 @@ export const semanticArrayPropertyReferences = (
}
return references;
};
/** Possible native fallback after own slots; explicit prototype references are walked separately. */
export const semanticNativePrototypeChain = (
node: t.ObjectExpression | t.ArrayExpression,
key: PropertyPath[number],
): readonly ("Object" | "Array")[] => {
const exact =
key !== null && typeof key !== "object" ? String(key) : undefined;
if (t.isArrayExpression(node)) {
if (exact === "length") return [];
if (exact !== undefined) {
const sources = semanticArrayPropertyReferences(node, [exact]);
if (
sources.length > 0 &&
sources.every((source) => source.capturedSelectionAt === undefined)
)
return [];
}
return ["Array", "Object"];
}
if (exact !== undefined && objectExpressionDefinedKeys(node).has(exact))
return [];
for (const property of node.properties) {
if (!isObjectPrototypeSetter(property)) continue;
const prototype = unwrapJavaScriptExpression(property.value).node;
// Null removes the prototype. Objects select the explicitly retained
// chain, while primitive values are ignored and unknowns may be primitive.
if (
t.isNullLiteral(prototype) ||
t.isObjectExpression(prototype) ||
t.isArrayExpression(prototype) ||
t.isFunctionExpression(prototype) ||
t.isArrowFunctionExpression(prototype) ||
t.isClassExpression(prototype) ||
t.isRegExpLiteral(prototype) ||
t.isNewExpression(prototype)
)
return [];
}
return ["Object"];
};
@@ -190,7 +190,6 @@ export function semanticProjectedSourceFactsAtRead(
memberReadAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
retainEligibility?: boolean,
valueEscapesHideSources?: boolean,
):
| {
readonly sources: readonly (Omit<ProjectedSource, "path"> & {
@@ -220,9 +219,6 @@ const projectedSourceFactsAtRead = (
memberReadAt: t.Node = reference,
capturedSelections: readonly SemanticCapturedSelection[] = [],
retainEligibility = false,
// Accessor lookup reads source syntax. A prior value escape does not hide
// that syntax; unknown writes and invocations still fail closed.
valueEscapesHideSources = true,
):
| {
readonly sources: readonly ProjectedSource[];
@@ -369,7 +365,6 @@ const projectedSourceFactsAtRead = (
(replacement.assignedSources?.[0]?.target?.start ?? -1)));
if (
includeUnknown &&
valueEscapesHideSources &&
semanticSomeEffectPathCandidate(
binding.escapedPaths,
path,
@@ -2,40 +2,6 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([
"load('./dep.cjs')",
"load.call(null,'./dep.cjs')",
"load.apply(null,['./dep.cjs'])",
"Reflect.apply(load,null,['./dep.cjs'])",
])("retains a createRequire filename through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({
specifier: "./dep.cjs",
resolution_base_path: "./sub/entry.cjs",
}),
]);
});
it.each([
"load.call(null,'./dep.cjs')",
"Reflect.apply(load,null,['./dep.cjs'])",
])("keeps mixed createRequire bases unknown through %s", (call) => {
const result = analyzeJavaScriptStaticSource(
`import {createRequire} from 'node:module';const load=createRequire(flag?import.meta.url:new URL('./sub/entry.cjs',import.meta.url));${call};`,
);
expect(result.references.filter(({ kind }) => kind === "require")).toEqual([
expect.objectContaining({ specifier: null }),
]);
expect(
result.references.every(
(reference) => !("resolution_base_path" in reference),
),
).toBe(true);
});
it.each([
['const h={require:module.require};h.require("./shared.cjs");', true],
[
@@ -30,8 +30,6 @@ import {
import {
semanticStaticPropertyKey,
semanticMemberChain,
semanticWriteSiteTarget,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js";
@@ -73,33 +71,30 @@ export type {
const possibleCommonJsDelegateWriteStates =
new WeakSet<JavaScriptSemanticAnalysisState>();
/** Admission and indexing share the same direct, projected and iteration leaves. */
function* commonJsDelegateWriteTargets(
node: t.Node,
): Generator<t.MemberExpression | t.OptionalMemberExpression> {
const write = semanticWriteSiteTarget(node);
if (write === null) return;
for (const target of semanticWriteTargets(write)) {
if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target))
continue;
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed)) yield target;
}
}
/** Source eligibility only; the shared write owner still proves receiver identity. */
export const observePossibleCommonJsDelegateWrite = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): void => {
if (possibleCommonJsDelegateWriteStates.has(state)) return;
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
semanticIterationSources(node, state.parentsByNode.get(node) ?? null).some(
(source) => source.kind === "reference-store",
)
)
possibleCommonJsDelegateWriteStates.add(state);
if (!commonJsDelegateWriteTargets(node).next().done)
if (
(t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) &&
["require", null].includes(
semanticStaticPropertyKey(target.property, target.computed),
)
)
possibleCommonJsDelegateWriteStates.add(state);
else if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
possibleCommonJsDelegateWriteStates.add(state);
@@ -891,9 +886,6 @@ export const semanticRequireCallAt = (
}
const interop = loaderInvocationInterop(state);
if (!t.isNewExpression(node)) {
// Invocation normalization retains the selected path identity. Keep its
// filename alongside that path rather than selecting the outer call again.
const sourceRelativePaths = new Map<readonly string[], string>();
const invocation = interop.intrinsicInvocation(node, (callee) => {
const loader = requireLoaderValueAt(
callee,
@@ -920,21 +912,22 @@ export const semanticRequireCallAt = (
state,
factoryBindingIds,
);
if (selected !== undefined) {
const path = [
if (selected !== undefined)
return [
"require-loader",
selected.loader,
selected.sourceRelative ? "relative" : "unknown",
...(selected.loaderAuthority === "candidate" ? ["candidate"] : []),
];
if (selected.sourceRelativePath !== undefined)
sourceRelativePaths.set(path, selected.sourceRelativePath);
return path;
}
return interop.intrinsicPath(callee);
});
if (invocation?.path[0] === "require-loader") {
const sourceRelativePath = sourceRelativePaths.get(invocation.path);
const selected = selectedRequireSourcesAt(
node.callee,
node,
state,
factoryBindingIds,
);
return {
operation: "load",
loader:
@@ -949,9 +942,9 @@ export const semanticRequireCallAt = (
: {}),
sourceRelative:
invocation.kind === "known" && invocation.path[2] === "relative",
...(invocation.kind === "known" && sourceRelativePath !== undefined
? { sourceRelativePath }
: {}),
...(selected?.sourceRelativePath === undefined
? {}
: { sourceRelativePath: selected.sourceRelativePath }),
};
}
}
@@ -1563,8 +1556,20 @@ const writesModuleRequire = (
};
traverseJavaScriptValueAst(state.program, state.erasedSyntaxNodes, {
enter: (node) => {
for (const target of commonJsDelegateWriteTargets(node))
record(node, target.object);
const target = t.isAssignmentExpression(node)
? node.left
: t.isUpdateExpression(node) ||
t.isUnaryExpression(node, { operator: "delete" })
? node.argument
: undefined;
if (
t.isMemberExpression(target) ||
t.isOptionalMemberExpression(target)
) {
const key = semanticStaticPropertyKey(target.property, target.computed);
if (key === "require" || (key === null && target.computed))
record(node, target.object);
}
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const invocation = interop.intrinsicInvocation(node);
const owner =
@@ -1578,21 +1583,42 @@ const writesModuleRequire = (
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Reflect" && method === "set") {
const key = interop.staticString(args?.[1]);
if (key === "require" || key === undefined)
record(node, args?.[3] ?? args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "defineProperties") {
const descriptors = args?.[1];
if (objectMayProvideRequire(descriptors))
if (
!t.isObjectExpression(descriptors) ||
descriptors.properties.some(
(property) =>
t.isSpreadElement(property) ||
(t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) ===
"require"),
)
)
record(node, args?.[0] ?? t.nullLiteral());
} else if (owner === "Object" && method === "assign") {
const target = args?.[0];
if (
target !== undefined &&
args?.slice(1).some(objectMayProvideRequire)
)
record(node, target);
if (target !== undefined) {
const sourceHasRequire = args
?.slice(1)
.some(
(arg) =>
t.isObjectExpression(arg) &&
arg.properties.some(
(property) =>
t.isObjectProperty(property) &&
semanticStaticPropertyKey(
property.key,
property.computed,
) === "require",
),
);
if (
sourceHasRequire ||
args?.slice(1).some((arg) => !t.isObjectExpression(arg))
)
record(node, target);
}
}
}
},
@@ -1601,15 +1627,6 @@ const writesModuleRequire = (
return writes;
};
/** Methods, spreads and unknown computed keys can all supply an own delegate. */
const objectMayProvideRequire = (node: t.Node | undefined): boolean =>
!t.isObjectExpression(node) ||
node.properties.some((property) => {
if (t.isSpreadElement(property)) return true;
const key = semanticStaticPropertyKey(property.key, property.computed);
return key === "require" || key === null;
});
/** Whether the original global process object can still own effects at this source site. */
export const semanticOriginalGlobalProcessAt = (
node: t.Node,
@@ -78,11 +78,7 @@ export const collectJavaScriptSemanticResources = (
const acquisitions = collectAcquisitions(program, context).filter(
(candidate) =>
!socketPortInFirstArgument(candidate) ||
(!rejectsSocketLiteralArguments(
candidate.node,
candidate.specifier,
context,
) &&
(!rejectsSocketPortLiteral(candidate.node, context) &&
!rejectsTlsProtocolVersionLiteral(
candidate.node,
candidate.specifier,
@@ -451,9 +447,6 @@ const collectReleases = (
resolution:
!opaqueReceiver &&
linked.length === 1 &&
(binding.kind !== "parameter" ||
semanticBindingAtRead(binding, member.object, context.state)
.hasDefiniteInitializer) &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
@@ -580,25 +573,16 @@ const resourceCandidateId = (candidate: ResourceCandidate): string =>
* readable non-port literal is decided. `""` is a path with no length, which
* Node also rejects.
*
* `net` also rejects an omitted or undefined port, before it returns a socket.
* TLS merges later options, so an undefined first argument alone cannot decide
* its admission. Object arguments and spread positions remain undecided here.
* `undefined` and an empty object throw `ERR_MISSING_ARGS`, but those are a
* missing option rather than a bad port, so they stay undecided here.
*/
const rejectsSocketLiteralArguments = (
const rejectsSocketPortLiteral = (
node: t.CallExpression | t.OptionalCallExpression,
specifier: string,
context: DataEffectTraversalContext,
): boolean => {
const port = node.arguments[0];
if (port === undefined) return specifier === "net";
if (t.isSpreadElement(port)) return false;
if (port === undefined || t.isSpreadElement(port)) return false;
const value = unwrapJavaScriptExpression(port).node;
if (
specifier === "net" &&
(t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, context.state, "undefined"))
)
return true;
if (t.isStringLiteral(value)) return value.value.length === 0;
if (
t.isNullLiteral(value) ||
@@ -617,14 +601,13 @@ const rejectsSocketLiteralArguments = (
isUnmodifiedGlobalReference(value, context.state, value.name)
)
return true;
// Numeric unary operators are judged by their result: `-0` is port zero,
// and `~(-1.5)` is zero too. `typeof` instead yields a nonempty IPC path.
// `-1` and `-1.5` are unary expressions over numeric literals: always out of
// range because a port is never negative.
if (t.isUnaryExpression(value)) {
if (value.operator === "!" || value.operator === "delete") return true;
const number = unaryOperandNumber(value, context, 0);
const operand = unwrapJavaScriptExpression(value.argument).node;
return (
number !== undefined &&
(!Number.isInteger(number) || number < 0 || number > 65535)
t.isNumericLiteral(operand) &&
(value.operator === "-" || !Number.isInteger(operand.value))
);
}
return false;
@@ -205,21 +205,3 @@ it("widens a cycle that rewrites the path head while increasing its depth", () =
returnedExpression(parsed.program, "target"),
);
});
it("retains both finite class-prototype callable selections", () => {
const { parsed, state } = indexed(`
class C { first(){return firstResult} second(){return secondResult} }
const selected=flag?C.prototype.first:C.prototype.second;
selected();
`);
const [read] = callReads(parsed.program, "selected");
if (read === undefined)
throw new Error("Missing selected prototype method call");
const result = createSemanticReturnedReferences(state)(read);
expect(result.precision).toBe("exact");
expect(
result.references
.map(({ node }) => (t.isIdentifier(node) ? node.name : null))
.sort(),
).toEqual(["firstResult", "secondResult"]);
});
@@ -24,13 +24,8 @@ import {
semanticKnownArrayIterationAt,
semanticSelectedReferenceSlot,
} from "./javascriptSemanticReferenceSources.js";
import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticArrayPropertyReferences,
semanticObjectPropertyReferences,
semanticClassPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticCapturedSelectionsAfterPropertyRead,
semanticNormalizeCapturedSelections,
semanticProjectedSourcesAtRead,
semanticShiftCapturedSelections,
@@ -67,7 +62,6 @@ interface Candidate {
| readonly SemanticCapturedSelection[]
| undefined;
readonly invocation?: JavaScriptSourceInvocationContext;
readonly classKind?: "instance" | "static";
}
export interface SemanticReturnedReferenceResult {
@@ -80,6 +74,22 @@ export interface SemanticReturnedReferenceResult {
readonly precision: "exact" | "widened";
}
interface ObjectTargetIndex {
readonly propertyByName: ReadonlyMap<
string,
t.ObjectMethod | t.ObjectProperty
>;
readonly trailingUnknownsByProperty: ReadonlyMap<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>;
}
interface ObjectTargetUnknown {
readonly node: t.ObjectMethod | t.ObjectProperty | t.SpreadElement;
readonly previous: ObjectTargetUnknown | undefined;
}
/** Resolve local result references conservatively, without claiming call execution. */
export const createSemanticReturnedReferences = (
state: JavaScriptSemanticAnalysisState,
@@ -105,6 +115,7 @@ export const createSemanticReturnedReferences = (
}[];
}
>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee, invocation) => {
const existing = results.get(callee);
if (
@@ -130,7 +141,7 @@ export const createSemanticReturnedReferences = (
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projectionDepths = new WeakMap<
t.Node,
Map<string, { depth: number | undefined }>
Map<string, number | undefined>
>();
const expanded = new WeakMap<t.Node, Set<string>>();
let additionalContexts = 0;
@@ -179,7 +190,6 @@ export const createSemanticReturnedReferences = (
readAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
invocationContext?: JavaScriptSourceInvocationContext,
classKind?: Candidate["classKind"],
): void => {
const source = unwrapJavaScriptExpression(node).node;
if (contextsWidened) {
@@ -203,25 +213,20 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
invocationKey(),
classKind,
]);
const depths = projectionDepths.get(source) ?? new Map();
projectionDepths.set(source, depths);
let context = depths.get(readKey);
if (context === undefined) {
context = { depth: path?.length };
depths.set(readKey, context);
} else if (
context.depth === undefined ||
context.depth !== path?.length
) {
if (context.depth !== undefined) result.precision = "widened";
context.depth = undefined;
path = undefined;
if (!depths.has(readKey)) {
depths.set(readKey, path?.length);
} else {
const previous = depths.get(readKey);
if (previous === undefined || previous !== path?.length) {
if (previous !== undefined) result.precision = "widened";
depths.set(readKey, undefined);
path = undefined;
}
}
// Selector joins share the transition's read/invocation/receiver context.
// Joining only by syntax would cross static and instance member names.
if (path !== undefined) path = selections.normalize(context, path);
if (path !== undefined) path = selections.normalize(source, path);
capturedSelections =
path === undefined
? undefined
@@ -274,10 +279,9 @@ export const createSemanticReturnedReferences = (
readAt?.start ?? -1,
readAt?.end ?? -1,
null,
classKind,
]);
if (sourceGenericInvocation) result.unresolvedInvocation = true;
depths.set(readKey, { depth: undefined });
depths.set(readKey, undefined);
key = expansionKey();
if (paths.has(key)) return;
}
@@ -285,7 +289,6 @@ export const createSemanticReturnedReferences = (
expanded.set(source, paths);
work.push({
node: source,
...(classKind === undefined ? {} : { classKind }),
path,
...(readAt === undefined ? {} : { readAt }),
...(capturedSelections === undefined ? {} : { capturedSelections }),
@@ -379,7 +382,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -503,38 +505,9 @@ export const createSemanticReturnedReferences = (
result.unresolvedInvocation = true;
}
}
} else if (t.isObjectExpression(node)) {
const [key, ...suffix] = current.path ?? [];
for (const choice of key === undefined
? [undefined]
: semanticAliasSelectionChoices(key))
for (const source of semanticObjectPropertyReferences(
node,
choice === undefined ? [] : [choice],
state,
)) {
if (source.widenedGetterResult) result.precision = "widened";
if (
source.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
pending.push({
...current,
node: source.node,
path:
current.path === undefined || source.widenedGetterResult
? undefined
: [...source.path, ...suffix],
capturedSelections: semanticCapturedSelectionsAfterPropertyRead(
current.capturedSelections,
source,
),
});
}
} else if (t.isArrayExpression(node)) {
} else if (t.isObjectExpression(node))
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key))
@@ -579,7 +552,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
continue;
@@ -618,83 +590,63 @@ export const createSemanticReturnedReferences = (
});
});
} else if (t.isNewExpression(node))
pending.push({ ...current, node: node.callee, classKind: "instance" });
pending.push({ ...current, node: node.callee });
else if (t.isClass(node)) {
const [key, ...path] = current.path ?? [];
if (current.path !== undefined && key !== undefined) {
for (const choice of semanticAliasSelectionChoices(key)) {
const prototype =
current.classKind !== "instance" && choice === "prototype";
if (prototype && path.length === 0) continue;
const ownEnumerable = current.capturedSelections?.some(
(capture) =>
capture.depth === (prototype ? 2 : 1) && capture.ownEnumerable,
);
const member = prototype ? path[0] : choice;
if (member === undefined) continue;
for (const selected of semanticAliasSelectionChoices(member))
for (const source of semanticClassPropertyReferences(
{
owner: node,
kind: prototype
? "prototype"
: (current.classKind ?? "static"),
...(ownEnumerable ? { fieldsOnly: true as const } : {}),
},
[selected],
state,
)) {
if (source.inherited && ownEnumerable) continue;
if (source.widenedGetterResult) result.precision = "widened";
pending.push({
...current,
node: source.node,
path: source.widenedGetterResult
? undefined
: [...source.path, ...(prototype ? path.slice(1) : path)],
classKind: "static",
capturedSelections:
semanticCapturedSelectionsAfterPropertyRead(
prototype
? semanticShiftCapturedSelections(
current.capturedSelections,
-1,
)
: current.capturedSelections,
source,
),
});
}
}
for (const child of pending)
enqueue(
child.node,
child.path,
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
continue;
}
// Unknown projections retain every possible callable descendant. At
// zero depth only construction contributes a source-local result.
for (const member of node.body.body) {
if (
t.isClassMethod(member) &&
(current.path === undefined || member.kind === "constructor")
if (t.isClassMethod(member)) {
if (
((current.path === undefined || current.path.length === 0) &&
member.kind === "constructor") ||
current.path === undefined ||
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
}))
)
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
} else if (
t.isClassProperty(member) &&
member.value !== null &&
member.value !== undefined &&
(key !== undefined || current.path === undefined) &&
(current.path === undefined ||
(key !== undefined &&
semanticAliasSelectionChoices(key).some((choice) => {
const name = semanticStaticPropertyKey(
member.key,
member.computed,
);
return (
name === null ||
semanticPropertyPathKeyMatches(choice, name)
);
})))
)
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : [],
node: member.value,
path: current.path === undefined ? undefined : path,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
-1,
),
});
else if (
current.path === undefined &&
t.isClassProperty(member) &&
member.value != null
)
pending.push({ ...current, node: member.value, path: undefined });
}
if (node.superClass !== null && node.superClass !== undefined)
pending.push({ ...current, node: node.superClass });
@@ -708,7 +660,6 @@ export const createSemanticReturnedReferences = (
child.readAt,
child.capturedSelections,
child.invocation ?? current.invocation,
child.classKind ?? current.classKind,
);
}
}
@@ -748,3 +699,139 @@ export const createSemanticReturnedReferences = (
return result;
};
};
const selectObjectTargets = (
node: t.ObjectExpression,
item: Candidate,
pending: Candidate[],
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): void => {
const [key, ...path] = item.path ?? [];
const childPath = item.path === undefined ? undefined : path;
if (key !== null && typeof key === "object" && "alternatives" in key) {
for (const choice of semanticAliasSelectionChoices(key))
selectObjectTargets(
node,
{ ...item, path: [choice, ...path] },
pending,
indexes,
);
return;
}
if (key !== null && key !== undefined && typeof key !== "object") {
const index = objectTargetIndex(node, indexes);
const property = index.propertyByName.get(String(key));
if (property !== undefined) {
const unknowns: ObjectTargetUnknown[] = [];
for (
let unknown = index.trailingUnknownsByProperty.get(property);
unknown !== undefined;
unknown = unknown.previous
)
unknowns.push(unknown);
for (const { node: unknown } of unknowns.reverse())
pending.push({
...item,
node: t.isSpreadElement(unknown)
? unknown.argument
: t.isObjectProperty(unknown)
? unknown.value
: unknown,
...(t.isSpreadElement(unknown) ? {} : { path: childPath }),
capturedSelections: t.isSpreadElement(unknown)
? [
...(item.capturedSelections ?? []),
{ at: unknown.argument, depth: 1, ownEnumerable: true },
]
: (semanticShiftCapturedSelections(item.capturedSelections, -1) ??
[]),
});
pending.push({
...item,
node: t.isObjectProperty(property) ? property.value : property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
return;
}
}
let replaced = false;
for (const property of [...node.properties].reverse()) {
if (replaced) break;
if (t.isSpreadElement(property)) {
pending.push({
...item,
node: property.argument,
capturedSelections: [
...(item.capturedSelections ?? []),
{ at: property.argument, depth: 1, ownEnumerable: true },
],
});
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (
key !== null &&
key !== undefined &&
name !== null &&
!semanticPropertyPathKeyMatches(key, name)
)
continue;
if (t.isObjectProperty(property))
pending.push({
...item,
node: property.value,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
else
pending.push({
...item,
node: property,
path: childPath,
capturedSelections:
semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [],
});
if (
key !== null &&
key !== undefined &&
typeof key !== "object" &&
name === String(key)
)
replaced = true;
}
};
const objectTargetIndex = (
node: t.ObjectExpression,
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): ObjectTargetIndex => {
const existing = indexes.get(node);
if (existing !== undefined) return existing;
const propertyByName = new Map<string, t.ObjectMethod | t.ObjectProperty>();
const trailingUnknownsByProperty = new Map<
t.ObjectMethod | t.ObjectProperty,
ObjectTargetUnknown
>();
let trailingUnknown: ObjectTargetUnknown | undefined;
for (const property of [...node.properties].reverse()) {
if (t.isSpreadElement(property)) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) {
trailingUnknown = { node: property, previous: trailingUnknown };
continue;
}
if (propertyByName.has(name)) continue;
propertyByName.set(name, property);
if (trailingUnknown !== undefined)
trailingUnknownsByProperty.set(property, trailingUnknown);
}
const created = { propertyByName, trailingUnknownsByProperty };
indexes.set(node, created);
return created;
};
@@ -155,31 +155,3 @@ it.each([
}
},
);
it("discloses getter-result widening when selecting returned callable targets", () => {
const parsed = parseJavaScriptSource(
"const box={get item(){return {make:()=>result}}};box.item.make();",
);
if (parsed === null) throw new Error("Expected getter fixture");
const steps = indexJavaScriptBindingsSteps(parsed.program, {
getter_expansions: 1,
});
let step = steps.next();
while (!step.done) step = steps.next();
let call: t.CallExpression | undefined;
t.traverseFast(parsed.program, (node) => {
if (t.isCallExpression(node)) call = node;
});
if (call === undefined)
throw new Error("Expected getter-produced callable invocation");
const result = createSemanticReturnedReferences(step.value)(
call.callee,
call,
);
expect(result.precision).toBe("widened");
expect(result.references).toContainEqual({
node: expect.objectContaining({ type: "Identifier", name: "result" }),
projection: [],
});
expect(step.value.returnedReferenceWidenings.has(call.callee)).toBe(true);
});
@@ -81,29 +81,3 @@ it.each([
);
},
);
it.each([
["const box={...{__proto__:{make:()=>module}}};box.make()", false],
["const box={...{get make(){return ()=>module}}};box.make()", true],
["class C{static make(){return module}};const box={...C};box.make()", false],
["class C{make(){return module}};const box={...new C()};box.make()", false],
[
"class Base{static make=()=>module};class C extends Base{};const box={...C};box.make()",
false,
],
["class C{static make=()=>module};const box={...C};box.make()", true],
["class C{make=()=>module};const box={...new C()};box.make()", true],
] as const)(
"copies only own enumerable callable values: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -201,82 +201,3 @@ it("refreshes cached returned targets after the shared member index settles", ()
}),
]);
});
it.each([
["const box={set make(value){return module}};box.make()", false],
["const box={get make(){return module}};box.make()", false],
["const box={get make(){return ()=>module}};box.make()", true],
[
"const box={get make(){return ()=>module},set make(value){}};box.make()",
true,
],
["const box={get make(){return ()=>module},make:()=>({})};box.make()", false],
["const box={__proto__:{make:()=>module}};box.make()", true],
["const box={__proto__:{make:()=>module},make:()=>({})};box.make()", false],
] as const)(
"uses property-read ownership before invoking a returned callable: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["class C{make(){return module}};C.make()", false],
["class C{static make(){return module}};new C().make()", false],
["class C{static make(){return module}};C.make()", true],
["class C{make(){return module}};new C().make()", true],
["class C{make(){return module}};C.prototype.make()", true],
["class C{get make(){return ()=>module}};new C().make()", true],
["class C{set make(value){return module}};new C().make()", false],
[
"class Base{static make(){return module}};class C extends Base{};C.make()",
true,
],
[
"class Base{make(){return module}};class C extends Base{make=()=>({})};new C().make()",
false,
],
] as const)(
"preserves the class property receiver before invoking its value: %s",
(expression, expected) => {
const analysis = analyzeJavaScriptStaticSource(
`${expression}.require('./shared.cjs');`,
);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
it.each([
["C.first:new C().second", false],
["C.second:new C().first", true],
] as const)(
"keeps static and instance selector alternatives correlated: %s",
(selection, expected) => {
const analysis = analyzeJavaScriptStaticSource(`
class C {
static first(){return {}} first(){return module}
static second(){return module} second(){return {}}
}
const selected=flag?${selection};selected().require('./shared.cjs');
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.references).toEqual(
expected
? [expect.objectContaining({ kind: "require", specifier: null })]
: [],
);
},
);
@@ -535,7 +535,7 @@ const semanticWithPrototypeKind = (
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
semanticStaticPropertyKey(property.key, false) === "__proto__"
t.isIdentifier(property.key, { name: "__proto__" })
)
return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node)
? "null"
@@ -3,58 +3,7 @@ import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
analyzeCommonJsStaticFixture,
analyzeCommonJsSemanticFixture,
} from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
"Object.assign(module,{require(){return {}}});",
"Object.assign(module,{...replacement});",
"Object.defineProperties(module,{[key]:{value:()=>({})}});",
'Reflect.set(module,"require",()=>({}));',
'Reflect.set({},"require",()=>({}),module);',
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("retains uncertainty after a possible delegate replacement: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).not.toContainEqual(
expect.objectContaining({ kind: "require", specifier: "electron" }),
);
expect(analysis.references).toContainEqual(
expect.objectContaining({ kind: "require", specifier: null }),
);
});
it.each([
"Object.assign(module,{other(){}});",
"Object.defineProperties(module,{other:{value:()=>({})}});",
"Object.assign({},{require(){}});",
'Reflect.set(module,"other",()=>({}));',
'Reflect.set(module,"require",()=>({}),{});',
"({x:module.other}={x:replacement});",
"[module.other]=[replacement];",
"for(module.other of [replacement]){}",
])("preserves the host loader after an unrelated write: %s", (write) => {
const analysis = analyzeCommonJsStaticFixture(`${write}require("electron");`);
expect(analysis.references).toEqual([
expect.objectContaining({ kind: "require", specifier: "electron" }),
]);
});
it.each([
"({x:module.require}={x:replacement});",
"[module.require]=[replacement];",
"for(module.require of [replacement]){}",
])("preserves a builtin captured before its delegate write: %s", (write) => {
const analysis = analyzeCommonJsSemanticFixture(
`const fs=require('node:fs');${write}fs.readFileSync('kept');`,
);
expect(analysis.configurationOperations.map(({ key }) => key)).toEqual([
"kept",
]);
});
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
it.each([
'var require;require("./before.cjs");',
@@ -41,7 +41,21 @@ export interface JavaScriptSourceAnalysisFailure {
export interface JavaScriptSourceAnalysisLimits {
readonly semanticPrecision?: JavaScriptSemanticPrecisionInput;
readonly heapMb: number;
/** Where `heapMb` was selected. Absent when the caller did not record it. */
readonly heapSource?:
| "default"
| "setting"
| "runtime"
| "request"
| undefined;
readonly timeoutMs: number;
/** Where `timeoutMs` was selected. Absent when the caller did not record it. */
readonly timeoutSource?:
| "default"
| "setting"
| "runtime"
| "request"
| undefined;
}
/** Sequential source-analysis authority with independently verified cleanup. */
@@ -76,44 +76,3 @@ it("recognizes a bundler runtime through nested member names", () => {
modules: [expect.objectContaining({ exports: ["ok"] })],
});
});
it.each(["1 as const", "1 satisfies number"])(
"keeps numeric bundle identities through erased syntax: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([[(${entry})], {
1: function(module, exports, req) { req((2 as const)); req.e((3 satisfies number)); },
2: function() {}
}, function(req) { req((${entry})); }]);
`);
expect(analysis.parse_error_count).toBe(0);
expect(analysis.bundler_registrations).toMatchObject([
{
chunk_keys: ["1"],
unknown_chunk_keys: 0,
entry_module_keys: ["1"],
unknown_entry_module_keys: 0,
async_chunk_keys: ["3"],
unknown_async_chunk_keys: 0,
},
]);
expect(analysis.references).toContainEqual(
expect.objectContaining({
specifier: "2",
loader: "factory-loader",
module_key: "1",
}),
);
},
);
it.each(["0, {}", "[], modules"])(
"does not grant factory-loader authority to a rejected registration: %s",
(entry) => {
const analysis = analyzeJavaScriptStaticSource(`
globalThis.webpackChunkApp.push([${entry}, function(req) { req("electron"); }]);
`);
expect(analysis.bundler_registrations).toEqual([]);
expect(analysis.references).toEqual([]);
},
);
@@ -140,7 +140,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(
file.program,
bindings,
bindings.erasedSyntaxNodes,
),
openReceiverFacts: receivers.openReceivers,
electronBindings: electronIdentity.bindings,
@@ -48,12 +48,12 @@ export const inspectBundlerRegistration = (
if (runtime === undefined || !t.isArrayExpression(entry)) return;
const chunkIds = entry.elements[0];
const table = entry.elements[1];
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const runtimeValue = runtimeMetadata(
entry.elements[2],
bindings,
accumulator,
);
if (!t.isArrayExpression(chunkIds) || !t.isObjectExpression(table)) return;
const recovered = recoverBundlerModules(source, table, accumulator, {
runtime,
bindings,
@@ -43,7 +43,6 @@ import type {
import { VITE_OUTPUT_DEPENDENCY_PREFIX } from "./javascriptStaticAnalysisTypes.js";
import {
resolveSemanticBindingState,
semanticBindingLookupAt,
semanticStableInitializer,
type JavaScriptBindingIndex,
type JavaScriptSemanticAnalysisState,
@@ -412,7 +411,7 @@ const chunkLoadSpecifier = (
const vitePreloadDependencySpecifiers = (
node: JavaScriptCallLike,
name: string,
table: FindingContext["vitePreloadDependencyTable"],
table: readonly (string | undefined)[] | null,
bindings: JavaScriptSemanticAnalysisState,
): readonly (string | undefined)[] => {
if (
@@ -421,18 +420,10 @@ const vitePreloadDependencySpecifiers = (
!t.isArrayExpression(node.arguments[0])
)
return [];
const callee = unwrapJavaScriptExpression(node.callee).node;
const lookup = semanticBindingLookupAt(bindings, callee, name);
if (
(lookup.kind !== "binding" && lookup.kind !== "uncertain-binding") ||
semanticStableInitializer(lookup.binding, bindings)?.node !== table.helper
)
return [];
const indexes = node.arguments[0].elements;
// Explicit cache overrides change which table the helper reads. Retain the
// call slots as unknown unless every override is statically undefined.
if (
lookup.kind !== "binding" ||
!node.arguments
.slice(1)
.every((argument) => isExplicitDefaultArgument(argument, bindings))
@@ -442,7 +433,7 @@ const vitePreloadDependencySpecifiers = (
if (element === null || !t.isNumericLiteral(element)) return undefined;
const index = element.value;
return Number.isSafeInteger(index) && index >= 0
? viteDependencySpecifier(table.dependencies[index])
? viteDependencySpecifier(table[index])
: undefined;
});
};
@@ -482,9 +473,8 @@ type ViteMapDepsFunction =
/** Index the canonical Vite helper's cache initializer from parsed syntax. */
export const indexVitePreloadDependencyTable = (
program: t.Program,
bindings: JavaScriptSemanticAnalysisState,
): FindingContext["vitePreloadDependencyTable"] => {
const { erasedSyntaxNodes } = bindings;
erasedSyntaxNodes: WeakSet<t.Node>,
): readonly (string | undefined)[] | null => {
const helpers: ViteMapDepsFunction[] = [];
traverseJavaScriptValueAst(program, erasedSyntaxNodes, {
enter: (node) => {
@@ -507,7 +497,7 @@ export const indexVitePreloadDependencyTable = (
const mapIdentifier = t.isAssignmentPattern(mapParameter)
? mapParameter.left
: mapParameter;
if (!t.isIdentifier(mapIdentifier)) return { helper, dependencies: [] };
if (!t.isIdentifier(mapIdentifier)) return [];
const tables: t.ArrayExpression[] = [];
traverseJavaScriptValueAst(helper, erasedSyntaxNodes, {
skip: (node) => t.isFunction(node),
@@ -525,9 +515,9 @@ export const indexVitePreloadDependencyTable = (
tables.push(node.right);
},
});
if (tables.length !== 1) return { helper, dependencies: [] };
if (tables.length !== 1) return [];
const table = tables[0];
if (table === undefined) return { helper, dependencies: [] };
if (table === undefined) return [];
// A spread contributes an unknown number of entries, so every later slot
// has an unknown index; leave those out instead of shifting them.
const spread = table.elements.findIndex((element) =>
@@ -550,14 +540,6 @@ export const indexVitePreloadDependencyTable = (
semanticIdentifierRole(node, parent, readAncestors) === null
)
return;
const lookup = semanticBindingLookupAt(bindings, node, node.name);
if (
lookup.kind === "ambient" ||
lookup.kind === "captured" ||
(lookup.kind === "binding" &&
semanticStableInitializer(lookup.binding, bindings)?.node !== helper)
)
return;
if (
(t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) &&
parent.callee === node
@@ -566,12 +548,7 @@ export const indexVitePreloadDependencyTable = (
escapedOrMutated = true;
},
});
return {
helper,
dependencies: escapedOrMutated
? dependencies.map(() => undefined)
: dependencies,
};
return escapedOrMutated ? dependencies.map(() => undefined) : dependencies;
};
const requestConstructorUrl = (
@@ -769,11 +769,8 @@ export const stringValue = (
/** Return a string or numeric argument literal. */
export const argumentValue = (
node: t.Node | null | undefined,
): string | undefined => {
if (node === null || node === undefined) return undefined;
const value = unwrapJavaScriptExpression(node).node;
return t.isNumericLiteral(value) ? String(value.value) : stringValue(value);
};
): string | undefined =>
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
/** Prefix a static argument value when one is available. */
export const prefixedArgument = (
@@ -133,10 +133,7 @@ export interface JavaScriptFindingContext {
readonly expressionShapes: JavaScriptExpressionShapeResolver;
readonly source: string;
readonly accumulator: JavaScriptAnalysisAccumulator;
readonly vitePreloadDependencyTable: {
readonly helper: t.Node;
readonly dependencies: readonly (string | undefined)[];
} | null;
readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null;
readonly openReceiverFacts?: ReadonlyMap<number, JavaScriptOpenReceiverFact>;
/** Proven Electron export path segments by root offset. */
readonly electronBindings?: ReadonlyMap<number, readonly string[]>;
@@ -1,7 +1,6 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
const windowPrefix =
"import {BrowserWindow} from 'electron'; import {fileURLToPath} from 'node:url';";
@@ -153,52 +152,3 @@ it("keeps a reused renderer receiver uncertain after its native method call", ()
"independent.html",
]);
});
it.each([
["untouched exports", "", 1],
[
"object-pattern exports write",
"({value: module.exports} = replacement);",
0,
],
["array-pattern exports write", "[module.exports] = replacement;", 0],
["loop-target exports write", "for (module.exports of replacement) {}", 0],
] as const)(
"requires intact bootstrap exports after %s",
(_name, write, expected) => {
const source = `(() => {
const table = {
external(module, exports, load) { module.exports = require('electron'); },
entry(module, exports, load) { new (load('external').BrowserWindow)(); }
};
function load(key) {
const module = {exports: {}};
table[key](module, module.exports, load);
${write}
return module.exports;
}
load('entry');
})();`;
const observed: string[] = [];
const windowClass = (identity: string) =>
class {
constructor() {
observed.push(identity);
}
};
const local = { BrowserWindow: windowClass("replacement") };
runInNewContext(source, {
replacement: Object.assign([local], { value: local }),
require: (specifier: string) => {
expect(specifier).toBe("electron");
return { BrowserWindow: windowClass("electron") };
},
});
expect(observed).toEqual([expected === 1 ? "electron" : "replacement"]);
expect(
analyzeCommonJsStaticFixture(source).electron.browser_windows,
).toHaveLength(expected);
},
);
import { runInNewContext } from "node:vm";
@@ -7,7 +7,6 @@ import {
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import {
symbolicTruth,
symbolicVariants,
@@ -1077,94 +1076,3 @@ it("retains each external selection guard once across deep member reads", () =>
});
expect(resolver.truncated).toBe(false);
});
it.each([
[
"module property after replacement",
'module.exports={api:null};module.exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"module property overwritten after replacement",
'module.exports={api:require("electron")};module.exports.api=null;',
{ kind: "literal", value: null },
],
[
"detached exports property",
'module.exports={api:null};exports.api=require("electron");',
{ kind: "literal", value: null },
],
[
"rebound exports parameter",
'module.exports={api:null};exports={api:require("electron")};',
{ kind: "literal", value: null },
],
[
"identity-preserving module write",
'module.exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"identity-preserving original exports write",
'module.exports=exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"reconnected exports parameter",
'module.exports={api:null};exports=module.exports;exports.api=require("electron");',
{ kind: "external", loaderAuthority: "candidate", path: ["electron"] },
],
[
"rebound module parameter",
'module={exports:{}};module.exports.api=require("electron");',
{ kind: "unknown", reason: "ambiguous export writes" },
],
] as const)("retains namespace write order for %s", (_name, body, expected) => {
const value = inspectOne(`(() => {
const modules={
1(module,exports){${body}},
2(module,exports,load){inspect(load(1).api);}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(value).toEqual(expected);
});
it("lists members added to the public module object after its replacement", () => {
const { resolver, values } = inspectFixture(`(() => {
const modules={
1(module,exports){module.exports={first:1};module.exports.later=2;exports.detached=3;},
2(module,exports,load){inspect(load(1));}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(values).toHaveLength(1);
expect(resolver.members(values[0]!)).toEqual(["first", "later"]);
});
it("derives bridge paths from the latest public module member writes", () => {
const result = analyzeJavaScriptStaticSource(`(() => {
const modules={
1(module,exports){
const e=require("electron");
module.exports={action:()=>e.ipcRenderer.send("stale")};
module.exports.action=()=>e.ipcRenderer.send("current");
exports.detached=()=>e.ipcRenderer.send("detached");
},
2(module,exports,load){
const api=load(1);
require("electron").contextBridge.exposeInMainWorld("desktop",api);
}
};
function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;}
load(2);
})();`);
expect(
result.electron.call_paths?.map(({ entry, channel }) => ({
entry,
channel,
})),
).toEqual([{ entry: "desktop.action", channel: "current" }]);
});
@@ -52,9 +52,6 @@ interface PropertyWrite {
readonly path: readonly string[];
readonly value: t.Node;
readonly site: t.Node;
readonly exportReceiver?: "module" | "exports";
readonly exportValueReceiver?: "module" | "exports";
readonly receiverUncertain?: true;
}
interface SymbolicReturnFlow extends SymbolicMetadata {
readonly values: readonly SymbolicValue[];
@@ -201,12 +198,6 @@ export class JavaScriptSymbolicBindings {
}
},
});
for (const writes of this.#writes.values())
writes.sort(
(left, right) =>
(left.site.end ?? Infinity) - (right.site.end ?? Infinity) ||
(right.site.start ?? 0) - (left.site.start ?? 0),
);
for (const table of this.#bootstrapTables)
this.#invalidateBootstrapTable(table.tableAliases, table.modules);
}
@@ -405,19 +396,19 @@ export class JavaScriptSymbolicBindings {
this.unresolved = true;
return symbolicUnknown("module export read boundary is unavailable");
}
const write = available.findLast(
(write) =>
write.path.length === 0 ||
(write.path.length === 1 && write.path[0] === key),
const replacement = available.findLast(
(write) => write.path.length === 0,
);
if (write?.receiverUncertain)
return symbolicUnknown("uncertain export receiver");
if (write?.path.length === 0)
if (replacement)
return this.property(
this.evaluate(write.value, value.environment),
this.evaluate(replacement.value, value.environment),
key,
read,
);
const writes = available.filter(
(w) => w.path.length === 1 && w.path[0] === key,
);
const write = writes.at(-1);
return write
? this.evaluate(write.value, value.environment)
: symbolicUnknown("unresolved module export");
@@ -518,23 +509,19 @@ export class JavaScriptSymbolicBindings {
if (value.kind === "namespace") {
const available = this.#namespaceWrites(value);
if (available === undefined) return [];
const replacementIndex = available.findLastIndex(
const replacement = available.findLast(
(write) => write.path.length === 0,
);
const replacement = available[replacementIndex];
return [
...new Set([
...(replacement === undefined
? []
: this.members(
this.evaluate(replacement.value, value.environment),
active,
)),
...available
.slice(replacementIndex + 1)
.flatMap((write) => (write.path.length === 1 ? write.path : [])),
]),
];
return replacement
? this.members(
this.evaluate(replacement.value, value.environment),
active,
)
: [
...new Set(
available.flatMap((w) => (w.path.length === 1 ? w.path : [])),
),
];
}
if (value.kind !== "object") return [];
return [
@@ -576,7 +563,6 @@ export class JavaScriptSymbolicBindings {
if (this.#mutated.has(value.bindingId)) return 1;
const available = this.#namespaceWrites(value);
if (available === undefined) return 1;
if (available.some((write) => write.receiverUncertain)) return 1;
const replacement = available.findLast(
(write) => write.path.length === 0,
);
@@ -1057,9 +1043,7 @@ export class JavaScriptSymbolicBindings {
if (boundary !== undefined && sourcePoint == null) return undefined;
if (completedAt !== undefined && completedAt.end == null) return undefined;
if (sourcePoint == null && completedAt === undefined)
return read === undefined
? this.#publicNamespaceWrites(writes)
: undefined;
return read === undefined ? writes : undefined;
// An owned snapshot cannot read beyond either its capture or this exit.
const point = Math.min(
sourcePoint ?? Infinity,
@@ -1085,84 +1069,17 @@ export class JavaScriptSymbolicBindings {
}
}
}
return this.#publicNamespaceWrites(
writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
),
return writes.filter(
(write) =>
write.site.end != null &&
write.site.end <= point &&
(changed.size === 0 ||
(write.path.length > 0
? !changed.has(write.path[0]!)
: selectedKey !== undefined && !changed.has(selectedKey))),
);
}
/** Keep publication separate from writes through a detached or rebound alias. */
#publicNamespaceWrites(
writes: readonly PropertyWrite[],
): readonly PropertyWrite[] {
let relation: "connected" | "detached" | "uncertain" = "connected";
const published: PropertyWrite[] = [];
for (const write of writes) {
if (write.path.length === 0 && write.exportReceiver === "exports") {
if (write.exportValueReceiver === "module") relation = "connected";
else if (write.exportValueReceiver !== "exports")
relation = "uncertain";
continue;
}
if (write.path.length === 0 && write.exportReceiver === "module") {
if (
write.exportValueReceiver === "module" ||
(write.exportValueReceiver === "exports" && relation === "connected")
)
continue;
relation =
write.exportValueReceiver === "exports"
? "connected"
: relation === "uncertain"
? "uncertain"
: "detached";
}
if (write.exportReceiver === "exports") {
if (relation === "detached") continue;
if (relation === "uncertain") {
published.push({ ...write, receiverUncertain: true });
continue;
}
}
published.push(write);
}
return published;
}
#exportValueReceiver(
input: t.Node,
alias: string,
): "module" | "exports" | undefined {
const expression = unwrapJavaScriptExpression(input).node;
const node = t.isAssignmentExpression(expression, { operator: "=" })
? expression.left
: expression;
const root = t.isMemberExpression(node) ? node.object : node;
if (!t.isIdentifier(root)) return undefined;
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (
binding === undefined ||
this.#exportAliases.get(binding.bindingId) !== alias
)
return undefined;
if (t.isIdentifier(node))
return this.#moduleBindings.has(binding.bindingId)
? undefined
: "exports";
return t.isMemberExpression(node) &&
this.#moduleBindings.has(binding.bindingId) &&
semanticStaticPropertyKey(node.property, node.computed) === "exports"
? "module"
: undefined;
}
#completeNamespace(
value: SymbolicNamespace,
point: t.Node,
@@ -1631,14 +1548,6 @@ export class JavaScriptSymbolicBindings {
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (!binding) return;
const alias = this.#exportAliases.get(binding.bindingId);
if (
alias &&
this.#moduleBindings.has(binding.bindingId) &&
path.length === 0
) {
this.#mutated.add(alias);
return;
}
if (this.#moduleBindings.has(binding.bindingId) && path[0] !== "exports")
return;
const key = alias ?? binding.bindingId;
@@ -1668,25 +1577,7 @@ export class JavaScriptSymbolicBindings {
}
}
const writes = this.#writes.get(key) ?? [];
const exportValueReceiver =
alias !== undefined &&
selected.length === 0 &&
t.isAssignmentExpression(value, { operator: "=" })
? this.#exportValueReceiver(value.right, alias)
: undefined;
writes.push({
path: selected,
value,
site,
...(alias === undefined
? {}
: {
exportReceiver: this.#moduleBindings.has(binding.bindingId)
? ("module" as const)
: ("exports" as const),
}),
...(exportValueReceiver === undefined ? {} : { exportValueReceiver }),
});
writes.push({ path: selected, value, site });
this.#writes.set(key, writes);
}
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more