Merge remote-tracking branch 'origin/main' into feat/persistent-workspace-investigation-runs
# Conflicts: # README.md # scripts/verify-package.mjs # src/cli.ts # src/domain/errors.ts # src/server/registerInvestigationTools.ts # tests/errors.test.ts
This commit is contained in:
commit
ee785ca9a9
65
README.md
65
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# REA: Reverse Engineer Anything
|
||||
|
||||
### One CLI and MCP server for coding agents to reverse engineer anything
|
||||
### One CLI and MCP server for agents to reverse engineer anything
|
||||
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
---
|
||||
|
||||
See a feature in an app that you want in your own product? Give the app to your coding agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, and controlled process capture. The longer-term toolkit extends the same agent workflow to packaged apps, JavaScript bundles, websites, APIs, protocols, mobile artifacts, firmware, runtime behavior, and differences between versions.
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ REA shows how it reached its conclusions. It does not claim to recover original
|
|||
| | |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| **Built for agents** | Ask what an app does and let your agent inspect it instead of guessing. |
|
||||
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or coding agent. |
|
||||
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or agent. |
|
||||
| **Complexity handled** | REA installs and manages the reverse-engineering tools behind the scenes. |
|
||||
| **From insight to code** | Understand a feature, then build your own version in the same coding session. |
|
||||
| **Local by design** | Analysis runs on your Mac. REA does not upload the app to a hosted analysis service. |
|
||||
|
|
@ -88,7 +88,7 @@ npm install --global rea-agents
|
|||
rea setup
|
||||
```
|
||||
|
||||
Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or coding-agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
|
||||
Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
|
||||
|
||||
REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. Registrations are additive, backup-first, and read back after writing. You can safely rerun setup.
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
|
|||
|
||||
Pass installer options after `bash -s --`, for example `--dry-run`, `--no-setup`, or `--version 1.0.0`. The curl wrapper never installs prerequisites or configures integrations itself. See [Installation and setup](docs/installation.md) for its exact mutation boundary.
|
||||
|
||||
### With a coding agent — recommended
|
||||
### With an agent — recommended
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea
|
||||
|
|
@ -118,7 +118,7 @@ npx -y rea-agents doctor
|
|||
npx -y rea-agents analyze /Applications/Notes.app
|
||||
```
|
||||
|
||||
Review the setup plan before confirming it. Restart a configured coding agent so it loads REA.
|
||||
Review the setup plan before confirming it. Restart a configured agent so it loads REA.
|
||||
|
||||
### From Terminal — install the `rea` command
|
||||
|
||||
|
|
@ -191,16 +191,17 @@ rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
|
|||
|
||||
Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
|
||||
|
||||
### CLI or coding agent?
|
||||
### CLI or agent?
|
||||
|
||||
| If you want to… | Use |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
||||
| Run or resume a persistent two-version artifact analysis | `rea investigate-versions` |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
| Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
|
||||
| If you want to… | Use |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
||||
| Run or resume a persistent two-version artifact analysis | `rea investigate-versions` |
|
||||
| Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /approved/path/analysis.json` to a deep-analysis command |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
| Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
|
||||
|
||||
Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
|
||||
|
||||
|
|
@ -230,6 +231,30 @@ rea import-reference-source /absolute/path/to/source
|
|||
|
||||
Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence v2 ID and manifest, and never execute bundle content.
|
||||
|
||||
Provider-neutral analysis snapshots persist successful, immutable REA calls and
|
||||
their Evidence v2 records. They are exact caches rather than Hopper databases:
|
||||
REA reuses an entry only when the binary digest, format, architecture, operation
|
||||
parameters, loader arguments, and provider identity match. Custom Hopper loader
|
||||
overrides disable snapshots because their provider configuration cannot be
|
||||
replayed safely. Cursor-dependent and mutating calls are never cached. Snapshot
|
||||
files can contain proprietary analysis results and local
|
||||
paths, so REA keeps them local, writes them with owner-only permissions, and
|
||||
requires a separate approved root:
|
||||
|
||||
```bash
|
||||
export REA_ANALYSIS_SNAPSHOT_ROOTS_JSON='["/absolute/path/to/analysis"]'
|
||||
rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json
|
||||
# The same exact query can now be answered from the snapshot.
|
||||
rea analyze /absolute/path/to/app --snapshot /absolute/path/to/analysis/app.json
|
||||
```
|
||||
|
||||
Exact CLI evidence replays happen before any provider starts. In MCP sessions,
|
||||
pass `snapshot_path` to `open_binary` to import a snapshot atomically while
|
||||
opening its matching target; MCP providers may still start before a cached call
|
||||
is replayed. Pass `snapshot_path` and, when required, `overwrite: true` to
|
||||
`close_binary` to save atomically before Hopper resources are released. If the
|
||||
save fails, REA deliberately leaves the session open.
|
||||
|
||||
## One prompt, a full investigation
|
||||
|
||||
```text
|
||||
|
|
@ -315,9 +340,9 @@ Roadmap items describe direction, not shipped support. New providers must produc
|
|||
|
||||
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
|
||||
|
||||
## Using REA with other coding agents
|
||||
## Using REA with other agents
|
||||
|
||||
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
|
||||
Setup currently configures Claude Desktop and Cursor automatically. Any agent that supports local MCP servers can use REA with the configuration below.
|
||||
|
||||
### Manual MCP configuration
|
||||
|
||||
|
|
@ -336,7 +361,7 @@ Setup currently configures Claude Desktop and Cursor automatically. Any coding a
|
|||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Agent["Coding agent"] --> REA["REA<br/>CLI + MCP"]
|
||||
Agent["Agent"] --> REA["REA<br/>CLI + MCP"]
|
||||
Terminal --> REA
|
||||
REA --> Workspace["Investigation workspace<br/>evidence + artifacts + captures"]
|
||||
Workspace --> Router["Capability router"]
|
||||
|
|
@ -455,7 +480,7 @@ No. Setup can install Hopper for you, but Hopper remains separate software with
|
|||
<details>
|
||||
<summary><strong>Does REA upload the app?</strong></summary>
|
||||
|
||||
REA has no hosted analysis service. Current providers analyze artifacts and capture behavior locally. Your coding agent or model provider may have its own data policy, so review that separately.
|
||||
REA has no hosted analysis service. Current providers analyze artifacts and capture behavior locally. Your agent or model provider may have its own data policy, so review that separately.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -469,7 +494,7 @@ No decompiler can guarantee the original source. REA gives an agent pseudocode,
|
|||
<details>
|
||||
<summary><strong>Which agents can use REA?</strong></summary>
|
||||
|
||||
Any coding agent that can run a local MCP server can use the manual configuration. Setup currently detects and configures Claude Desktop and Cursor automatically.
|
||||
Any agent that can run a local MCP server can use the manual configuration. Setup currently detects and configures Claude Desktop and Cursor automatically.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,22 @@ MAX_SEARCH_PATTERN_LENGTH = 256
|
|||
MAX_SEARCH_VALUE_LENGTH = 4096
|
||||
|
||||
|
||||
def _session_document():
|
||||
"""Find only the document opened for this authenticated REA session."""
|
||||
target = os.path.realpath(REA_TARGET_PATH)
|
||||
for document in Document.getAllDocuments():
|
||||
paths = (document.getExecutableFilePath(), document.getDatabaseFilePath())
|
||||
for path in paths:
|
||||
if path and os.path.realpath(path) == target:
|
||||
return document
|
||||
return None
|
||||
|
||||
|
||||
MAX_REGEX_BACKTRACKING_PATHS = 10000
|
||||
MAX_REGEX_CANDIDATE_LENGTH = 4096
|
||||
MAX_REGEX_SEARCH_WORK_UNITS = 1000000
|
||||
|
||||
|
||||
def _hex(value):
|
||||
return "0x%x" % value
|
||||
|
||||
|
|
@ -216,33 +232,150 @@ def _search_inventory(document, kind):
|
|||
return inventory
|
||||
|
||||
|
||||
def _checked_regex_paths(left, right, operation):
|
||||
"""Apply one path-count operation without crossing the static work budget."""
|
||||
if operation == "add":
|
||||
exceeded = left > MAX_REGEX_BACKTRACKING_PATHS - right
|
||||
result = left + right
|
||||
else:
|
||||
exceeded = right != 0 and left > MAX_REGEX_BACKTRACKING_PATHS // right
|
||||
result = left * right
|
||||
if exceeded or result > MAX_REGEX_BACKTRACKING_PATHS:
|
||||
raise ValueError(
|
||||
"Regex exceeds the %d-path backtracking budget"
|
||||
% MAX_REGEX_BACKTRACKING_PATHS
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _repeat_regex_paths(child_paths, minimum, maximum):
|
||||
"""Count every bounded repetition path, including alternative child paths."""
|
||||
paths = 0
|
||||
repeated_paths = 1
|
||||
for count in range(maximum + 1):
|
||||
if count >= minimum:
|
||||
paths = _checked_regex_paths(paths, repeated_paths, "add")
|
||||
if count < maximum:
|
||||
repeated_paths = _checked_regex_paths(
|
||||
repeated_paths, child_paths, "multiply"
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def _validate_regex_class(items):
|
||||
"""Accept only constant-time character-class operations."""
|
||||
allowed = {
|
||||
sre_parse.CATEGORY,
|
||||
sre_parse.LITERAL,
|
||||
sre_parse.NEGATE,
|
||||
sre_parse.RANGE,
|
||||
}
|
||||
if any(operation not in allowed for operation, _ in items):
|
||||
raise ValueError("Regex operation is not supported by the bounded matcher")
|
||||
|
||||
|
||||
def _validate_regex_node(node, inside_repeat=False):
|
||||
"""Reject regex structures with disproportionate or non-local evaluation cost."""
|
||||
"""Return capped path and step bounds for Python regex evaluation."""
|
||||
leaf_operations = {
|
||||
sre_parse.ANY,
|
||||
sre_parse.AT,
|
||||
sre_parse.CATEGORY,
|
||||
sre_parse.LITERAL,
|
||||
sre_parse.NOT_LITERAL,
|
||||
}
|
||||
forbidden = {
|
||||
sre_parse.ASSERT,
|
||||
sre_parse.ASSERT_NOT,
|
||||
sre_parse.GROUPREF,
|
||||
sre_parse.GROUPREF_EXISTS,
|
||||
}
|
||||
for name in ("GROUPREF_IGNORE", "GROUPREF_LOC_IGNORE", "GROUPREF_UNI_IGNORE"):
|
||||
operation = getattr(sre_parse, name, None)
|
||||
if operation is not None:
|
||||
forbidden.add(operation)
|
||||
repeat_tokens = {sre_parse.MAX_REPEAT, sre_parse.MIN_REPEAT}
|
||||
possessive = getattr(sre_parse, "POSSESSIVE_REPEAT", None)
|
||||
if possessive is not None:
|
||||
repeat_tokens.add(possessive)
|
||||
atomic = getattr(sre_parse, "ATOMIC_GROUP", None)
|
||||
|
||||
paths = 1
|
||||
steps = 0
|
||||
for operation, argument in node:
|
||||
if operation in forbidden:
|
||||
raise ValueError("Regex lookarounds and backreferences are not supported")
|
||||
if operation in repeat_tokens:
|
||||
if operation in leaf_operations:
|
||||
operation_paths = 1
|
||||
operation_steps = 1
|
||||
elif operation == sre_parse.IN:
|
||||
_validate_regex_class(argument)
|
||||
operation_paths = 1
|
||||
operation_steps = 1
|
||||
elif operation in repeat_tokens:
|
||||
if inside_repeat:
|
||||
raise ValueError("Nested regex repetitions are not supported")
|
||||
minimum, maximum, child = argument
|
||||
if maximum == sre_parse.MAXREPEAT or maximum > 1000:
|
||||
raise ValueError("Unbounded or excessive regex repetitions are not supported")
|
||||
_validate_regex_node(child, True)
|
||||
raise ValueError(
|
||||
"Unbounded or excessive regex repetitions are not supported"
|
||||
)
|
||||
child_paths, child_steps = _validate_regex_node(child, True)
|
||||
operation_paths = _repeat_regex_paths(
|
||||
child_paths, minimum, maximum
|
||||
)
|
||||
operation_steps = maximum * child_steps
|
||||
elif operation == sre_parse.SUBPATTERN:
|
||||
_validate_regex_node(argument[-1], inside_repeat)
|
||||
operation_paths, operation_steps = _validate_regex_node(
|
||||
argument[-1], inside_repeat
|
||||
)
|
||||
elif operation == sre_parse.BRANCH:
|
||||
operation_paths = 0
|
||||
operation_steps = 0
|
||||
for branch in argument[1]:
|
||||
_validate_regex_node(branch, inside_repeat)
|
||||
branch_paths, branch_steps = _validate_regex_node(
|
||||
branch, inside_repeat
|
||||
)
|
||||
operation_paths = _checked_regex_paths(
|
||||
operation_paths,
|
||||
branch_paths,
|
||||
"add",
|
||||
)
|
||||
operation_steps = max(operation_steps, branch_steps)
|
||||
elif atomic is not None and operation == atomic:
|
||||
operation_paths, operation_steps = _validate_regex_node(
|
||||
argument, inside_repeat
|
||||
)
|
||||
else:
|
||||
raise ValueError("Regex operation is not supported by the bounded matcher")
|
||||
paths = _checked_regex_paths(paths, operation_paths, "multiply")
|
||||
steps += operation_steps
|
||||
return paths, steps
|
||||
|
||||
|
||||
def _bounded_regex_matcher(expression, backtracking_paths, steps_per_path):
|
||||
"""Create a matcher with per-candidate and cumulative work bounds."""
|
||||
remaining_work = MAX_REGEX_SEARCH_WORK_UNITS
|
||||
work_per_character = backtracking_paths * max(steps_per_path, 1)
|
||||
|
||||
def matches(value):
|
||||
nonlocal remaining_work
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Regex candidates must be strings")
|
||||
if len(value) > MAX_REGEX_CANDIDATE_LENGTH:
|
||||
raise ValueError(
|
||||
"Regex candidate exceeds the %d-character safety limit"
|
||||
% MAX_REGEX_CANDIDATE_LENGTH
|
||||
)
|
||||
required_work = work_per_character * max(len(value), 1)
|
||||
if required_work > remaining_work:
|
||||
raise ValueError(
|
||||
"Regex search exceeds the %d-unit work budget"
|
||||
% MAX_REGEX_SEARCH_WORK_UNITS
|
||||
)
|
||||
remaining_work -= required_work
|
||||
return expression.search(value) is not None
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def _search_page(document, kind, params):
|
||||
|
|
@ -268,11 +401,13 @@ def _search_page(document, kind, params):
|
|||
else:
|
||||
try:
|
||||
parsed = sre_parse.parse(pattern)
|
||||
_validate_regex_node(parsed)
|
||||
backtracking_paths, steps_per_path = _validate_regex_node(parsed)
|
||||
expression = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
|
||||
except re.error as error:
|
||||
except (re.error, OverflowError) as error:
|
||||
raise ValueError("Invalid regex pattern") from error
|
||||
matches = lambda value: expression.search(value) is not None
|
||||
matches = _bounded_regex_matcher(
|
||||
expression, backtracking_paths, steps_per_path
|
||||
)
|
||||
|
||||
selected = []
|
||||
total = 0
|
||||
|
|
@ -494,7 +629,21 @@ def _dispatch(method, params):
|
|||
if method == "health":
|
||||
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
|
||||
if method == "shutdown":
|
||||
return {"shutdown": True}
|
||||
document = _session_document()
|
||||
if document is None:
|
||||
return {"shutdown": True, "analysis_stopped": True, "document_closed": True}
|
||||
if document.backgroundProcessActive():
|
||||
document.requestBackgroundProcessStop()
|
||||
if document.backgroundProcessActive():
|
||||
document.waitForBackgroundProcessToEnd()
|
||||
analysis_stopped = not document.backgroundProcessActive()
|
||||
document.closeDocument()
|
||||
document_closed = _session_document() is None
|
||||
return {
|
||||
"shutdown": True,
|
||||
"analysis_stopped": analysis_stopped,
|
||||
"document_closed": document_closed,
|
||||
}
|
||||
if method == "list_documents":
|
||||
return [document.getDocumentName() for document in Document.getAllDocuments()]
|
||||
if method == "current_document":
|
||||
|
|
@ -604,9 +753,9 @@ def _dispatch(method, params):
|
|||
if method == "procedure_pseudo_code":
|
||||
return procedure.decompile()
|
||||
if method == "procedure_callers":
|
||||
return [_procedure_name(item) for item in procedure.getAllCallerProcedures()]
|
||||
return sorted((_hex(item.getEntryPoint()) for item in procedure.getAllCallerProcedures()), key=lambda value: int(value, 16))
|
||||
if method == "procedure_callees":
|
||||
return [_procedure_name(item) for item in procedure.getAllCalleeProcedures()]
|
||||
return sorted((_hex(item.getEntryPoint()) for item in procedure.getAllCalleeProcedures()), key=lambda value: int(value, 16))
|
||||
if method == "procedure_info":
|
||||
blocks = list(procedure.basicBlockIterator())
|
||||
length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<li class="">
|
||||
<div class="tsd-signature tsd-anchor-link" id="detectclients"><span class="tsd-kind-call-signature">detectClients</span><span class="tsd-signature-symbol">(</span><span class="tsd-kind-parameter">home</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">string</span><span class="tsd-signature-symbol">)</span><span class="tsd-signature-symbol">:</span> <span class="tsd-signature-type">Promise</span><span class="tsd-signature-symbol"><</span><span class="tsd-signature-keyword">readonly</span> <a href="../interfaces/application_Setup.SetupClient.html" class="tsd-signature-type tsd-kind-interface">SetupClient</a><span class="tsd-signature-symbol">[]</span><span class="tsd-signature-symbol">></span><a href="#detectclients" aria-label="Permalink" class="tsd-anchor-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="../assets/icons.svg#icon-anchor"></use></svg></a></div>
|
||||
<div class="tsd-description">
|
||||
<div class="tsd-comment tsd-typography"><p>Detect supported coding agents from stable per-user installation markers.</p>
|
||||
<div class="tsd-comment tsd-typography"><p>Detect supported agents from stable per-user installation markers.</p>
|
||||
</div>
|
||||
<div class="tsd-parameters">
|
||||
<h4 class="tsd-parameters-title">Parameters</h4>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Installation and setup
|
||||
|
||||
REA separates installing its CLI from configuring external software and coding agents.
|
||||
REA separates installing its CLI from configuring external software and agents.
|
||||
|
||||
## Install the CLI
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ Supported options are `--version <semver>`, `--dry-run`, `--no-setup`, `--no-pro
|
|||
`rea setup` discovers the current state, prints one plan, and asks `Continue? [Y/n]`. The plan identifies:
|
||||
|
||||
- an existing Hopper installation or the official package it proposes to install;
|
||||
- each detected coding-agent configuration path;
|
||||
- each detected agent configuration path;
|
||||
- the REA skill destination;
|
||||
- external software and package-manager effects.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Installation roadmap
|
||||
|
||||
REA setup currently configures the capabilities that exist today: its coding-agent integrations, bundled skill, and optional Hopper provider.
|
||||
REA setup currently configures the capabilities that exist today: its agent integrations, bundled skill, and optional Hopper provider.
|
||||
|
||||
When REA has at least two real optional toolchains, setup can evolve into a capability-oriented wizard. It may ask whether the operator wants to investigate native binaries, websites, mobile applications, firmware, or runtime protocols, then propose only the tools needed for the selected work.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result
|
||||
1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
|
||||
2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
|
||||
3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
|
||||
4,src/domain/errors.ts:HopperProtocolError,Malformed provider protocol result,Raw constructor message,Can expose provider protocol and implementation details,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
|
||||
5,src/domain/errors.ts:AnalysisOutputError,Analysis output fails parsing,"Invalid analysis output for {operation}: {reason}",Exposes internal operation and parser reason,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
|
||||
6,src/domain/errors.ts:AnalysisProtocolError,Application protocol fails,Raw constructor message,Can expose protocol internals,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
|
||||
7,src/domain/errors.ts:HopperProcessError,Analysis subprocess stops,"Hopper bridge stopped unexpectedly with code {exitCode}",Exposes provider bridge and process code,"Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
8,src/domain/errors.ts:HopperStartError,Analysis subprocess cannot start,Hopper application bridge could not be started,Exposes provider and bridge implementation,"Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
9,src/domain/errors.ts:BinaryTargetError,Target cannot open,"Cannot open artifact: {reason}",May expose raw reason and lacks checks,"REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.",implemented,focused assertions passed; full `npm run check` passed
|
||||
10,src/domain/errors.ts:ConfigurationError,Environment configuration invalid,Raw constructor message,May expose provider-specific variables and lacks recovery,"REA configuration is invalid. Run `rea doctor` and fix the reported setting.",implemented,focused assertions passed; full `npm run check` passed
|
||||
11,src/domain/errors.ts:ArtifactOperationError,Artifact inventory or extraction fails,"Artifact {operation} failed: {reason} at {logicalPath} (declared_sha256=...)",Exposes paths hashes and internal reason,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed
|
||||
12,src/domain/errors.ts:EvidenceFileError,Evidence file operation fails,"Evidence bundle {operation} failed: {reason}",Internal reason token gives weak recovery,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed
|
||||
13,src/domain/errors.ts:EvidenceIntegrityError,Evidence validation fails,Raw constructor message,May expose validation internals and lacks recovery,"Evidence is invalid or has changed. Recreate or re-import it, then try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
14,src/domain/errors.ts:UnknownRegistryError,Evidence-state mutation fails,"Residual unknown registry mutation failed: {reason}",Exposes internal subsystem and state token,"Evidence state changed before the update completed. Refresh the current state and try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
15,src/domain/errors.ts:ProcessCaptureError,Process capture fails,Raw constructor message,May expose process and cleanup internals,"Process capture could not complete. Check command access and limits, then try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
16,src/domain/errors.ts:AnalysisTimeoutError,Analysis deadline expires,"Analysis operation timed out after {timeoutMs}ms: {operation}",Exposes internal deadline and operation; weak recovery,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed
|
||||
17,src/domain/errors.ts:HopperTimeoutError,Provider deadline expires,"Hopper did not respond within {timeoutMs}ms",Exposes provider and internal deadline,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed
|
||||
18,src/domain/errors.ts:ProviderSelectionError,No configured provider supports operation,"No configured provider can execute {operation}",Exposes provider architecture and internal operation,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed
|
||||
19,src/domain/errors.ts:EvidenceLimitError,Evidence ledger limit reached,"Evidence ledger {limit} limit exceeded ({maximum})",Exposes internal storage model,"Evidence is too large for this session. Reduce the evidence set and try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
20,src/domain/errors.ts:AnalysisInputError,Parsed analysis input invalid,"Invalid analysis input for {operation}",Exposes operation identifier and lacks correction,"Analysis input is invalid. Check the arguments and try again.",implemented,focused assertions passed; full `npm run check` passed
|
||||
21,src/domain/errors.ts:AnalysisCancelledError,Caller cancels analysis,"Analysis operation was cancelled: {operation}",Exposes operation identifier,"Analysis was cancelled. Start it again when ready.",implemented,focused assertions passed; full `npm run check` passed
|
||||
22,src/domain/errors.ts:HopperCancelledError,Caller cancels provider request,Hopper operation was cancelled,Exposes provider name,"Analysis was cancelled. Start it again when ready.",implemented,focused assertions passed; full `npm run check` passed
|
||||
23,src/domain/errors.ts:NoBinaryOpenError,Analysis requested without target,"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",Low risk; already supplies agent recovery,Keep current copy,implemented,focused assertions passed; full `npm run check` passed
|
||||
24,src/server/toolResult.ts:successResult,Successful value violates public output schema,AnalysisOutputError: Analysis output does not match the tool contract,Exposes internal error tag and contract implementation,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed
|
||||
24.5,src/server/registerOfficialTools.ts:capability unknown,Approved unknown tracking records an unavailable analysis,"TOOL_NAME is unavailable: PROVIDER_REASON",Exposes an internal tool identifier and provider diagnostic in persistent user-visible evidence,"The requested analysis is unavailable for the current target.",implemented,verified by focused MCP integration test
|
||||
24.6,src/application/ReferenceSourceImportEntries.ts:failed entry,A partial reference import contains an unreadable entry,Low-level reader diagnostic stored in entry limitations,Can persist filesystem or reader implementation details,"Code- and entry-specific safe guidance with a retry, limit, exclusion, or platform recovery step.",implemented,verified by exhaustive focused projection test
|
||||
24.7,src/server/registerSessionTools.ts:process residual,Capture records an approved residual unknown,SCOPE: RAW_REASON,Persists adapter diagnostic and implementation wording,"Was SCOPE behavior fully observed during capture?",implemented,verified by focused session MCP test
|
||||
24.8,src/server/registerInvestigationTools.ts:changed behavior unknown,Changed-behavior workflow remains incomplete,"Changed behavior result EVIDENCE_ID remains STATUS",Persists internal evidence identifier,"Did both versions behave the same under a complete controlled replay?",implemented,verified by focused investigation MCP test
|
||||
24.9,src/server/registerInvestigationTools.ts:call path unknown,Call-path workflow remains incomplete,Call path START_ADDRESS → GOAL_ADDRESS is STATUS,Persists binary addresses and internal status wording,Can the requested call path be established from complete analysis?,implemented,verified by focused unresolved call-path MCP test
|
||||
24.91,src/server/registerInvestigationTools.ts:static runtime unknown,Static/runtime correlation remains incomplete,Static/runtime correlation result EVIDENCE_ID is STATUS,Persists internal evidence identifier,Does runtime behavior match the available static analysis?,implemented,verified by focused unresolved static/runtime MCP test
|
||||
24.92,src/server/registerInvestigationTools.ts:reconstruction unknown,Reconstruction verification retains unknown claims,Reconstruction SPECIFICATION_HASH has unknown claims,Persists internal hash,Does the reconstruction satisfy every declared claim?,implemented,verified by focused unknown reconstruction MCP test
|
||||
24.93,src/server/registerFunctionComparisonTool.ts:probe rationale,Function comparison is incomplete,Capture complete dossiers with equal providers and analysis limits.,Exposes provider architecture and gives ambiguous equality guidance,"Capture complete dossiers for both functions under the same target context and analysis limits.",implemented,verified by focused function-comparison MCP test
|
||||
24.95,incur:CLI argument validation,Caller omits or mistypes a command argument,VALIDATION_ERROR with raw Zod Details and fieldErrors including code/path/expected/received,Exposes validator representation and buries the useful correction,"REA could not read the command arguments. Check the named argument and its expected type, then try again.",implemented,verified by sanitizer unit tests and real dispatcher missing-argument test
|
||||
25,src/application/ReferenceSourceImport.ts:cancelled,Reference-source import is cancelled,Reference source import cancelled,No recovery timing guidance,"Reference source import was cancelled. Start it again when ready.",implemented,focused assertions passed
|
||||
26,src/application/ReferenceSourceImportPolicy.ts:invalid-limits,Configured import limits are not positive integers,Import limits must be positive integers,Does not tell user where recovery lies,"Reference source limits are invalid. Use positive integer limits, then try again.",implemented,focused assertions passed
|
||||
27,src/application/ReferenceSourceImportPolicy.ts:invalid-root,Source path cannot resolve or is not a directory,Reference source root could not be resolved / Reference source root is not a directory,Multiple low-level copies and no complete path check,"Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",implemented,focused assertions passed
|
||||
28,src/application/ReferenceSourceImportPolicy.ts:policy,No approved root exists or requested root is outside policy,No approved reference source roots / Reference source root is outside approved roots,Does not identify safe configuration recovery,"Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.",implemented,focused assertions passed
|
||||
29,src/application/ReferenceSourceImport.ts:io,Reader fails during bounded traversal,Raw reader error message,May expose filesystem details and inconsistent recovery,"Reference source files could not be read. Check directory permissions and try again.",implemented,focused assertions passed
|
||||
30,src/application/ReferenceSourceImport.ts:parse,Historical graph validation fails,Raw thrown graph validation message,Can expose internal graph invariants,"Reference source could not be indexed. Check that the source tree is readable, then try again.",implemented,focused assertions passed
|
||||
31,src/application/ReferenceSourceImportTypes.ts:limit,Import reports terminal limit failure,No reachable constructor found,Declared state may be unreachable; false verification risk,Remove unreachable top-level limit variant; per-entry limit guidance remains caller-visible.,removed,"verified by typecheck, exhaustive projection test, and source search"
|
||||
32,src/application/Setup.ts:configureDetectedClients(backup),Agent configuration backup fails,"{clientName} configuration backup verification failed; no successful configuration was reported.",Exposes machine client key and obscures no-write guarantee,"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",implemented,focused assertions passed
|
||||
33,src/application/Setup.ts:configureDetectedClients(write),Agent configuration write fails,"{clientName} configuration write verification failed; no successful configuration was reported.",Exposes machine client key and gives no recovery,"Agent configuration could not be updated. Check file permissions, then rerun setup.",implemented,focused assertions passed
|
||||
34,src/application/Setup.ts:configureDetectedClients(readback),Written agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",implemented,focused assertions passed
|
||||
35,src/application/Setup.ts:installSkill,Skill install or semantic readback fails,Agent skill installation or readback failed.,No useful recovery action,"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",implemented,focused assertions passed
|
||||
36,src/main.ts:serveStdio.onerror,Established MCP client connection reports an error,MCP stdio transport error,Exposes transport implementation and gives no recovery,REA lost its MCP client connection. Restart REA from your MCP client.,implemented,verified by injected transport onerror runtime test
|
||||
37,src/main.ts:serveStdio throw,MCP connection cannot be created,Failed to start MCP stdio transport,Exposes transport implementation and gives no recovery,REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.,implemented,verified by injected transport startup-throw runtime test
|
||||
38,src/main.ts:requestShutdown catch,Connection or analysis session close rejects,Failed to shut down cleanly,Does not explain duplicate-process risk or recovery,REA could not close cleanly. End the REA process before starting it again.,implemented,verified by injected handle-close rejection runtime test
|
||||
39,src/main.ts:entrypoint catch,Unhandled rejection escapes run(),Unexpected server startup failure,Vague and lacks diagnostic recovery,"REA could not start. Run `rea doctor`, then restart REA from your MCP client.",implemented,verified by injected entrypoint rejection test
|
||||
40,src/application/Upgrade.ts:unknown-install-method,Running package is not owned by detectable global npm installation,Update manually with: npm install --global rea-agents@latest,Already gives exact safe recovery,Keep current copy,unchanged,existing focused assertion passed
|
||||
41,src/application/Upgrade.ts:install,npm global install subprocess exits unsuccessfully,npm install --global rea-agents@latest failed.,Does not identify bounded likely checks before retry,"REA could not update through npm. Check npm registry access and global install permissions, then run: npm install --global rea-agents@latest",implemented,focused assertion passed
|
||||
42,src/application/Uninstall.ts:removeClient read,Existing agent configuration cannot be read,Configuration could not be read.,No recovery step,"Configuration could not be read. Check file permissions, then rerun uninstall.",implemented,verified by injected filesystem read failure test
|
||||
43,src/application/Uninstall.ts:removeClient parse,Agent configuration is malformed,Malformed configuration was preserved without mutation.,Does not name required repair format or retry,"Configuration is not valid JSON/TOML and was not changed. Repair it, then rerun uninstall.",implemented,focused JSON assertion passed; TOML path uses same branch
|
||||
44,src/application/Uninstall.ts:removeClient backup,Configuration backup copy fails,Configuration backup failed; no mutation was attempted.,No recovery step,"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",implemented,verified by injected filesystem backup failure test
|
||||
45,src/application/Uninstall.ts:removeClient update,Write or semantic readback fails,Configuration update failed; the original was restored and {backupPath} was retained.,Exposes local path and lacks clear next sequence,"Report whether the original was restored; if restoration also fails, direct the user to the retained backup.",implemented,verified by injected update and restoration failure tests
|
||||
45.5,src/application/Uninstall.ts:removeClient restore,Configuration update fails and restoring the original also fails,"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.","High risk; persistent configuration may be damaged, but retained backup is authoritative",Keep new recovery copy,implemented,verified by injected restoration failure test
|
||||
46,src/application/Uninstall.ts:removeManagedPath symlink,Managed cache/state/skill path is a symbolic link,Refused to follow symlink {path}.,Exposes local path and does not explain manual safety check,"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",implemented,focused assertion passed
|
||||
47,src/application/Uninstall.ts:removeManagedPath failure,Managed item removal fails,{path} could not be removed.,Exposes local path and gives no recovery,"This item could not be removed. Check file permissions, then rerun uninstall.",implemented,verified by injected managed-path removal failure test
|
||||
48,src/application/Doctor.ts:node,Runtime version is outside supported ranges,Install Node.js 22.19+ or 24.11+.,Low risk; exact dependency recovery,Keep current copy,unchanged,verified by doctor unit test
|
||||
49,src/application/Doctor.ts:host,Host OS/distribution is unsupported,"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",Low risk; clearly states supported choices,Keep current copy,unchanged,verified by doctor unit tests
|
||||
50,src/application/Doctor.ts:analysis engine,Optional deep-analysis executable cannot be found,"Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",Low risk in setup-specific diagnostic; exact recovery paths,Keep current copy,unchanged,verified by doctor unit tests
|
||||
51,src/application/Doctor.ts:target,Optional target path fails shared validation,Supply a readable local app or program path.,Low risk; tells caller valid target class,Keep current copy,unchanged,verified by doctor unit test
|
||||
52,src/application/Setup.ts:approval,Setup plan lacks approval,"Review the setup plan, then rerun interactively or with --yes.",Low risk; exact authorization recovery,Keep current copy,unchanged,verified by setup unit tests
|
||||
53,src/application/Setup.ts:engine install,Approved analysis-engine installation returns no launcher,Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.,Mentions component intentionally being installed and offers two bounded recovery paths,Keep current copy,unchanged,verified by setup unit test
|
||||
54,src/application/Setup.ts:activation,New analysis-engine install requires one-time GUI activation,"Open Hopper, complete its one-time activation, then rerun rea doctor --json.",Low risk; exact manual step and verification command,Keep current copy,unchanged,verified by setup unit test
|
||||
55,src/application/Setup.ts:optional engine,Setup completes without optional deep-analysis engine,Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.,Exposes provider terminology but this is an explanatory setup status rather than an error; recovery command is exact,Keep current copy,unchanged,verified by setup unit test
|
||||
56,src/application/Setup.ts:doctor fallback,Post-setup diagnostics remain unhealthy,Run rea doctor and apply each reported remediation.,Low risk; delegates to structured per-check recovery,Keep current copy,unchanged,verified by exact setup unit test
|
||||
57,src/application/Setup.ts:unsupported platform,Platform is neither macOS nor Linux,REA supports Hopper on macOS and selected 64-bit Linux distributions.,Low risk; states support boundary,Keep current copy,unchanged,verified by setup unit test
|
||||
58,src/application/Setup.ts:unsupported Node,Runtime version cannot run setup,Install Node.js 22.19+ or 24.11+ and rerun setup.,Low risk; exact dependency and retry,Keep current copy,unchanged,verified by setup host tests
|
||||
59,src/application/Setup.ts:unsupported macOS,macOS version is missing or older than 12,Upgrade to macOS 12 or newer.,Low risk; exact support floor,Keep current copy,unchanged,verified by setup host tests
|
||||
60,src/application/Setup.ts:unsupported Linux,Linux distribution is outside supported set,"REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",Low risk; exact supported alternatives,Keep current copy,unchanged,verified by setup unit test
|
||||
61,src/application/ProcessCli.ts:readJson read,Process scenario or evidence file cannot be read,Raw filesystem exception through CLI framework,Can expose local paths/codes and no recovery,"Process input file could not be read. Check that the path exists and is readable.",implemented,verified by focused CLI test
|
||||
62,src/application/ProcessCli.ts:readJson size,Process scenario or evidence file exceeds 64 MiB,Process JSON input exceeds byte limit,Does not state limit or recovery,"Process input file is too large. Reduce it below 64 MiB, then try again.",implemented,verified by focused sparse-file CLI test
|
||||
63,src/application/ProcessCli.ts:readJson parse,Process scenario or evidence file is malformed JSON,Raw JSON parser exception through CLI framework,Can expose parser implementation and lacks recovery,"Process input file is not valid JSON. Repair the file, then try again.",implemented,verified by focused CLI test
|
||||
64,src/application/ProcessCli.ts:capture scenario parse,Scenario fails Process Capture schema,Raw Zod validation exception through CLI framework,Exposes schema internals and field paths without stable recovery,"Process scenario is invalid. Check its required fields and limits, then try again.",implemented,verified by focused CLI test
|
||||
65,src/application/ProcessCli.ts:legacy evidence,Comparison receives Process Capture v3 Evidence,"Process Capture v3 is unsupported. Re-run the scenario with this REA version using capture_process_scenario to produce Process Capture v4; captures cannot be upgraded because v4 requires new manifest and settlement evidence.",Long but accurately explains why recapture is required and gives exact tool recovery,Keep current copy,unchanged,verified by focused CLI test
|
||||
66,src/application/ProcessCli.ts:wrong evidence,Comparison receives valid Evidence from another workflow,Expected REA Process Capture v4 Evidence,Uses internal schema name and does not explain recovery,"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",implemented,verified by focused CLI test
|
||||
67,src/application/ProcessCli.ts:unexpected,Unexpected parser/comparison/canonicalization failure escapes expected classifications,Raw thrown exception through CLI framework,Can expose stack or internal invariant,"Process command could not complete. Check the input files and run `rea doctor`, then try again.",implemented,verified by direct unexpected-cause projection test
|
||||
68,src/server/registerEvidenceTools.ts:object guard,Validated provider-tool input is unexpectedly not an object,Provider tool input was not an object,Exposes provider architecture and implies caller can fix impossible state,Remove the impossible duplicate guard and parse through the declared JSON-object schema.,removed,verified by typecheck and MCP integration tests
|
||||
69,src/server/registerEnhancedTools.ts:object guard,Validated enhanced-tool input is unexpectedly not an object,Validated enhanced input was not a JSON object,Exposes validation/JSON implementation detail,Remove the impossible duplicate guard and parse through the declared JSON-object schema.,removed,verified by typecheck and enhanced MCP integration tests
|
||||
70,src/server/registerOfficialTools.ts:object guard,Validated direct-tool input is unexpectedly not an object,Validated MCP tool input was not a JSON object,Exposes MCP validation implementation detail,Remove the impossible duplicate guard and parse through the declared JSON-object schema.,removed,verified by typecheck and official MCP integration tests
|
||||
71,src/server/registerProcessComparisonTool.ts:canonical guard,Process comparison value cannot become canonical JSON,Process capture could not be canonicalized,Names internal serialization invariant and gives no recovery,Remove the impossible canonicalization error branch; compare schema-validated values directly.,removed,verified by process-comparison MCP tests and source search
|
||||
72,src/server/sessionEvidence.ts:canonical guard,Recorded Evidence cannot become canonical JSON,Evidence could not be canonicalized,Names internal serialization invariant and gives no recovery,Remove the impossible canonicalization error branch; compare parsed Evidence directly.,removed,verified by session Evidence integrity tests and source search
|
||||
73,install.sh:--version value,--version has no following value,--version requires a value.,Low risk; identifies correction,Keep current copy,unchanged,verified by shell branch inspection
|
||||
74,install.sh:unknown option,Installer receives an unsupported flag,unknown option with caller-provided flag,Low risk; identifies offending input,Keep current copy,unchanged,verified by shell branch inspection
|
||||
75,install.sh:platform,Installer runs outside macOS or supported Linux,REA supports macOS and selected 64-bit Linux distributions.,Low risk; states support boundary,Keep current copy,unchanged,verified by shell branch inspection
|
||||
76,install.sh:curl missing,curl is absent,curl is required.,No recovery instruction,"curl is required. Install curl, then rerun this installer.",implemented,verified by exact missing-curl installer test
|
||||
77,install.sh:node missing,node is absent,Node.js 22.19+ or 24.11+ is required; install it from nodejs.org and retry.,Low risk; exact versions and recovery,Keep current copy,unchanged,verified by shell branch inspection
|
||||
78,install.sh:npm missing,npm is absent,npm is required; install it with Node.js and retry.,Low risk; exact recovery,Keep current copy,unchanged,verified by shell branch inspection
|
||||
79,install.sh:node unreadable,node exists but does not report a parseable version,could not determine the active Node.js version.,No cause-local recovery,"The active Node.js version could not be read. Check that node works and is on PATH, then retry.",implemented,verified by exact unreadable-Node-version installer test
|
||||
80,install.sh:Node 22 floor,Node 22 minor version is below 19,Node.js VERSION is unsupported; use Node.js 22.19+ or 24.11+.,Low risk; exact detected version and supported alternatives,Keep current copy,unchanged,verified by exact installer test using Node 20 and branch inspection
|
||||
81,install.sh:Node 24 floor,Node 24 minor version is below 11,Node.js VERSION is unsupported; use Node.js 24.11 or newer.,Low risk; exact detected version and floor,Keep current copy,unchanged,verified by shell branch inspection
|
||||
82,install.sh:version syntax,Explicit version is not exact semantic version,version must be an exact semantic version.,Low risk; exact correction,Keep current copy,unchanged,verified by shell branch inspection
|
||||
83,install.sh:release lookup,Latest GitHub release request fails,could not resolve the latest GitHub release.,No fallback despite supporting pinned versions,"The latest REA release could not be resolved. Check network access or pass --version VERSION, then retry.",implemented,verified by exact release-lookup installer test
|
||||
84,install.sh:release response,Latest release response is not valid expected JSON,Node JSON parser stack followed by the safe release-response message,Leaks a parser stack and buries recovery guidance,The release response was invalid. Retry later or pass --version VERSION.,implemented,verified by exact malformed-release test with parser stderr suppressed
|
||||
85,install.sh:release tag,Latest release tag does not match REA release convention,latest release tag is not a valid rea-agents release.,Exposes package naming detail and omits fallback,The latest release tag was invalid. Retry later or pass --version VERSION.,implemented,verified by exact invalid-release-tag installer test
|
||||
86,install.sh:npm prefix,npm cannot return its global prefix,could not determine the npm global prefix.,No recovery instruction,"The npm global prefix could not be read. Repair the npm configuration, then retry.",implemented,verified by exact macOS npm-prefix installer test
|
||||
87,install.sh:npm install,npm global installation exits nonzero,npm package installation failed.,No likely causes or recovery,"npm could not install REA. Check registry access and npm permissions, then retry.",implemented,verified by exact installer test
|
||||
88,install.sh:binary absent,npm exits successfully without executable,the rea command was not installed at local path,Leaks local path and no recovery,"npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry.",implemented,verified by exact absent-installed-command test
|
||||
89,install.sh:version read,Installed rea executable cannot report its version,could not read the installed REA version.,No recovery instruction,"The installed REA version could not be read. Reinstall the requested version, then retry.",implemented,verified by exact unreadable-installed-version test
|
||||
90,install.sh:version mismatch,Installed rea reports a different version,installed version ACTUAL does not match EXPECTED.,Low risk; gives exact integrity mismatch,Keep current copy,unchanged,verified by exact installer test
|
||||
90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Input validation error: Invalid arguments for tool TOOL: FIELD: Invalid input: expected TYPE, received TYPE",Low risk; public tool and field names plus exact type correction,Keep SDK-generated copy,unchanged,verified by real in-memory MCP client/server call with wrong argument type
|
||||
90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation
|
||||
|
22
install.sh
22
install.sh
|
|
@ -17,7 +17,7 @@ usage() {
|
|||
cat <<'EOF'
|
||||
Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose]
|
||||
|
||||
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and coding agents.
|
||||
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and agents.
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -54,13 +54,13 @@ fi
|
|||
|
||||
platform="$(uname -s)"
|
||||
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required. Install curl, then rerun this installer."
|
||||
command -v node >/dev/null 2>&1 || fail "Node.js 22.19+ or 24.11+ is required; install it from https://nodejs.org and retry."
|
||||
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
|
||||
|
||||
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
|
||||
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "could not determine the active Node.js version."
|
||||
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "the active Node.js version could not be read. Check that node works and is on PATH, then retry."
|
||||
if ! ((node_major == 22 && node_minor >= 19 || node_major >= 24)); then
|
||||
fail "Node.js $node_version is unsupported; use Node.js 22.19+ or 24.11+."
|
||||
fi
|
||||
|
|
@ -71,9 +71,9 @@ fi
|
|||
if [[ -n "$version" ]]; then
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "version must be an exact semantic version."
|
||||
else
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "could not resolve the latest GitHub release."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})')" || fail "GitHub returned malformed release metadata."
|
||||
[[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "latest release tag is not a valid rea-agents release."
|
||||
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry."
|
||||
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})' 2>/dev/null)" || fail "the release response was invalid. Retry later or pass --version VERSION."
|
||||
[[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "the latest release tag was invalid. Retry later or pass --version VERSION."
|
||||
version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ if [[ "$platform" == "Linux" ]]; then
|
|||
prefix_args=(--prefix "$HOME/.local")
|
||||
install_bin="$HOME/.local/bin/rea"
|
||||
else
|
||||
npm_prefix="$(npm prefix --global)" || fail "could not determine the npm global prefix."
|
||||
npm_prefix="$(npm prefix --global)" || fail "the npm global prefix could not be read. Repair the npm configuration, then retry."
|
||||
install_bin="$npm_prefix/bin/rea"
|
||||
fi
|
||||
|
||||
|
|
@ -99,9 +99,9 @@ if [[ "$dry_run" == true ]]; then
|
|||
fi
|
||||
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm package installation failed."
|
||||
[[ -x "$install_bin" ]] || fail "the rea command was not installed at $install_bin."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version."
|
||||
npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version."
|
||||
|
||||
printf 'REA %s is installed.\n' "$version"
|
||||
|
|
@ -109,5 +109,5 @@ if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
|
|||
printf 'Starting REA setup...\n'
|
||||
"$install_bin" setup </dev/tty >/dev/tty
|
||||
else
|
||||
printf 'Run %s setup to configure Hopper and coding agents.\n' "$install_bin"
|
||||
printf 'Run %s setup to configure Hopper and agents.\n' "$install_bin"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "rea-agents",
|
||||
"version": "1.2.0",
|
||||
"description": "Reverse engineer anything from your terminal or coding agent with one CLI and MCP server.",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"keywords": [
|
||||
"reverse-engineering",
|
||||
"binary-analysis",
|
||||
"coding-agents",
|
||||
"agents",
|
||||
"agent-skills",
|
||||
"cli",
|
||||
"mcp-server",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ const isMcpMode =
|
|||
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
|
||||
|
||||
if (isMcpMode) {
|
||||
const { run } = await import("../dist/main.js");
|
||||
process.exitCode = await run();
|
||||
const { runEntrypoint } = await import("../dist/main.js");
|
||||
await runEntrypoint();
|
||||
} else {
|
||||
const { createCli } = await import("../dist/cli.js");
|
||||
await createCli().serve(args);
|
||||
const { sanitizeCliOutput } = await import("../dist/cliOutput.js");
|
||||
await createCli().serve(args, {
|
||||
stdout: (output) => process.stdout.write(sanitizeCliOutput(output)),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { verifyPackagedInvestigation } from "./verify-package-investigation.mjs";
|
||||
import { verifyPackedBridge } from "./verify-packed-bridge.mjs";
|
||||
|
||||
const expectedToolCount = TOOL_CONTRACTS.length;
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ try {
|
|||
) {
|
||||
throw new Error("package contained generated Python bytecode");
|
||||
}
|
||||
await verifyPackedBridge({ root, workspace, tarball, packedFiles });
|
||||
const prefix = join(workspace, "prefix");
|
||||
const home = join(workspace, "home");
|
||||
const fakeBin = join(workspace, "bin");
|
||||
|
|
@ -488,7 +490,7 @@ try {
|
|||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true })}\n`,
|
||||
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true })}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (tarball) await rm(join(root, tarball), { force: true });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const packedBridge = "package/bridge/hopper_bridge.py";
|
||||
|
||||
/** Verify that the packed production bridge rejects a catastrophic regex. */
|
||||
export async function verifyPackedBridge({
|
||||
root,
|
||||
workspace,
|
||||
tarball,
|
||||
packedFiles,
|
||||
}) {
|
||||
if (!packedFiles.includes(packedBridge))
|
||||
throw new Error("package omitted the Hopper bridge");
|
||||
await exec("tar", ["-xf", join(root, tarball), "-C", workspace]);
|
||||
const probe = JSON.parse(
|
||||
(
|
||||
await exec("python3", [
|
||||
join(root, "tests/fixtures/bridgeRegexProbe.py"),
|
||||
join(workspace, packedBridge),
|
||||
JSON.stringify({
|
||||
action: "match",
|
||||
pattern: "(a|aa){1,35}b",
|
||||
value: "a".repeat(35),
|
||||
case_sensitive: true,
|
||||
}),
|
||||
])
|
||||
).stdout,
|
||||
);
|
||||
if (
|
||||
probe.ok !== false ||
|
||||
probe.diagnostic_type !== "invalid_request" ||
|
||||
probe.message !== "Regex exceeds the 10000-path backtracking budget"
|
||||
)
|
||||
throw new Error(
|
||||
`packaged Hopper bridge omitted regex bounds: ${JSON.stringify(probe)}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ Notes is only a documentation example. Never select an app unless the user names
|
|||
2. If setup is needed, tell the user REA needs to install its local binary-analysis tools. Do not lead with implementation details or assume the user knows reverse-engineering products.
|
||||
3. Before installing external software, obtain approval and identify what will be installed. If deeper analysis needs Hopper, describe it as REA's local analysis engine and note that it is a separate Mac app with its own license. Then run `npx -y rea-agents setup --yes`.
|
||||
4. If macOS or an installer requests human input, tell the user exactly what needs attention. After they finish, rerun setup and doctor.
|
||||
5. If setup registers a new MCP server, tell the user to restart their coding agent to load all REA tools. Direct CLI commands remain available before restart.
|
||||
5. If setup registers a new MCP server, tell the user to restart their agent to load all REA tools. Direct CLI commands remain available before restart.
|
||||
|
||||
## Locate the app
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
NoBinaryOpenError,
|
||||
type AnalysisError,
|
||||
type EvidenceLimitError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
analysisQueryId,
|
||||
createAnalysisSnapshotEntry,
|
||||
snapshotMatchesTarget,
|
||||
snapshotTarget,
|
||||
type AnalysisSnapshot,
|
||||
type AnalysisSnapshotEntry,
|
||||
type AnalysisSnapshotTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
|
||||
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"health",
|
||||
"current_address",
|
||||
"current_procedure",
|
||||
"current_document",
|
||||
"goto_address",
|
||||
"list_documents",
|
||||
"set_current_document",
|
||||
]);
|
||||
|
||||
const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"address_name",
|
||||
"comment",
|
||||
"inline_comment",
|
||||
"next_address",
|
||||
"prev_address",
|
||||
"xrefs",
|
||||
]);
|
||||
|
||||
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter(
|
||||
(name) =>
|
||||
name !== "current_document" &&
|
||||
name !== "list_documents" &&
|
||||
name !== "set_current_document",
|
||||
),
|
||||
);
|
||||
|
||||
/** Whether an operation is immutable and independent of provider UI state. */
|
||||
export const isSnapshotCacheable = (
|
||||
operation: AnalysisOperation,
|
||||
descriptor: CapabilityDescriptor | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
!STATEFUL_OPERATIONS.has(operation) &&
|
||||
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
|
||||
typeof parameters.document === "string") &&
|
||||
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
||||
typeof parameters.address === "string") &&
|
||||
descriptor?.effects.mutatesArtifact === false &&
|
||||
descriptor.effects.mayWriteFilesystem === false &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
/** Bounded in-memory cache for one immutable binary identity. */
|
||||
export class AnalysisSnapshotCache {
|
||||
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
|
||||
#target: AnalysisSnapshotTarget | undefined;
|
||||
|
||||
/** Whether staged entries belong to the supplied binary. */
|
||||
matches(target: BinaryTarget): boolean {
|
||||
return (
|
||||
this.#target === undefined || snapshotMatchesTarget(this.#target, target)
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace cache state when the active binary identity changes. */
|
||||
select(target: BinaryTarget): void {
|
||||
if (!this.matches(target)) this.#entries.clear();
|
||||
this.#target = snapshotTarget(target);
|
||||
}
|
||||
|
||||
/** Merge already-validated entries and return the new-entry count. */
|
||||
stage(snapshot: AnalysisSnapshot): number {
|
||||
if (
|
||||
this.#target !== undefined &&
|
||||
(this.#target.sha256 !== snapshot.target.sha256 ||
|
||||
this.#target.format !== snapshot.target.format ||
|
||||
this.#target.architecture !== snapshot.target.architecture)
|
||||
)
|
||||
this.#entries.clear();
|
||||
this.#target = snapshot.target;
|
||||
let imported = 0;
|
||||
for (const entry of snapshot.entries) {
|
||||
if (!this.#entries.has(entry.query_id)) imported += 1;
|
||||
this.#entries.set(entry.query_id, entry);
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
/** Build a complete snapshot for an active target. */
|
||||
export(
|
||||
target: BinaryTarget | undefined,
|
||||
evidenceBundle: EvidenceBundle,
|
||||
): Result<AnalysisSnapshot, NoBinaryOpenError> {
|
||||
if (target === undefined) return err(new NoBinaryOpenError());
|
||||
return ok({
|
||||
snapshot_version: 1,
|
||||
target: snapshotTarget(target),
|
||||
entries: this.entries(),
|
||||
evidence_bundle: evidenceBundle,
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate target identity, merge evidence atomically, then stage entries. */
|
||||
import(
|
||||
snapshot: AnalysisSnapshot,
|
||||
active: BinaryTarget | undefined,
|
||||
mergeEvidence: (
|
||||
bundle: EvidenceBundle,
|
||||
) => Result<number, EvidenceIntegrityError | EvidenceLimitError>,
|
||||
): Result<number, AnalysisError> {
|
||||
if (active !== undefined && !snapshotMatchesTarget(snapshot.target, active))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the active binary",
|
||||
),
|
||||
);
|
||||
const importedEvidence = mergeEvidence(snapshot.evidence_bundle);
|
||||
return importedEvidence.ok ? ok(this.stage(snapshot)) : importedEvidence;
|
||||
}
|
||||
|
||||
/** Return canonical entries for persistence. */
|
||||
entries(): AnalysisSnapshotEntry[] {
|
||||
return [...this.#entries.values()].sort((left, right) =>
|
||||
left.query_id.localeCompare(right.query_id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Replay an exact provider-specific query, marking its cached provenance. */
|
||||
lookup(
|
||||
target: BinaryTarget,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
provider: ProviderIdentity,
|
||||
): AnalysisExecution | undefined {
|
||||
const queryId = analysisQueryId(
|
||||
snapshotTarget(target),
|
||||
operation,
|
||||
parameters,
|
||||
provider,
|
||||
);
|
||||
const cached = this.#entries.get(queryId);
|
||||
if (cached === undefined) return undefined;
|
||||
const subject = cached.execution.subject;
|
||||
return {
|
||||
result: cached.execution.result,
|
||||
rawResult: cached.execution.raw_result,
|
||||
provider: cached.execution.provider,
|
||||
limitations: [
|
||||
...cached.execution.limitations,
|
||||
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
||||
],
|
||||
locations: cached.execution.locations,
|
||||
subject:
|
||||
subject === null
|
||||
? null
|
||||
: subject.architecture === null
|
||||
? {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
}
|
||||
: {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Record one successful immutable call unless the cache is full. */
|
||||
record(
|
||||
target: BinaryTarget,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
): void {
|
||||
if (this.#entries.size >= 10_000) return;
|
||||
this.select(target);
|
||||
const entry = createAnalysisSnapshotEntry(
|
||||
snapshotTarget(target),
|
||||
operation,
|
||||
parameters,
|
||||
execution,
|
||||
);
|
||||
this.#entries.set(entry.query_id, entry);
|
||||
}
|
||||
|
||||
/** Forget all target-bound entries. */
|
||||
clear(): void {
|
||||
this.#entries.clear();
|
||||
this.#target = undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
parseAnalysisSnapshot,
|
||||
serializeAnalysisSnapshot,
|
||||
type AnalysisSnapshot,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
|
||||
|
||||
type SnapshotFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
|
||||
/** Read and validate a bounded analysis snapshot inside an approved root. */
|
||||
export const readAnalysisSnapshot = async (
|
||||
path: string,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<Result<AnalysisSnapshot, SnapshotFailure>> => {
|
||||
const loaded = await readBoundedJson(path, policy);
|
||||
if (!loaded.ok) return loaded;
|
||||
try {
|
||||
return ok(parseAnalysisSnapshot(loaded.value));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Atomically write a deterministic analysis snapshot inside an approved root. */
|
||||
export const writeAnalysisSnapshot = async (
|
||||
snapshot: AnalysisSnapshot,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, SnapshotFailure>
|
||||
> => {
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = serializeAnalysisSnapshot(snapshot);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return writeBoundedText(encoded, path, overwrite, policy);
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
|||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
|
|
@ -33,63 +34,23 @@ import { UnknownRegistryError } from "../domain/errors.js";
|
|||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
|
||||
const REGISTRY_PROVIDER: ProviderIdentity = {
|
||||
id: "rea-unknown-registry",
|
||||
name: "REA residual unknown registry",
|
||||
version: "1",
|
||||
};
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import { snapshotMatchesTarget } from "../domain/analysisSnapshot.js";
|
||||
import {
|
||||
AnalysisSnapshotCache,
|
||||
isSnapshotCacheable,
|
||||
} from "./AnalysisSnapshotCache.js";
|
||||
import {
|
||||
UNKNOWN_REGISTRY_PROVIDER,
|
||||
unknownEvidenceLinks,
|
||||
unknownMutationEvidence,
|
||||
} from "./UnknownEvidence.js";
|
||||
import type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>;
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
listUnknowns(filters?: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
}): ResidualUnknown[];
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single active target shared by CLI and MCP adapters.
|
||||
*
|
||||
|
|
@ -111,8 +72,15 @@ export class BinarySession implements BinarySessionPort {
|
|||
maxRecords: 10_000,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
readonly #snapshot = new AnalysisSnapshotCache();
|
||||
readonly #snapshotsEnabled: boolean;
|
||||
#snapshotInvalidated = false;
|
||||
|
||||
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
|
||||
constructor(
|
||||
readonly provider: AnalysisProvider | AnalysisClientFactory,
|
||||
options: { readonly snapshotsEnabled?: boolean } = {},
|
||||
) {
|
||||
this.#snapshotsEnabled = options.snapshotsEnabled ?? true;
|
||||
this.#createClient =
|
||||
typeof provider === "function"
|
||||
? provider
|
||||
|
|
@ -185,6 +153,44 @@ export class BinarySession implements BinarySessionPort {
|
|||
return this.#evidence.import(bundle);
|
||||
}
|
||||
|
||||
/** Export immutable cached calls and session evidence for the active target. */
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
|
||||
if (!this.#snapshotsEnabled)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
||||
),
|
||||
);
|
||||
if (this.#snapshotInvalidated)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable after analysis metadata mutations",
|
||||
),
|
||||
);
|
||||
const target = this.#active?.target;
|
||||
return this.#snapshot.export(
|
||||
target,
|
||||
target === undefined
|
||||
? this.#evidence.export()
|
||||
: evidenceBundleForTarget(this.#evidence.export(), target.sha256),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stage validated cached calls for an identical binary and merge its evidence. */
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError> {
|
||||
if (!this.#snapshotsEnabled)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
||||
),
|
||||
);
|
||||
return this.#snapshot.import(snapshot, this.#active?.target, (bundle) =>
|
||||
this.#evidence.import(bundle),
|
||||
);
|
||||
}
|
||||
|
||||
/** Create an approved residual unknown and immutable mutation evidence. */
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
|
|
@ -209,21 +215,25 @@ export class BinarySession implements BinarySessionPort {
|
|||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const evidence = createEvidence(this.#active?.target, REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "update_unknown",
|
||||
parameters: {
|
||||
unknown_id: input.unknown_id,
|
||||
expected_revision: input.expected_revision,
|
||||
const evidence = createEvidence(
|
||||
this.#active?.target,
|
||||
UNKNOWN_REGISTRY_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "update_unknown",
|
||||
parameters: {
|
||||
unknown_id: input.unknown_id,
|
||||
expected_revision: input.expected_revision,
|
||||
},
|
||||
result: { action: "update", status: input.status },
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
},
|
||||
result: { action: "update", status: input.status },
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
);
|
||||
return this.#evidence.updateUnknown(input, evidence);
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +269,7 @@ export class BinarySession implements BinarySessionPort {
|
|||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
} = {},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
|
|
@ -271,10 +282,30 @@ export class BinarySession implements BinarySessionPort {
|
|||
options.targetKind,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (
|
||||
options.snapshot !== undefined &&
|
||||
!snapshotMatchesTarget(options.snapshot.target, parsed.value)
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the requested binary",
|
||||
),
|
||||
);
|
||||
if (this.#active === undefined && !this.#snapshot.matches(parsed.value))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the requested binary",
|
||||
),
|
||||
);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (this.#active?.target.path === parsed.value.path)
|
||||
if (this.#active?.target.path === parsed.value.path) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
return ok(parsed.value);
|
||||
}
|
||||
await this.#drainCalls();
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
|
|
@ -292,6 +323,17 @@ export class BinarySession implements BinarySessionPort {
|
|||
return err(new AnalysisCancelledError("open_binary"));
|
||||
}
|
||||
this.#active = { target: parsed.value, client };
|
||||
this.#snapshot.select(parsed.value);
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
this.#active = undefined;
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
this.#snapshotInvalidated = false;
|
||||
return ok(parsed.value);
|
||||
});
|
||||
}
|
||||
|
|
@ -304,6 +346,8 @@ export class BinarySession implements BinarySessionPort {
|
|||
await this.#drainCalls();
|
||||
await previous?.client.close();
|
||||
this.#evidence.clear();
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = false;
|
||||
return ok(null);
|
||||
});
|
||||
}
|
||||
|
|
@ -398,10 +442,27 @@ export class BinarySession implements BinarySessionPort {
|
|||
);
|
||||
const active = this.#active;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const cacheable = isSnapshotCacheable(name, capability, arguments_);
|
||||
const cached = cacheable
|
||||
? this.#snapshot.lookup(
|
||||
active.target,
|
||||
name,
|
||||
arguments_,
|
||||
capability.provider,
|
||||
)
|
||||
: undefined;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
return await call;
|
||||
const result = await call;
|
||||
if (result.ok && cacheable)
|
||||
this.#snapshot.record(active.target, name, arguments_, result.value);
|
||||
else if (result.ok && capability?.effects.mutatesArtifact === true) {
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = true;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.#calls.delete(call);
|
||||
}
|
||||
|
|
@ -469,41 +530,5 @@ export class BinarySession implements BinarySessionPort {
|
|||
}
|
||||
}
|
||||
|
||||
const unknownMutationEvidence = (
|
||||
target: BinaryTarget | undefined,
|
||||
input: RecordUnknownInput,
|
||||
): Evidence =>
|
||||
createEvidence(target, REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "record_unknown",
|
||||
parameters: {
|
||||
domain: input.domain,
|
||||
severity: input.severity,
|
||||
},
|
||||
result: {
|
||||
action: "record",
|
||||
question: input.question,
|
||||
required_authority: input.required_authority,
|
||||
required_confidence: input.required_confidence,
|
||||
},
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
|
||||
const unknownEvidenceLinks = (
|
||||
input: RecordUnknownInput | UpdateUnknownInput,
|
||||
): string[] =>
|
||||
[
|
||||
...input.supporting_evidence_ids,
|
||||
...input.contradicting_evidence_ids,
|
||||
...("resolution" in input && input.resolution !== null
|
||||
? input.resolution.evidence_ids
|
||||
: []),
|
||||
].filter((id, index, values) => values.indexOf(id) === index);
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
EvidenceIntegrityError,
|
||||
EvidenceLimitError,
|
||||
UnknownRegistryError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
ResidualUnknown,
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>;
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError | EvidenceLimitError>;
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError>;
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError>;
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError>;
|
||||
listUnknowns(filters?: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
}): ResidualUnknown[];
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { EvidenceFileError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Read bounded JSON data from a regular file beneath an approved root. */
|
||||
export const readBoundedJson = async (
|
||||
path: string,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<Result<unknown, EvidenceFileError>> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new EvidenceFileError("read", "disabled"));
|
||||
try {
|
||||
const canonicalPath = await realpath(resolve(path));
|
||||
if (!(await isApproved(canonicalPath, policy.roots)))
|
||||
return err(new EvidenceFileError("read", "outside-root"));
|
||||
const stats = await lstat(canonicalPath);
|
||||
if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file"));
|
||||
if (stats.size > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
const encoded = await readFile(canonicalPath);
|
||||
if (encoded.byteLength > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(encoded.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
||||
}
|
||||
const limits = validateJsonLimits(decoded, policy);
|
||||
return limits.ok ? ok(decoded) : limits;
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "io", { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
/** Atomically write bounded text to a regular file beneath an approved root. */
|
||||
export const writeBoundedText = async (
|
||||
encoded: string,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError>
|
||||
> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new EvidenceFileError("write", "disabled"));
|
||||
const bytes = Buffer.byteLength(encoded, "utf8");
|
||||
if (bytes > policy.maxBytes)
|
||||
return err(new EvidenceFileError("write", "too-large"));
|
||||
try {
|
||||
const requestedPath = resolve(path);
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
const destination = resolve(canonicalParent, basename(requestedPath));
|
||||
if (!(await isApproved(destination, policy.roots)))
|
||||
return err(new EvidenceFileError("write", "outside-root"));
|
||||
const existing = await lstat(destination).catch((cause: unknown) => {
|
||||
if (isFileNotFound(cause)) return undefined;
|
||||
throw cause;
|
||||
});
|
||||
if (existing !== undefined) {
|
||||
if (!overwrite) return err(new EvidenceFileError("write", "exists"));
|
||||
if (!existing.isFile() || existing.isSymbolicLink())
|
||||
return err(new EvidenceFileError("write", "not-file"));
|
||||
}
|
||||
await writeFileAtomic(destination, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
return ok({ path: requestedPath, bytes });
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("write", "io", { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
const isApproved = async (
|
||||
candidate: string,
|
||||
roots: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
for (const configuredRoot of roots) {
|
||||
const root = await realpath(resolve(configuredRoot));
|
||||
const relation = relative(root, candidate);
|
||||
if (
|
||||
relation === "" ||
|
||||
(!relation.startsWith("..") && !isAbsolute(relation))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Result<null, EvidenceFileError> => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
|
|
@ -4,8 +4,26 @@ import { EnhancedTools } from "./EnhancedTools.js";
|
|||
import { createBinarySession } from "./runtime.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { NativeToolName } from "../contracts/nativeToolContracts.js";
|
||||
import type { ArtifactToolName } from "../contracts/artifactToolContracts.js";
|
||||
import {
|
||||
EvidenceIntegrityError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import {
|
||||
readAnalysisSnapshot,
|
||||
writeAnalysisSnapshot,
|
||||
} from "./AnalysisSnapshotFiles.js";
|
||||
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
snapshotEvidenceForQuery,
|
||||
snapshotMatchesTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
|
||||
const WORKFLOW_PROVIDER = {
|
||||
id: "rea-workflow",
|
||||
|
|
@ -31,8 +49,15 @@ export const runDirectAnalysis = async (
|
|||
path: string,
|
||||
tool: DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
|
||||
options: {
|
||||
readonly logger?: Logger;
|
||||
readonly snapshotPath?: string | undefined;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
runAnalysis(path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
});
|
||||
|
||||
/** Execute one provider-native semantic operation with atomic provenance. */
|
||||
export const runProviderAnalysis = async (
|
||||
|
|
@ -40,15 +65,15 @@ export const runProviderAnalysis = async (
|
|||
tool: NativeToolName | ArtifactToolName,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => runAnalysis(path, tool, arguments_, logger);
|
||||
): Promise<JsonValue> =>
|
||||
runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
|
||||
|
||||
/** Describe configured providers without opening a target or launching Hopper. */
|
||||
export const runSessionStatus = async (
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
try {
|
||||
return session.status();
|
||||
|
|
@ -61,41 +86,67 @@ const runAnalysis = async (
|
|||
path: string,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger,
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly snapshotPath: string | undefined;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, snapshotPath } = options;
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const unavailable = snapshotUnavailable(
|
||||
snapshotPath,
|
||||
config.value.hopperLoaderArgs,
|
||||
);
|
||||
if (unavailable !== undefined) return cliError(unavailable);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
try {
|
||||
const opened = await session.open(path);
|
||||
if (!opened.ok)
|
||||
return { error: opened.error._tag, message: opened.error.message };
|
||||
const prepared = await prepareSnapshot({
|
||||
path,
|
||||
tool,
|
||||
arguments: arguments_,
|
||||
snapshotPath,
|
||||
policy: config.value.analysisSnapshotFilePolicy,
|
||||
provider: replayProviderFor(session, tool),
|
||||
});
|
||||
if (!prepared.ok) return cliError(prepared.error);
|
||||
if (prepared.value.evidence !== undefined) return prepared.value.evidence;
|
||||
const { snapshot } = prepared.value;
|
||||
const opened = await session.open(
|
||||
path,
|
||||
snapshot === undefined ? {} : { snapshot },
|
||||
);
|
||||
if (!opened.ok) return cliError(opened.error);
|
||||
let output: JsonValue;
|
||||
let evidence: Evidence | undefined;
|
||||
if (
|
||||
tool === "binary_overview" ||
|
||||
tool === "analyze_function" ||
|
||||
tool === "trace_feature"
|
||||
) {
|
||||
const result = await new EnhancedTools(session).execute(tool, arguments_);
|
||||
return result.ok
|
||||
? createEvidence(
|
||||
opened.value,
|
||||
tool === "analyze_function"
|
||||
? session.providerIdentity(tool)
|
||||
: WORKFLOW_PROVIDER,
|
||||
{
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
},
|
||||
)
|
||||
: { error: result.error._tag, message: result.error.message };
|
||||
}
|
||||
const result = await session.execute(tool, arguments_);
|
||||
return result.ok
|
||||
? createEvidence(
|
||||
if (!result.ok) output = cliError(result.error);
|
||||
else {
|
||||
evidence = createEvidence(
|
||||
opened.value,
|
||||
tool === "analyze_function"
|
||||
? session.providerIdentity(tool)
|
||||
: WORKFLOW_PROVIDER,
|
||||
{
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
},
|
||||
);
|
||||
output = evidence;
|
||||
}
|
||||
} else {
|
||||
const result = await session.execute(tool, arguments_);
|
||||
if (!result.ok) output = cliError(result.error);
|
||||
else {
|
||||
evidence = createEvidence(
|
||||
result.value.subject ?? opened.value,
|
||||
result.value.provider,
|
||||
{
|
||||
|
|
@ -106,9 +157,109 @@ const runAnalysis = async (
|
|||
limitations: result.value.limitations,
|
||||
locations: result.value.locations,
|
||||
},
|
||||
)
|
||||
: { error: result.error._tag, message: result.error.message };
|
||||
);
|
||||
output = evidence;
|
||||
}
|
||||
}
|
||||
if (evidence !== undefined) session.recordEvidence(evidence);
|
||||
if (snapshotPath !== undefined && evidence !== undefined) {
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) return cliError(snapshot.error);
|
||||
const written = await writeAnalysisSnapshot(
|
||||
snapshot.value,
|
||||
snapshotPath,
|
||||
true,
|
||||
config.value.analysisSnapshotFilePolicy,
|
||||
);
|
||||
if (!written.ok) return cliError(written.error);
|
||||
}
|
||||
return output;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
};
|
||||
|
||||
const prepareSnapshot = async (options: {
|
||||
readonly path: string;
|
||||
readonly tool: string;
|
||||
readonly arguments: Readonly<Record<string, JsonValue>>;
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly policy: Parameters<typeof readAnalysisSnapshot>[1];
|
||||
readonly provider: Parameters<typeof snapshotEvidenceForQuery>[1]["provider"];
|
||||
}): Promise<
|
||||
Result<
|
||||
{ readonly snapshot?: AnalysisSnapshot; readonly evidence?: Evidence },
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
const {
|
||||
path,
|
||||
tool,
|
||||
arguments: arguments_,
|
||||
snapshotPath,
|
||||
policy,
|
||||
provider,
|
||||
} = options;
|
||||
if (snapshotPath === undefined || !(await fileExists(snapshotPath)))
|
||||
return ok({});
|
||||
const loaded = await readAnalysisSnapshot(snapshotPath, policy);
|
||||
if (!loaded.ok) return loaded;
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) return target;
|
||||
if (!snapshotMatchesTarget(loaded.value.target, target.value))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot target does not match the requested binary",
|
||||
),
|
||||
);
|
||||
const evidence = snapshotEvidenceForQuery(loaded.value, {
|
||||
target: target.value,
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
provider,
|
||||
});
|
||||
return ok(
|
||||
evidence === undefined
|
||||
? { snapshot: loaded.value }
|
||||
: { snapshot: loaded.value, evidence },
|
||||
);
|
||||
};
|
||||
|
||||
const snapshotUnavailable = (
|
||||
snapshotPath: string | undefined,
|
||||
loaderArgs: readonly string[],
|
||||
): EvidenceIntegrityError | undefined =>
|
||||
snapshotPath !== undefined && loaderArgs.length > 0
|
||||
? new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable with custom Hopper loader arguments",
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const replayProviderFor = (
|
||||
session: ReturnType<typeof createBinarySession>,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
) =>
|
||||
tool === "binary_overview" || tool === "trace_feature"
|
||||
? WORKFLOW_PROVIDER
|
||||
: session.providerIdentity(tool);
|
||||
|
||||
const fileExists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
)
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const cliError = (error: AnalysisError): JsonValue => ({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
import { lstat, readFile, realpath } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import {
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
|
|
@ -15,6 +10,7 @@ import {
|
|||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
parseProcessCapture,
|
||||
} from "../domain/processCapture.js";
|
||||
import { readBoundedJson, writeBoundedText } from "./BoundedJsonFiles.js";
|
||||
|
||||
type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
|
|
@ -26,43 +22,23 @@ export const readEvidenceBundle = async (
|
|||
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
|
||||
if (policy.roots.length === 0)
|
||||
return err(new EvidenceFileError("read", "disabled"));
|
||||
const loaded = await readBoundedJson(path, policy);
|
||||
if (!loaded.ok) return loaded;
|
||||
try {
|
||||
const canonicalPath = await realpath(resolve(path));
|
||||
if (!(await isApproved(canonicalPath, policy.roots)))
|
||||
return err(new EvidenceFileError("read", "outside-root"));
|
||||
const stats = await lstat(canonicalPath);
|
||||
if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file"));
|
||||
if (stats.size > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
const encoded = await readFile(canonicalPath);
|
||||
if (encoded.byteLength > policy.maxBytes)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(encoded.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "invalid-json", { cause }));
|
||||
}
|
||||
const limits = validateJsonLimits(decoded, policy);
|
||||
if (!limits.ok) return limits;
|
||||
try {
|
||||
const bundle = parseEvidenceBundle(decoded);
|
||||
for (const record of bundle.records) {
|
||||
if (record.predicate_type === "rea.process-capture/v3")
|
||||
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
||||
if (record.predicate_type === "rea.process-capture/v4")
|
||||
parseProcessCapture(record.normalized_result);
|
||||
}
|
||||
return ok(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
const bundle = parseEvidenceBundle(loaded.value);
|
||||
for (const record of bundle.records) {
|
||||
if (record.predicate_type === "rea.process-capture/v3")
|
||||
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
||||
if (record.predicate_type === "rea.process-capture/v4")
|
||||
parseProcessCapture(record.normalized_result);
|
||||
}
|
||||
return ok(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("read", "io", { cause }));
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -90,82 +66,5 @@ export const writeEvidenceBundle = async (
|
|||
}),
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.byteLength(encoded, "utf8");
|
||||
if (bytes > policy.maxBytes)
|
||||
return err(new EvidenceFileError("write", "too-large"));
|
||||
try {
|
||||
const requestedPath = resolve(path);
|
||||
const canonicalParent = await realpath(dirname(requestedPath));
|
||||
const destination = resolve(canonicalParent, basename(requestedPath));
|
||||
if (!(await isApproved(destination, policy.roots)))
|
||||
return err(new EvidenceFileError("write", "outside-root"));
|
||||
const existing = await lstat(destination).catch((cause: unknown) => {
|
||||
if (isFileNotFound(cause)) return undefined;
|
||||
throw cause;
|
||||
});
|
||||
if (existing !== undefined) {
|
||||
if (!overwrite) return err(new EvidenceFileError("write", "exists"));
|
||||
if (!existing.isFile() || existing.isSymbolicLink())
|
||||
return err(new EvidenceFileError("write", "not-file"));
|
||||
}
|
||||
await writeFileAtomic(destination, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
return ok({ path: requestedPath, bytes });
|
||||
} catch (cause: unknown) {
|
||||
return err(new EvidenceFileError("write", "io", { cause }));
|
||||
}
|
||||
return writeBoundedText(encoded, path, overwrite, policy);
|
||||
};
|
||||
|
||||
const isApproved = async (
|
||||
candidate: string,
|
||||
roots: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
for (const configuredRoot of roots) {
|
||||
const root = await realpath(resolve(configuredRoot));
|
||||
const relation = relative(root, candidate);
|
||||
if (
|
||||
relation === "" ||
|
||||
(!relation.startsWith("..") && !isAbsolute(relation))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateJsonLimits = (
|
||||
root: unknown,
|
||||
policy: EvidenceFilePolicy,
|
||||
): Result<null, EvidenceFileError> => {
|
||||
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
nodes += 1;
|
||||
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (
|
||||
typeof current.value === "string" &&
|
||||
current.value.length > policy.maxStringLength
|
||||
)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
if (typeof current.value !== "object" || current.value === null) continue;
|
||||
for (const [key, value] of Object.entries(current.value)) {
|
||||
if (key.length > policy.maxStringLength)
|
||||
return err(new EvidenceFileError("read", "too-large"));
|
||||
pending.push({ value, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const isFileNotFound = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import type {
|
|||
ProcessExecutionPolicy,
|
||||
ProcessScenario,
|
||||
} from "../domain/processCapture.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
candidate === root ||
|
||||
|
|
@ -41,8 +44,7 @@ export const assertRealPathAuthority = async (
|
|||
|
||||
/** Expected refusal or runtime failure from the process capture adapter. */
|
||||
export const assertNotCancelled = (signal: AbortSignal | undefined): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
if (signal?.aborted === true) throw processCaptureCancelled();
|
||||
};
|
||||
|
||||
/** Runtime availability of the native PTY adapter on this host. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
import { AnalysisError } from "../domain/errors.js";
|
||||
|
||||
interface ProcessCaptureErrorOptions extends ErrorOptions {
|
||||
readonly userMessage?: string;
|
||||
readonly userCategory?: "permission_required" | "cancelled";
|
||||
}
|
||||
|
||||
/** Typed application failure produced by controlled process capture. */
|
||||
export class ProcessCaptureError extends AnalysisError {
|
||||
readonly _tag = "ProcessCaptureError";
|
||||
|
||||
override readonly userMessage: string | undefined;
|
||||
override readonly userCategory:
|
||||
| "permission_required"
|
||||
| "cancelled"
|
||||
| undefined;
|
||||
|
||||
constructor(message: string, options?: ProcessCaptureErrorOptions) {
|
||||
super(message, options);
|
||||
this.userMessage = options?.userMessage;
|
||||
this.userCategory = options?.userCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-visible cancellation without exposing capture implementation state. */
|
||||
export const processCaptureCancelled = (): ProcessCaptureError =>
|
||||
new ProcessCaptureError("process capture was cancelled", {
|
||||
userCategory: "cancelled",
|
||||
userMessage: "Process capture was cancelled. Start it again when ready.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
ProcessExecutionPolicy,
|
||||
ProcessPolicyDecision,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
TerminalFrame,
|
||||
|
|
@ -405,7 +406,11 @@ export const prepareProcessCapture = async (
|
|||
readonly before: SnapshotResult;
|
||||
}> => {
|
||||
const decision = authorizeProcessScenario(scenario, policy);
|
||||
if (!decision.allowed) throw new ProcessCaptureError(decision.reason);
|
||||
if (!decision.allowed)
|
||||
throw new ProcessCaptureError(decision.reason, {
|
||||
userCategory: "permission_required",
|
||||
userMessage: processPolicyMessage(decision.reason),
|
||||
});
|
||||
await assertRealPathAuthority(scenario, policy);
|
||||
assertNotCancelled(signal);
|
||||
const before = await snapshotRoots(scenario, signal);
|
||||
|
|
@ -415,3 +420,23 @@ export const prepareProcessCapture = async (
|
|||
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
|
||||
return { temporaryRoot, runId, home, before };
|
||||
};
|
||||
|
||||
const processPolicyMessage = (
|
||||
reason: Exclude<ProcessPolicyDecision, { readonly allowed: true }>["reason"],
|
||||
): string => {
|
||||
if (reason === "process capture is disabled")
|
||||
return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
|
||||
if (reason === "host network access is not approved by operator policy")
|
||||
return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
|
||||
if (reason === "executable is outside approved roots")
|
||||
return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
|
||||
if (reason === "working directory is outside approved roots")
|
||||
return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
||||
if (reason === "filesystem root is outside approved roots")
|
||||
return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
||||
if (
|
||||
reason === "scenario requests an environment variable not allowed by policy"
|
||||
)
|
||||
return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
|
||||
return reason satisfies never;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { parseConfig } from "../config.js";
|
||||
import { AnalysisError, projectAnalysisError } from "../domain/errors.js";
|
||||
import { createEvidence, parseEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
|
|
@ -17,17 +18,50 @@ import {
|
|||
|
||||
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Safe process-command failure returned to the CLI adapter. */
|
||||
export interface ProcessCliErrorOutput {
|
||||
readonly error: "Process command failed";
|
||||
readonly category: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Distinguish a safe process-command failure from successful Evidence. */
|
||||
export const isProcessCliErrorOutput = (
|
||||
value: unknown,
|
||||
): value is ProcessCliErrorOutput =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"error" in value &&
|
||||
value.error === "Process command failed" &&
|
||||
"category" in value &&
|
||||
typeof value.category === "string" &&
|
||||
"message" in value &&
|
||||
typeof value.message === "string";
|
||||
|
||||
/** Capture one JSON scenario through the same policy and evidence contract as MCP. */
|
||||
export const captureProcessScenarioFile = async (path: string) => {
|
||||
const scenario = parseProcessScenario(await readJson(path));
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) throw config.error;
|
||||
const captured = await captureProcessScenario(
|
||||
scenario,
|
||||
config.value.processExecutionPolicy,
|
||||
);
|
||||
if (!captured.ok) throw captured.error;
|
||||
return createProcessCaptureEvidence(scenario, captured.value);
|
||||
try {
|
||||
const input = await readJson(path);
|
||||
let scenario;
|
||||
try {
|
||||
scenario = parseProcessScenario(input);
|
||||
} catch {
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
"Process scenario is invalid. Check its required fields and limits, then try again.",
|
||||
);
|
||||
}
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliAnalysisError(config.error);
|
||||
const captured = await captureProcessScenario(
|
||||
scenario,
|
||||
config.value.processExecutionPolicy,
|
||||
);
|
||||
if (!captured.ok) return cliAnalysisError(captured.error);
|
||||
return createProcessCaptureEvidence(scenario, captured.value);
|
||||
} catch (cause: unknown) {
|
||||
return projectProcessCliError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
/** Compare two capture Evidence files and emit derived comparison Evidence. */
|
||||
|
|
@ -35,24 +69,28 @@ export const compareProcessEvidenceFiles = async (
|
|||
leftPath: string,
|
||||
rightPath: string,
|
||||
) => {
|
||||
const left = parseCaptureEvidence(await readJson(leftPath));
|
||||
const right = parseCaptureEvidence(await readJson(rightPath));
|
||||
const comparison = compareProcessCaptures(left.capture, right.capture);
|
||||
return createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-comparison/v3",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_id: left.id,
|
||||
right_evidence_id: right.id,
|
||||
left_normalization: left.capture.normalization,
|
||||
right_normalization: right.capture.normalization,
|
||||
},
|
||||
result: jsonValueSchema.parse(comparison),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: comparison.limitations,
|
||||
evidenceLinks: [left.id, right.id],
|
||||
});
|
||||
try {
|
||||
const left = parseCaptureEvidence(await readJson(leftPath));
|
||||
const right = parseCaptureEvidence(await readJson(rightPath));
|
||||
const comparison = compareProcessCaptures(left.capture, right.capture);
|
||||
return createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-comparison/v3",
|
||||
operation: "compare_process_captures",
|
||||
parameters: {
|
||||
left_evidence_id: left.id,
|
||||
right_evidence_id: right.id,
|
||||
left_normalization: left.capture.normalization,
|
||||
right_normalization: right.capture.normalization,
|
||||
},
|
||||
result: jsonValueSchema.parse(comparison),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: comparison.limitations,
|
||||
evidenceLinks: [left.id, right.id],
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return projectProcessCliError(cause);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCaptureEvidence = (input: unknown) => {
|
||||
|
|
@ -64,8 +102,14 @@ const parseCaptureEvidence = (input: unknown) => {
|
|||
evidence.provider.version !== PROCESS_PROVIDER.version
|
||||
) {
|
||||
if (evidence.predicate_type === "rea.process-capture/v3")
|
||||
throw new TypeError(LEGACY_PROCESS_CAPTURE_MESSAGE);
|
||||
throw new TypeError("Expected REA Process Capture v4 Evidence");
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
);
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: evidence.evidence_id,
|
||||
|
|
@ -74,8 +118,59 @@ const parseCaptureEvidence = (input: unknown) => {
|
|||
};
|
||||
|
||||
const readJson = async (path: string): Promise<unknown> => {
|
||||
const bytes = await readFile(path);
|
||||
let bytes;
|
||||
try {
|
||||
bytes = await readFile(path);
|
||||
} catch {
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
"Process input file could not be read. Check that the path exists and is readable.",
|
||||
);
|
||||
}
|
||||
if (bytes.length > MAX_INPUT_BYTES)
|
||||
throw new TypeError("Process JSON input exceeds byte limit");
|
||||
return JSON.parse(bytes.toString("utf8")) as unknown;
|
||||
throw new ProcessCliFailure(
|
||||
"truncated",
|
||||
"Process input file is too large. Reduce it below 64 MiB, then try again.",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(bytes.toString("utf8")) as unknown;
|
||||
} catch {
|
||||
throw new ProcessCliFailure(
|
||||
"invalid_input",
|
||||
"Process input file is not valid JSON. Repair the file, then try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
class ProcessCliFailure extends Error {
|
||||
constructor(
|
||||
readonly category: string,
|
||||
readonly userMessage: string,
|
||||
) {
|
||||
super(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const cliAnalysisError = (error: AnalysisError): ProcessCliErrorOutput => ({
|
||||
error: "Process command failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
|
||||
/** Project any process CLI failure without exposing its cause. */
|
||||
export const projectProcessCliError = (
|
||||
cause: unknown,
|
||||
): ProcessCliErrorOutput => {
|
||||
if (cause instanceof ProcessCliFailure)
|
||||
return {
|
||||
error: "Process command failed",
|
||||
category: cause.category,
|
||||
message: cause.userMessage,
|
||||
};
|
||||
if (cause instanceof AnalysisError) return cliAnalysisError(cause);
|
||||
return {
|
||||
error: "Process command failed",
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Process command could not complete. Check the input files and run `rea doctor`, then try again.",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import type {
|
|||
} from "../domain/processCapture.js";
|
||||
import { validateProcessCapture } from "../domain/processCapture.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
|
|
@ -372,8 +375,7 @@ const completeCapture = async (options: {
|
|||
const { runtime, scenario } = options;
|
||||
runtime.checkpoints.trigger("root_exit");
|
||||
const { reason } = options.exit;
|
||||
if (reason === "cancelled")
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
[
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ export interface ParsedReferenceSourceEntries {
|
|||
readonly limitations: string[];
|
||||
}
|
||||
|
||||
/** Project a low-level entry failure into safe import guidance. */
|
||||
export const projectReferenceSourceEntryFailure = (
|
||||
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
|
||||
): string => {
|
||||
if (entry.code === "cancelled")
|
||||
return "This entry was not read because the import was cancelled. Start the import again when ready.";
|
||||
if (entry.code === "limit")
|
||||
return "This entry was not read because an import limit was reached. Import a smaller directory or raise the configured limits.";
|
||||
if (entry.code === "unsupported")
|
||||
return "This entry cannot be read safely on this system. Exclude it or import the directory on a supported system.";
|
||||
if (entry.kind === "directory")
|
||||
return "This directory could not be read. Check its permissions, then try again.";
|
||||
if (entry.kind === "symlink")
|
||||
return "This symbolic link could not be read safely. Check the link and its permissions, then try again.";
|
||||
return "This file could not be read. Check its permissions, then try again.";
|
||||
};
|
||||
|
||||
const hashBytes = (bytes: Uint8Array): string =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
|
|
@ -27,13 +44,14 @@ const failedEntry = (
|
|||
entry: Extract<ReferenceSourceEntry, { status: "failed" }>,
|
||||
): HistoricalSourceGraphInput["entries"][number] => {
|
||||
const classifications = classifyReferenceSourcePath(entry.path);
|
||||
const limitation = projectReferenceSourceEntryFailure(entry);
|
||||
if (entry.kind === "directory")
|
||||
return {
|
||||
path: entry.path,
|
||||
kind: "directory",
|
||||
classifications,
|
||||
tree_state: entry.code === "limit" ? "partial" : "unreadable",
|
||||
limitations: [entry.message],
|
||||
limitations: [limitation],
|
||||
};
|
||||
if (entry.kind === "symlink")
|
||||
return {
|
||||
|
|
@ -42,7 +60,7 @@ const failedEntry = (
|
|||
target: "<unreadable>",
|
||||
target_state: "unreadable",
|
||||
classifications,
|
||||
limitations: [entry.message],
|
||||
limitations: [limitation],
|
||||
};
|
||||
return {
|
||||
path: entry.path,
|
||||
|
|
@ -52,7 +70,7 @@ const failedEntry = (
|
|||
language: null,
|
||||
classifications: entry.kind === "file" ? classifications : ["unknown"],
|
||||
content_state: entry.code === "limit" ? "too-large" : "unreadable",
|
||||
limitations: [entry.message],
|
||||
limitations: [limitation],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,51 @@ export interface ReferenceSourceImportError {
|
|||
| "invalid-root"
|
||||
| "io"
|
||||
| "parse"
|
||||
| "policy"
|
||||
| "limit";
|
||||
| "policy";
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Safe CLI projection for a historical-source import failure. */
|
||||
export const projectReferenceSourceImportError = (
|
||||
error: ReferenceSourceImportError,
|
||||
): Readonly<{ category: string; message: string }> => {
|
||||
if (error.code === "cancelled")
|
||||
return {
|
||||
category: "cancelled",
|
||||
message:
|
||||
"Reference source import was cancelled. Start it again when ready.",
|
||||
};
|
||||
if (error.code === "invalid-limits")
|
||||
return {
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Reference source limits are invalid. Use positive integer limits, then try again.",
|
||||
};
|
||||
if (error.code === "invalid-root")
|
||||
return {
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",
|
||||
};
|
||||
if (error.code === "policy")
|
||||
return {
|
||||
category: "permission_required",
|
||||
message:
|
||||
"Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.",
|
||||
};
|
||||
if (error.code === "io")
|
||||
return {
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Reference source files could not be read. Check directory permissions and try again.",
|
||||
};
|
||||
return {
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Reference source could not be indexed. Check that the source tree is readable, then try again.",
|
||||
};
|
||||
};
|
||||
|
||||
/** Bounded import request paired with mandatory operator policy. */
|
||||
export interface ReferenceSourceImportOptions {
|
||||
readonly root: string;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
import { installMacHopper } from "./MacHopper.js";
|
||||
import { configureDetectedClients } from "./SetupClients.js";
|
||||
import { installCanonicalSkill } from "./SetupSkill.js";
|
||||
|
||||
export { installCanonicalSkill } from "./SetupSkill.js";
|
||||
|
|
@ -140,28 +141,6 @@ const setupPlan = (
|
|||
},
|
||||
];
|
||||
|
||||
const configureDetectedClients = async (options: {
|
||||
readonly host: SetupHost;
|
||||
readonly detectedClients: readonly SetupClient[];
|
||||
readonly hopperPath: string | undefined;
|
||||
readonly clients: Record<string, ClientConfigurationResult>;
|
||||
readonly appliedActions: string[];
|
||||
}): Promise<string | undefined> => {
|
||||
for (const client of options.detectedClients) {
|
||||
const result = await options.host.configureClient(
|
||||
client,
|
||||
options.hopperPath,
|
||||
registrationCommand(),
|
||||
);
|
||||
options.clients[client.name] = result;
|
||||
if (result.status === "failed")
|
||||
return `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`;
|
||||
if (result.status === "configured")
|
||||
options.appliedActions.push(`configured_${client.name}`);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Install prerequisites and configure detected clients idempotently.
|
||||
* Discovery always precedes mutation. Interactive confirmation or explicit
|
||||
|
|
@ -226,13 +205,16 @@ export const runSetup = async (
|
|||
host,
|
||||
detectedClients,
|
||||
hopperPath,
|
||||
command: registrationCommand(),
|
||||
clients,
|
||||
appliedActions,
|
||||
});
|
||||
if (clientFailure !== undefined) return fail(clientFailure);
|
||||
const skill = await host.installSkill();
|
||||
if (skill === "failed")
|
||||
return fail("Agent skill installation or readback failed.");
|
||||
return fail(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
|
||||
);
|
||||
if (skill === "installed") appliedActions.push("installed_skill");
|
||||
const doctor = await host.doctor();
|
||||
const activationRequired = appliedActions.includes("installed_hopper");
|
||||
|
|
@ -301,7 +283,7 @@ const systemSetupHost = (): SetupHost => {
|
|||
};
|
||||
};
|
||||
|
||||
/** Detect supported coding agents from stable per-user installation markers. */
|
||||
/** Detect supported agents from stable per-user installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import type {
|
||||
ClientConfigurationResult,
|
||||
SetupClient,
|
||||
SetupHost,
|
||||
} from "./Setup.js";
|
||||
|
||||
const failedConfigurationMessage = (
|
||||
reason: "backup" | "write" | "readback",
|
||||
): string => {
|
||||
if (reason === "backup")
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
if (reason === "write")
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
|
||||
};
|
||||
|
||||
/** Configure each detected agent, stopping after the first failed transaction. */
|
||||
export const configureDetectedClients = async (options: {
|
||||
readonly host: SetupHost;
|
||||
readonly detectedClients: readonly SetupClient[];
|
||||
readonly hopperPath: string | undefined;
|
||||
readonly command: readonly string[];
|
||||
readonly clients: Record<string, ClientConfigurationResult>;
|
||||
readonly appliedActions: string[];
|
||||
}): Promise<string | undefined> => {
|
||||
for (const client of options.detectedClients) {
|
||||
const result = await options.host.configureClient(
|
||||
client,
|
||||
options.hopperPath,
|
||||
options.command,
|
||||
);
|
||||
options.clients[client.name] = result;
|
||||
if (result.status === "failed")
|
||||
return failedConfigurationMessage(result.reason);
|
||||
if (result.status === "configured")
|
||||
options.appliedActions.push(`configured_${client.name}`);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
|
@ -9,6 +9,28 @@ import { z } from "zod";
|
|||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { supportedClients, type SetupClient } from "./Setup.js";
|
||||
|
||||
interface ManagedPathStats {
|
||||
isSymbolicLink(): boolean;
|
||||
}
|
||||
|
||||
/** Injectable filesystem operations used to prove uninstall failure recovery. */
|
||||
export interface UninstallFileSystem {
|
||||
readText(path: string): Promise<string>;
|
||||
copy(source: string, destination: string): Promise<void>;
|
||||
writeText(path: string, contents: string): Promise<void>;
|
||||
stat(path: string): Promise<ManagedPathStats>;
|
||||
remove(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
const systemFileSystem: UninstallFileSystem = {
|
||||
readText: (path) => readFile(path, "utf8"),
|
||||
copy: (source, destination) => copyFile(source, destination),
|
||||
writeText: (path, contents) =>
|
||||
writeFileAtomic(path, contents, { encoding: "utf8" }),
|
||||
stat: (path) => lstat(path),
|
||||
remove: (path) => rm(path, { recursive: true }),
|
||||
};
|
||||
|
||||
/** One explicitly classified uninstall action. */
|
||||
export interface UninstallItem {
|
||||
readonly name: string;
|
||||
|
|
@ -54,21 +76,28 @@ export const runUninstall = async (
|
|||
};
|
||||
|
||||
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
|
||||
export const systemUninstallHost = (home = homedir()): UninstallHost => ({
|
||||
export const systemUninstallHost = (
|
||||
home = homedir(),
|
||||
fileSystem: UninstallFileSystem = systemFileSystem,
|
||||
): UninstallHost => ({
|
||||
clients: () => Promise.resolve(supportedClients(home)),
|
||||
removeClient,
|
||||
removeClient: (client) => removeClient(client, fileSystem),
|
||||
removeSkill: () =>
|
||||
removeManagedPath(
|
||||
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
|
||||
"skill",
|
||||
fileSystem,
|
||||
),
|
||||
purgeData: async () => [
|
||||
await removeManagedPath(join(home, ".rea/cache"), "cache"),
|
||||
await removeManagedPath(join(home, ".rea/state"), "state"),
|
||||
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
||||
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
||||
],
|
||||
});
|
||||
|
||||
const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
||||
const removeClient = async (
|
||||
client: SetupClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
): Promise<UninstallItem> => {
|
||||
if (client.format === "unsupported")
|
||||
return item(
|
||||
client.name,
|
||||
|
|
@ -77,11 +106,15 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
|||
);
|
||||
let original: string;
|
||||
try {
|
||||
original = await readFile(client.configPath, "utf8");
|
||||
original = await fileSystem.readText(client.configPath);
|
||||
} catch (cause: unknown) {
|
||||
return isMissing(cause)
|
||||
? item(client.name, "skipped", "Configuration does not exist.")
|
||||
: item(client.name, "failed", "Configuration could not be read.");
|
||||
: item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
let document: Record<string, unknown>;
|
||||
let key: "mcp_servers" | "mcpServers";
|
||||
|
|
@ -96,7 +129,7 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
|||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Malformed configuration was preserved without mutation.",
|
||||
`Configuration is not valid ${client.format === "toml" ? "TOML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
|
|
@ -113,12 +146,12 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
|||
document[key] = remaining;
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await copyFile(client.configPath, backupPath);
|
||||
await fileSystem.copy(client.configPath, backupPath);
|
||||
} catch {
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration backup failed; no mutation was attempted.",
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
|
@ -126,11 +159,11 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
|||
client.format === "toml"
|
||||
? stringifyToml(document)
|
||||
: `${JSON.stringify(document, null, 2)}\n`;
|
||||
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
|
||||
await fileSystem.writeText(client.configPath, encoded);
|
||||
const readback = objectSchema.parse(
|
||||
client.format === "toml"
|
||||
? parseToml(await readFile(client.configPath, "utf8"))
|
||||
: JSON.parse(await readFile(client.configPath, "utf8")),
|
||||
? parseToml(await fileSystem.readText(client.configPath))
|
||||
: JSON.parse(await fileSystem.readText(client.configPath)),
|
||||
);
|
||||
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
|
||||
throw new Error("registration readback mismatch");
|
||||
|
|
@ -140,13 +173,19 @@ const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
|||
`Removed registration from ${client.configPath}.`,
|
||||
);
|
||||
} catch {
|
||||
await writeFileAtomic(client.configPath, original, {
|
||||
encoding: "utf8",
|
||||
}).catch(() => undefined);
|
||||
try {
|
||||
await fileSystem.writeText(client.configPath, original);
|
||||
} catch {
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
`Configuration update failed; the original was restored and ${backupPath} was retained.`,
|
||||
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -170,17 +209,26 @@ const isOwnedRegistration = (value: unknown): boolean => {
|
|||
const removeManagedPath = async (
|
||||
path: string,
|
||||
name: string,
|
||||
fileSystem: UninstallFileSystem,
|
||||
): Promise<UninstallItem> => {
|
||||
try {
|
||||
const stats = await lstat(path);
|
||||
const stats = await fileSystem.stat(path);
|
||||
if (stats.isSymbolicLink())
|
||||
return item(name, "retained", `Refused to follow symlink ${path}.`);
|
||||
await rm(path, { recursive: true });
|
||||
return item(
|
||||
name,
|
||||
"retained",
|
||||
"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",
|
||||
);
|
||||
await fileSystem.remove(path);
|
||||
return item(name, "removed", `Removed ${path}.`);
|
||||
} catch (cause: unknown) {
|
||||
return isMissing(cause)
|
||||
? item(name, "skipped", `${path} does not exist.`)
|
||||
: item(name, "failed", `${path} could not be removed.`);
|
||||
: item(
|
||||
name,
|
||||
"failed",
|
||||
"This item could not be removed. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
|
||||
/** Provider identity for analyst-authored residual-unknown mutations. */
|
||||
export const UNKNOWN_REGISTRY_PROVIDER: ProviderIdentity = {
|
||||
id: "rea-unknown-registry",
|
||||
name: "REA residual unknown registry",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
/** Create immutable evidence for one approved residual-unknown mutation. */
|
||||
export const unknownMutationEvidence = (
|
||||
target: BinaryTarget | undefined,
|
||||
input: RecordUnknownInput,
|
||||
): Evidence =>
|
||||
createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
|
||||
predicateType: "rea.residual-unknown-mutation/v1",
|
||||
operation: "record_unknown",
|
||||
parameters: { domain: input.domain, severity: input.severity },
|
||||
result: {
|
||||
action: "record",
|
||||
question: input.question,
|
||||
required_authority: input.required_authority,
|
||||
required_confidence: input.required_confidence,
|
||||
},
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
evidenceLinks: unknownEvidenceLinks(input),
|
||||
limitations: [
|
||||
"Registry mutation evidence records analyst intent, not proof of the answer.",
|
||||
],
|
||||
});
|
||||
|
||||
/** Deduplicate evidence links carried by an unknown mutation. */
|
||||
export const unknownEvidenceLinks = (
|
||||
input: RecordUnknownInput | UpdateUnknownInput,
|
||||
): string[] =>
|
||||
[
|
||||
...input.supporting_evidence_ids,
|
||||
...input.contradicting_evidence_ids,
|
||||
...("resolution" in input && input.resolution !== null
|
||||
? input.resolution.evidence_ids
|
||||
: []),
|
||||
].filter((id, index, values) => values.indexOf(id) === index);
|
||||
|
|
@ -92,7 +92,7 @@ export const runUpgrade = async (
|
|||
currentVersion,
|
||||
latestVersion: latestVersion ?? null,
|
||||
reason: "install",
|
||||
remediation: `${UPGRADE_COMMAND} failed.`,
|
||||
remediation: `REA could not update through npm. Check npm registry access and global install permissions, then run: ${UPGRADE_COMMAND}`,
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@ export const createBinarySession = (
|
|||
new NativeMacOSProvider(),
|
||||
new HopperProvider(config, logger),
|
||||
]),
|
||||
{ snapshotsEnabled: config.hopperLoaderArgs.length === 0 },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
45
src/cli.ts
45
src/cli.ts
|
|
@ -19,6 +19,8 @@ import { importReferenceSource } from "./application/ReferenceSourceImport.js";
|
|||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cliProcessCommands.js";
|
||||
import { registerInvestigationCommands } from "./cliInvestigationCommands.js";
|
||||
import { projectAnalysisError } from "./domain/errors.js";
|
||||
import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
|
|
@ -34,8 +36,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
|||
);
|
||||
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
|
||||
version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
|
||||
description:
|
||||
"Reverse engineer anything from your terminal or coding agent.",
|
||||
description: "Reverse engineer anything from your terminal or agent.",
|
||||
mcp: {
|
||||
command: PRODUCT_IDENTITY.mcpCommand,
|
||||
instructions:
|
||||
|
|
@ -75,6 +76,11 @@ const registerCoreCommands = (
|
|||
const overviewOptions = z.object({
|
||||
detail: z.enum(["concise", "detailed"]).default("concise"),
|
||||
limit: z.number().int().min(1).max(50).default(10),
|
||||
snapshot: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Load and update a local analysis snapshot"),
|
||||
});
|
||||
cli.command("analyze", {
|
||||
description: "Get an overview of an app",
|
||||
|
|
@ -88,7 +94,7 @@ const registerCoreCommands = (
|
|||
args.path,
|
||||
"binary_overview",
|
||||
{ detail: options.detail, limit: options.limit },
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -104,7 +110,7 @@ const registerCoreCommands = (
|
|||
args.path,
|
||||
"binary_overview",
|
||||
{ detail: options.detail, limit: options.limit },
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -114,13 +120,14 @@ const registerCoreCommands = (
|
|||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Procedure address"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object({ snapshot: z.string().min(1).optional() }),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "decompile", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: args.address },
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -131,7 +138,7 @@ const registerSetupCommands = (
|
|||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("setup", {
|
||||
description: "Install requirements and configure coding agents",
|
||||
description: "Install requirements and configure agents",
|
||||
options: z.object({
|
||||
yes: z
|
||||
.boolean()
|
||||
|
|
@ -223,13 +230,14 @@ const registerXrefsCommand = (
|
|||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Hexadecimal address"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
options: z.object({ snapshot: z.string().min(1).optional() }),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "xrefs", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"xrefs",
|
||||
{ address: args.address },
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -249,6 +257,7 @@ const registerTraceCommand = (
|
|||
caseSensitive: z.boolean().default(false),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
maxOperations: z.number().int().min(1).max(100).default(20),
|
||||
snapshot: z.string().min(1).optional(),
|
||||
}),
|
||||
alias: {
|
||||
caseSensitive: "case-sensitive",
|
||||
|
|
@ -265,7 +274,7 @@ const registerTraceCommand = (
|
|||
limit: options.limit,
|
||||
max_operations: options.maxOperations,
|
||||
},
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -301,6 +310,7 @@ const registerFunctionCommand = (
|
|||
limit: z.number().int().min(1).max(500).default(100),
|
||||
maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
|
||||
maxInstructions: z.number().int().min(1).max(5_000).default(500),
|
||||
snapshot: z.string().min(1).optional(),
|
||||
}),
|
||||
alias: {
|
||||
includeAssembly: "include-assembly",
|
||||
|
|
@ -319,7 +329,7 @@ const registerFunctionCommand = (
|
|||
max_pseudocode_chars: options.maxPseudocodeChars,
|
||||
max_instructions: options.maxInstructions,
|
||||
},
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -341,6 +351,7 @@ const registerSearchCommand = (
|
|||
caseSensitive: z.boolean().default(false),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(100).default(100),
|
||||
snapshot: z.string().min(1).optional(),
|
||||
}),
|
||||
alias: { caseSensitive: "case-sensitive" },
|
||||
run: ({ args, options }) =>
|
||||
|
|
@ -355,7 +366,7 @@ const registerSearchCommand = (
|
|||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
},
|
||||
logger,
|
||||
{ logger, snapshotPath: options.snapshot },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
@ -376,7 +387,10 @@ const registerReferenceSourceCommand = (
|
|||
logCliCommand(logger, "import-reference-source", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
return {
|
||||
error: "Import failed",
|
||||
...projectAnalysisError(config.error),
|
||||
};
|
||||
const imported = await importReferenceSource({
|
||||
root: args.root,
|
||||
caller: "rea-cli",
|
||||
|
|
@ -386,7 +400,10 @@ const registerReferenceSourceCommand = (
|
|||
});
|
||||
return imported.ok
|
||||
? imported.value
|
||||
: { error: imported.error.code, message: imported.error.message };
|
||||
: {
|
||||
error: "Import failed",
|
||||
...projectReferenceSourceImportError(imported.error),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
import { parseConfig } from "./config.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
|
||||
|
||||
/** Register filesystem-gated Evidence v2 commands. */
|
||||
export const registerEvidenceCommands = (
|
||||
|
|
@ -22,15 +24,12 @@ export const registerEvidenceCommands = (
|
|||
run: ({ args }) =>
|
||||
logCliCommand(logger, "evidence-import", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const imported = await importEvidenceBundleCommand(
|
||||
args.path,
|
||||
config.value.evidenceFilePolicy,
|
||||
);
|
||||
return imported.ok
|
||||
? imported.value
|
||||
: { error: imported.error._tag, message: imported.error.message };
|
||||
return imported.ok ? imported.value : cliError(imported.error);
|
||||
}),
|
||||
});
|
||||
cli.command("evidence-export", {
|
||||
|
|
@ -45,17 +44,14 @@ export const registerEvidenceCommands = (
|
|||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "evidence-export", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const exported = await exportEvidenceBundleCommand(
|
||||
args.source,
|
||||
args.output,
|
||||
options.overwrite,
|
||||
config.value.evidenceFilePolicy,
|
||||
);
|
||||
return exported.ok
|
||||
? exported.value
|
||||
: { error: exported.error._tag, message: exported.error.message };
|
||||
return exported.ok ? exported.value : cliError(exported.error);
|
||||
}),
|
||||
});
|
||||
cli.command("compare", {
|
||||
|
|
@ -72,8 +68,7 @@ export const registerEvidenceCommands = (
|
|||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "compare", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const compared = await compareEvidenceBundlesCommand({
|
||||
leftPath: args.left,
|
||||
rightPath: args.right,
|
||||
|
|
@ -81,9 +76,12 @@ export const registerEvidenceCommands = (
|
|||
limit: options.limit,
|
||||
policy: config.value.evidenceFilePolicy,
|
||||
});
|
||||
return compared.ok
|
||||
? compared.value
|
||||
: { error: compared.error._tag, message: compared.error.message };
|
||||
return compared.ok ? compared.value : cliError(compared.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const cliError = (error: AnalysisError): JsonValue => ({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
const SAFE_VALIDATION_MESSAGE =
|
||||
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const validationError = (value: unknown): JsonRecord | undefined => {
|
||||
if (!isRecord(value)) return undefined;
|
||||
if (value.code === "VALIDATION_ERROR") return value;
|
||||
if (value.ok === false && isRecord(value.error))
|
||||
return value.error.code === "VALIDATION_ERROR" ? value.error : undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Remove validator internals from Incur's caller-visible CLI output. */
|
||||
export const sanitizeCliOutput = (output: string): string => {
|
||||
const trimmed = output.trimStart();
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
const error = validationError(parsed);
|
||||
if (error !== undefined) {
|
||||
const safeError = {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: SAFE_VALIDATION_MESSAGE,
|
||||
};
|
||||
if (error === parsed) return `${JSON.stringify(safeError)}\n`;
|
||||
if (!isRecord(parsed)) return output;
|
||||
return `${JSON.stringify({ ...parsed, error: safeError })}\n`;
|
||||
}
|
||||
} catch {
|
||||
return output;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (
|
||||
/^ok: false\r?\nerror:\r?\n code: VALIDATION_ERROR(?:\r?\n|$)/u.test(
|
||||
trimmed,
|
||||
)
|
||||
)
|
||||
return `ok: false\nerror:\n code: VALIDATION_ERROR\n message: "${SAFE_VALIDATION_MESSAGE}"\n`;
|
||||
if (!/^code: VALIDATION_ERROR(?:\r?\n|$)/u.test(trimmed)) return output;
|
||||
return `code: VALIDATION_ERROR\nmessage: "${SAFE_VALIDATION_MESSAGE}"\n`;
|
||||
};
|
||||
|
|
@ -1,12 +1,26 @@
|
|||
import { Cli, z } from "incur";
|
||||
import { Cli, Errors, z } from "incur";
|
||||
|
||||
import {
|
||||
captureProcessScenarioFile,
|
||||
compareProcessEvidenceFiles,
|
||||
isProcessCliErrorOutput,
|
||||
} from "./application/ProcessCli.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const requireProcessCommandSuccess = async <Value>(
|
||||
execute: () => Promise<Value>,
|
||||
): Promise<Value> => {
|
||||
const value = await execute();
|
||||
if (isProcessCliErrorOutput(value))
|
||||
throw new Errors.IncurError({
|
||||
code: "PROCESS_COMMAND_FAILED",
|
||||
message: value.message,
|
||||
retryable: false,
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Register Process Capture v4 one-shot commands through shared application services. */
|
||||
export const registerProcessCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
|
|
@ -17,7 +31,9 @@ export const registerProcessCommands = (
|
|||
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "capture-process", () =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
requireProcessCommandSuccess(() =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command("compare-process-captures", {
|
||||
|
|
@ -28,7 +44,9 @@ export const registerProcessCommands = (
|
|||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "compare-process-captures", () =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
requireProcessCommandSuccess(() =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface AppConfig {
|
|||
readonly processExecutionPolicy: ProcessExecutionPolicy;
|
||||
readonly artifactNativeMountEnabled: boolean;
|
||||
readonly evidenceFilePolicy: EvidenceFilePolicy;
|
||||
readonly analysisSnapshotFilePolicy: EvidenceFilePolicy;
|
||||
readonly referenceSourcePolicy: ReferenceSourcePolicy;
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ const environmentSchema = z.object({
|
|||
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
|
||||
REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_REFERENCE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
|
||||
});
|
||||
|
|
@ -68,6 +70,14 @@ const parseStringArray = (
|
|||
}
|
||||
};
|
||||
|
||||
const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
|
||||
roots,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse Hopper launcher configuration once at the composition root.
|
||||
* Explicit loader arguments override REA's header-derived defaults for a
|
||||
|
|
@ -127,6 +137,11 @@ export const parseConfig = (
|
|||
"REA_EVIDENCE_ROOTS_JSON",
|
||||
);
|
||||
if (!evidenceRoots.ok) return evidenceRoots;
|
||||
const analysisSnapshotRoots = parseStringArray(
|
||||
parsedEnvironment.data.REA_ANALYSIS_SNAPSHOT_ROOTS_JSON,
|
||||
"REA_ANALYSIS_SNAPSHOT_ROOTS_JSON",
|
||||
);
|
||||
if (!analysisSnapshotRoots.ok) return analysisSnapshotRoots;
|
||||
const referenceRoots = parseStringArray(
|
||||
parsedEnvironment.data.REA_REFERENCE_ROOTS_JSON,
|
||||
"REA_REFERENCE_ROOTS_JSON",
|
||||
|
|
@ -155,13 +170,8 @@ export const parseConfig = (
|
|||
},
|
||||
artifactNativeMountEnabled:
|
||||
parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true",
|
||||
evidenceFilePolicy: {
|
||||
roots: evidenceRoots.value,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
},
|
||||
evidenceFilePolicy: filePolicy(evidenceRoots.value),
|
||||
analysisSnapshotFilePolicy: filePolicy(analysisSnapshotRoots.value),
|
||||
referenceSourcePolicy: {
|
||||
roots: referenceRoots.value,
|
||||
secretPatterns: secretPatterns.value,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/** Input contract for opening a target with an optional staged snapshot. */
|
||||
export const openBinaryInputSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
snapshot_path: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** Input contract for closing a target after an optional atomic snapshot. */
|
||||
export const closeBinaryInputSchema = z.object({
|
||||
snapshot_path: z.string().min(1).optional(),
|
||||
overwrite: z.boolean().default(false),
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
/** Caller-visible MCP execution hints. */
|
||||
export interface ToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
readonly destructiveHint: boolean;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: boolean;
|
||||
}
|
||||
|
||||
/** One validated example request for an MCP tool contract. */
|
||||
export interface ToolExample {
|
||||
readonly title: string;
|
||||
readonly input: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
export type ToolKind =
|
||||
| "official-proxy"
|
||||
| "enhanced"
|
||||
| "native-provider"
|
||||
| "artifact-provider"
|
||||
| "session";
|
||||
|
||||
/** Single source of truth for a public MCP tool. */
|
||||
export interface ToolContract<Name extends string = string> {
|
||||
readonly name: Name;
|
||||
readonly description: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly inputSchema: z.ZodObject;
|
||||
readonly outputSchema: z.ZodObject;
|
||||
readonly annotations: ToolAnnotations;
|
||||
readonly examples: readonly ToolExample[];
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
processCaptureSchema,
|
||||
processScenarioSchema,
|
||||
|
|
@ -26,6 +26,22 @@ import {
|
|||
import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import {
|
||||
closeBinaryInputSchema,
|
||||
openBinaryInputSchema,
|
||||
} from "./sessionLifecycleInputs.js";
|
||||
import type {
|
||||
ToolAnnotations,
|
||||
ToolContract,
|
||||
ToolExample,
|
||||
ToolKind,
|
||||
} from "./toolContractTypes.js";
|
||||
|
||||
export type {
|
||||
ToolAnnotations,
|
||||
ToolContract,
|
||||
ToolExample,
|
||||
} from "./toolContractTypes.js";
|
||||
|
||||
const document = z.string().optional().describe("The document name");
|
||||
const address = z.string().describe("A Hopper address");
|
||||
|
|
@ -53,37 +69,6 @@ const searchInput = {
|
|||
document,
|
||||
};
|
||||
|
||||
export interface ToolAnnotations {
|
||||
readonly readOnlyHint: boolean;
|
||||
readonly destructiveHint: boolean;
|
||||
readonly idempotentHint: boolean;
|
||||
readonly openWorldHint: boolean;
|
||||
}
|
||||
|
||||
export interface ToolExample {
|
||||
readonly title: string;
|
||||
readonly input: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
type ToolKind =
|
||||
| "official-proxy"
|
||||
| "enhanced"
|
||||
| "native-provider"
|
||||
| "artifact-provider"
|
||||
| "session";
|
||||
|
||||
/** Single source of truth for a public tool's name, description, and input. */
|
||||
export interface ToolContract<Name extends string = string> {
|
||||
readonly name: Name;
|
||||
readonly description: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly inputSchema: z.ZodObject;
|
||||
readonly outputSchema: z.ZodObject;
|
||||
readonly annotations: ToolAnnotations;
|
||||
readonly examples: readonly ToolExample[];
|
||||
}
|
||||
|
||||
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
|
||||
const examplesFor = (
|
||||
name: string,
|
||||
|
|
@ -106,12 +91,16 @@ const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
|
|||
name === "verify_unknown_resolution",
|
||||
destructiveHint:
|
||||
name === "export_evidence_bundle" ||
|
||||
name === "close_binary" ||
|
||||
name === "unset_bookmark" ||
|
||||
name === "set_address_name" ||
|
||||
name === "set_addresses_names" ||
|
||||
name === "set_comment" ||
|
||||
name === "set_inline_comment",
|
||||
idempotentHint: name !== "record_unknown" && name !== "update_unknown",
|
||||
idempotentHint:
|
||||
name !== "close_binary" &&
|
||||
name !== "record_unknown" &&
|
||||
name !== "update_unknown",
|
||||
openWorldHint: name === "capture_process_scenario",
|
||||
});
|
||||
|
||||
|
|
@ -293,12 +282,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
|||
),
|
||||
official(
|
||||
"search_procedures",
|
||||
"Search analyzed procedure names using literal matching by default or a structurally bounded regex. Returns a deterministic, offset-paginated page; continue at next_offset while has_more is true.",
|
||||
"Search analyzed procedure names using literal matching by default or regex opt-in. Regex rejects unsupported constructs, more than 10,000 static backtracking paths, candidates over 4,096 characters, and searches over 1,000,000 work units. Results are deterministic and offset-paginated.",
|
||||
z.object(searchInput),
|
||||
),
|
||||
official(
|
||||
"search_strings",
|
||||
"Search analyzed strings using literal matching by default or a structurally bounded regex. Returns a deterministic, offset-paginated page with explicit value truncation; follow matches with xrefs.",
|
||||
"Search analyzed strings using literal matching by default or regex opt-in. Regex rejects unsupported constructs, more than 10,000 static backtracking paths, candidates over 4,096 characters, and searches over 1,000,000 work units. Results are deterministic, offset-paginated, and explicitly truncated.",
|
||||
z.object(searchInput),
|
||||
),
|
||||
official(
|
||||
|
|
@ -404,13 +393,13 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
|||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"open_binary",
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. Providers start lazily: inventory_artifact does not launch Hopper; deep native operations may show Hopper UI.",
|
||||
z.object({ path: z.string().min(1) }),
|
||||
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. An optional provider-neutral snapshot is imported atomically and must match the binary digest, format, and architecture exactly; provider resources may still start before an MCP cache replay.",
|
||||
openBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
"close_binary",
|
||||
"Close the active target and every provider resource started for it. The operation is idempotent; call binary_session to verify the session is closed.",
|
||||
z.object({}),
|
||||
"Optionally write a provider-neutral analysis snapshot atomically, then close the active target and every provider resource started for it. Snapshot files require an operator-approved root and explicit overwrite; a failed save leaves the session open so cached analysis is not lost.",
|
||||
closeBinaryInputSchema,
|
||||
),
|
||||
session(
|
||||
"binary_session",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { z } from "zod";
|
||||
import { ANALYSIS_ERROR_TAGS } from "../domain/errors.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
processCaptureComparisonSchema,
|
||||
|
|
@ -180,9 +179,18 @@ const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
|
|||
);
|
||||
const analysisErrorProjectionSchema = z
|
||||
.object({
|
||||
tag: z.enum(ANALYSIS_ERROR_TAGS),
|
||||
category: z.enum([
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
"cancelled",
|
||||
"timeout",
|
||||
"unavailable",
|
||||
"execution_failure",
|
||||
]),
|
||||
message: z.string(),
|
||||
details: z.record(z.string(), z.union([z.string(), z.number(), z.null()])),
|
||||
})
|
||||
.strict();
|
||||
const graphNode = z.discriminatedUnion("status", [
|
||||
|
|
@ -377,7 +385,16 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
}),
|
||||
),
|
||||
close_binary: lifecycleResultOf(z.null()),
|
||||
close_binary: lifecycleResultOf(
|
||||
z.union([
|
||||
z.null(),
|
||||
z.object({
|
||||
path: z.string(),
|
||||
bytes: z.number().int().min(0),
|
||||
entries: z.number().int().min(0),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
binary_session: lifecycleResultOf(
|
||||
z.union([
|
||||
sessionProvider.extend({ open: z.literal(false) }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { BinaryTarget } from "./binaryTarget.js";
|
||||
import {
|
||||
evidenceBundleForTarget,
|
||||
evidenceBundleSchema,
|
||||
parseEvidenceBundle,
|
||||
} from "./evidenceBundle.js";
|
||||
import {
|
||||
evidenceLocationSchema,
|
||||
providerSchema,
|
||||
type Evidence,
|
||||
} from "./evidence.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "./jsonValue.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const architectureSchema = z.enum(["x86", "x86_64", "arm", "arm64"]);
|
||||
const formatSchema = z.enum([
|
||||
"hopper",
|
||||
"mach-o",
|
||||
"elf",
|
||||
"pe",
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"asar",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
"javascript",
|
||||
"source-map",
|
||||
]);
|
||||
const subjectSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
sha256: digestSchema,
|
||||
format: z.enum([
|
||||
...formatSchema.options,
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"mach-o-universal",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
]),
|
||||
architecture: architectureSchema.nullable(),
|
||||
});
|
||||
const targetSchema = z.object({
|
||||
sha256: digestSchema,
|
||||
format: formatSchema,
|
||||
architecture: architectureSchema.nullable(),
|
||||
loader_args: z.array(z.string()),
|
||||
});
|
||||
const entrySchema = z.object({
|
||||
query_id: z.string().regex(/^query_[a-f0-9]{64}$/u),
|
||||
operation: z.string().min(1),
|
||||
parameters: jsonObjectSchema,
|
||||
execution: z.object({
|
||||
result: jsonValueSchema,
|
||||
raw_result: jsonValueSchema.nullable(),
|
||||
provider: providerSchema,
|
||||
limitations: z.array(z.string()),
|
||||
locations: z.array(evidenceLocationSchema),
|
||||
subject: subjectSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Provider-neutral, deterministic cache of successful immutable analysis calls. */
|
||||
const analysisSnapshotSchema = z.object({
|
||||
snapshot_version: z.literal(1),
|
||||
target: targetSchema,
|
||||
entries: z.array(entrySchema).max(10_000),
|
||||
evidence_bundle: evidenceBundleSchema,
|
||||
});
|
||||
|
||||
export type AnalysisSnapshot = z.infer<typeof analysisSnapshotSchema>;
|
||||
export type AnalysisSnapshotEntry = z.infer<typeof entrySchema>;
|
||||
export type AnalysisSnapshotTarget = z.infer<typeof targetSchema>;
|
||||
|
||||
const canonicalJson = (value: unknown): string => {
|
||||
const json = jsonValueSchema.parse(value);
|
||||
const encoded = canonicalize(json);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("RFC 8785 canonicalization rejected snapshot data");
|
||||
return encoded;
|
||||
};
|
||||
|
||||
/** Project a binary into the path-free identity used for cache invalidation. */
|
||||
export const snapshotTarget = (
|
||||
target: BinaryTarget,
|
||||
): AnalysisSnapshotTarget => ({
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
loader_args: [...target.loaderArgs],
|
||||
});
|
||||
|
||||
/** Compare an open binary with the immutable identity in a snapshot. */
|
||||
export const snapshotMatchesTarget = (
|
||||
snapshot: AnalysisSnapshotTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
snapshot.sha256 === target.sha256 &&
|
||||
snapshot.format === target.format &&
|
||||
snapshot.architecture === (target.architecture ?? null) &&
|
||||
JSON.stringify(snapshot.loader_args) === JSON.stringify(target.loaderArgs);
|
||||
|
||||
/** Find exact persisted CLI evidence without starting an analysis provider. */
|
||||
export const snapshotEvidenceForQuery = (
|
||||
snapshot: AnalysisSnapshot,
|
||||
query: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly operation: string;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly provider: AnalysisExecution["provider"];
|
||||
},
|
||||
): Evidence | undefined => {
|
||||
const { target, operation, parameters, provider } = query;
|
||||
if (!snapshotMatchesTarget(snapshot.target, target)) return undefined;
|
||||
const encodedParameters = canonicalJson(parameters);
|
||||
return snapshot.evidence_bundle.records.find(
|
||||
(record) =>
|
||||
record.subject?.digest.sha256 === target.sha256 &&
|
||||
record.operation === operation &&
|
||||
record.provider.id === provider.id &&
|
||||
record.provider.name === provider.name &&
|
||||
record.provider.version === provider.version &&
|
||||
canonicalJson(record.parameters) === encodedParameters,
|
||||
);
|
||||
};
|
||||
|
||||
/** Compute the stable lookup key for one provider-specific analysis query. */
|
||||
export const analysisQueryId = (
|
||||
target: AnalysisSnapshotTarget,
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
provider: AnalysisExecution["provider"],
|
||||
): string =>
|
||||
`query_${createHash("sha256")
|
||||
.update(
|
||||
canonicalJson({
|
||||
target,
|
||||
operation,
|
||||
parameters,
|
||||
provider: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
version: provider.version,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.digest("hex")}`;
|
||||
|
||||
/** Create one serializable snapshot entry from a successful provider call. */
|
||||
export const createAnalysisSnapshotEntry = (
|
||||
target: AnalysisSnapshotTarget,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
): AnalysisSnapshotEntry => ({
|
||||
query_id: analysisQueryId(target, operation, parameters, execution.provider),
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(parameters),
|
||||
execution: {
|
||||
result: execution.result,
|
||||
raw_result: execution.rawResult,
|
||||
provider: execution.provider,
|
||||
limitations: [...execution.limitations],
|
||||
locations: [...execution.locations],
|
||||
subject:
|
||||
execution.subject === null
|
||||
? null
|
||||
: {
|
||||
...execution.subject,
|
||||
architecture: execution.subject.architecture ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse a snapshot and reject altered query IDs or non-canonical entry order. */
|
||||
export const parseAnalysisSnapshot = (input: unknown): AnalysisSnapshot => {
|
||||
const parsed = analysisSnapshotSchema.parse(input);
|
||||
parseEvidenceBundle(parsed.evidence_bundle);
|
||||
if (
|
||||
JSON.stringify(
|
||||
evidenceBundleForTarget(parsed.evidence_bundle, parsed.target.sha256),
|
||||
) !== JSON.stringify(parsed.evidence_bundle)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Analysis snapshot evidence contains records for another target",
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
for (const entry of parsed.entries) {
|
||||
const expected = analysisQueryId(
|
||||
parsed.target,
|
||||
entry.operation,
|
||||
entry.parameters,
|
||||
entry.execution.provider,
|
||||
);
|
||||
if (entry.query_id !== expected)
|
||||
throw new TypeError("Analysis snapshot query identifier does not match");
|
||||
if (ids.has(entry.query_id))
|
||||
throw new TypeError("Analysis snapshot contains duplicate queries");
|
||||
ids.add(entry.query_id);
|
||||
}
|
||||
const sorted = [...parsed.entries].sort((left, right) =>
|
||||
left.query_id.localeCompare(right.query_id),
|
||||
);
|
||||
if (JSON.stringify(parsed.entries) !== JSON.stringify(sorted))
|
||||
throw new TypeError("Analysis snapshot entries are not canonical");
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** Serialize a validated snapshot with byte-stable canonical entry ordering. */
|
||||
export const serializeAnalysisSnapshot = (snapshot: AnalysisSnapshot): string =>
|
||||
`${canonicalJson(parseAnalysisSnapshot(snapshot))}\n`;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
/** Stable tags exposed by safe analysis-error projections. */
|
||||
export const ANALYSIS_ERROR_TAGS = [
|
||||
const ANALYSIS_ERROR_TAGS = [
|
||||
"AnalysisProtocolError",
|
||||
"AnalysisInputError",
|
||||
"AnalysisOutputError",
|
||||
|
|
@ -34,6 +34,9 @@ export type AnalysisErrorTag = (typeof ANALYSIS_ERROR_TAGS)[number];
|
|||
/** Base class for expected analysis, provider, and session failures. */
|
||||
export abstract class AnalysisError extends Error {
|
||||
abstract readonly _tag: AnalysisErrorTag;
|
||||
readonly userMessage: string | undefined = undefined;
|
||||
readonly userCategory: "permission_required" | "cancelled" | undefined =
|
||||
undefined;
|
||||
}
|
||||
|
||||
/** Base class for failures produced specifically by the Hopper provider. */
|
||||
|
|
@ -323,9 +326,17 @@ export class BinaryTargetError extends AnalysisError {
|
|||
|
||||
export interface AnalysisErrorProjection
|
||||
extends Readonly<Record<string, JsonValue>> {
|
||||
readonly tag: AnalysisErrorTag;
|
||||
readonly category:
|
||||
| "invalid_input"
|
||||
| "permission_required"
|
||||
| "unsupported_provider"
|
||||
| "integrity_mismatch"
|
||||
| "truncated"
|
||||
| "cancelled"
|
||||
| "timeout"
|
||||
| "unavailable"
|
||||
| "execution_failure";
|
||||
readonly message: string;
|
||||
readonly details: Readonly<Record<string, string | number | boolean | null>>;
|
||||
}
|
||||
|
||||
/** Project expected failures into exhaustive, secret-safe caller fields. */
|
||||
|
|
@ -334,56 +345,185 @@ export const projectAnalysisError = (
|
|||
): AnalysisErrorProjection => {
|
||||
assertKnownTag(error._tag);
|
||||
return {
|
||||
tag: error._tag,
|
||||
message: error.message,
|
||||
details: safeDetails(error),
|
||||
category: errorCategory(error),
|
||||
message: userMessage(error),
|
||||
};
|
||||
};
|
||||
|
||||
const safeDetails = (
|
||||
const errorCategory = (
|
||||
error: AnalysisError,
|
||||
): Readonly<Record<string, string | number | boolean | null>> => {
|
||||
if (error instanceof AnalysisInputError)
|
||||
return { operation: error.operation };
|
||||
if (error instanceof AnalysisOutputError)
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
if (error instanceof AnalysisCapabilityUnavailableError)
|
||||
return {
|
||||
providerId: error.providerId,
|
||||
operation: error.operation,
|
||||
reason: error.reason,
|
||||
};
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return { operation: error.operation };
|
||||
if (error instanceof AnalysisTimeoutError)
|
||||
return { operation: error.operation, timeoutMs: error.timeoutMs };
|
||||
if (error instanceof ProviderSelectionError)
|
||||
return { operation: error.operation };
|
||||
if (error instanceof ProviderAdapterError)
|
||||
return { providerId: error.providerId, operation: error.operation };
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return error.userCategory ?? "execution_failure";
|
||||
if (
|
||||
error instanceof HopperRemoteError &&
|
||||
error.diagnosticType === "authorization"
|
||||
)
|
||||
return "permission_required";
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
reason: error.reason,
|
||||
...error.artifactDetails,
|
||||
};
|
||||
if (error instanceof EvidenceLimitError)
|
||||
return { limit: error.limit, maximum: error.maximum };
|
||||
if (error instanceof EvidenceFileError)
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
return artifactErrorCategory(error.reason);
|
||||
if (error instanceof EvidenceFileError && error.reason === "disabled")
|
||||
return "unavailable";
|
||||
if (error instanceof InvestigationWorkspaceError)
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
if (error instanceof UnknownRegistryError) return { reason: error.reason };
|
||||
if (error instanceof HopperTimeoutError)
|
||||
return { timeoutMs: error.timeoutMs };
|
||||
if (error instanceof HopperRemoteError)
|
||||
return {
|
||||
code: error.code,
|
||||
safeMessage: error.safeMessage,
|
||||
diagnosticType: error.diagnosticType,
|
||||
};
|
||||
if (error instanceof HopperProcessError) return { exitCode: error.exitCode };
|
||||
return {};
|
||||
return investigationWorkspaceCategory(error.reason);
|
||||
return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure";
|
||||
};
|
||||
|
||||
const investigationWorkspaceCategory = (
|
||||
reason: InvestigationWorkspaceError["reason"],
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (
|
||||
reason === "disabled" ||
|
||||
reason === "outside-root" ||
|
||||
reason === "not-file"
|
||||
)
|
||||
return "unavailable";
|
||||
if (reason === "too-large") return "truncated";
|
||||
if (reason === "invalid-json" || reason === "integrity")
|
||||
return "integrity_mismatch";
|
||||
return "execution_failure";
|
||||
};
|
||||
|
||||
const artifactErrorCategory = (
|
||||
reason: ArtifactOperationError["reason"],
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (reason === "integrity") return "integrity_mismatch";
|
||||
if (reason === "limit") return "truncated";
|
||||
if (reason === "cancelled") return "cancelled";
|
||||
if (reason === "unavailable") return "unavailable";
|
||||
return "execution_failure";
|
||||
};
|
||||
|
||||
const STATIC_ERROR_CATEGORIES: Readonly<
|
||||
Partial<Record<AnalysisErrorTag, AnalysisErrorProjection["category"]>>
|
||||
> = {
|
||||
AnalysisInputError: "invalid_input",
|
||||
AnalysisCapabilityUnavailableError: "unsupported_provider",
|
||||
ProviderSelectionError: "unsupported_provider",
|
||||
EvidenceIntegrityError: "integrity_mismatch",
|
||||
EvidenceLimitError: "truncated",
|
||||
AnalysisCancelledError: "cancelled",
|
||||
HopperCancelledError: "cancelled",
|
||||
AnalysisTimeoutError: "timeout",
|
||||
HopperTimeoutError: "timeout",
|
||||
NoBinaryOpenError: "unavailable",
|
||||
BinaryTargetError: "unavailable",
|
||||
};
|
||||
|
||||
const userMessage = (error: AnalysisError): string => {
|
||||
if (error instanceof AnalysisInputError)
|
||||
return "Analysis input is invalid. Check the arguments and try again.";
|
||||
if (UNREADABLE_OUTPUT_TAGS.has(error._tag))
|
||||
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";
|
||||
if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag))
|
||||
return "This analysis is unavailable for the current target. Choose another analysis or target.";
|
||||
if (CANCELLED_TAGS.has(error._tag))
|
||||
return "Analysis was cancelled. Start it again when ready.";
|
||||
if (TIMEOUT_TAGS.has(error._tag))
|
||||
return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.";
|
||||
if (ADAPTER_FAILURE_TAGS.has(error._tag))
|
||||
return "Analysis could not complete. Retry once; if it continues, run `rea doctor`.";
|
||||
if (START_FAILURE_TAGS.has(error._tag))
|
||||
return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.";
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return artifactMessage(error.reason);
|
||||
if (error instanceof EvidenceIntegrityError)
|
||||
return "Evidence is invalid or has changed. Recreate or re-import it, then try again.";
|
||||
if (error instanceof EvidenceLimitError)
|
||||
return "Evidence is too large for this session. Reduce the evidence set and try again.";
|
||||
if (error instanceof EvidenceFileError)
|
||||
return evidenceFileMessage(error.reason);
|
||||
if (error instanceof InvestigationWorkspaceError)
|
||||
return investigationWorkspaceMessage(error.reason);
|
||||
if (error instanceof UnknownRegistryError)
|
||||
return "Evidence state changed before the update completed. Refresh the current state and try again.";
|
||||
if (error instanceof ConfigurationError)
|
||||
return "REA configuration is invalid. Run `rea doctor` and fix the reported setting.";
|
||||
if (error instanceof NoBinaryOpenError) return error.message;
|
||||
if (error instanceof BinaryTargetError)
|
||||
return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.";
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return (
|
||||
error.userMessage ??
|
||||
"Process capture could not complete. Run `rea doctor`, then review capture policy and try again."
|
||||
);
|
||||
return "Analysis could not complete. Run `rea doctor`, then try again.";
|
||||
};
|
||||
|
||||
const UNREADABLE_OUTPUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisProtocolError",
|
||||
"AnalysisOutputError",
|
||||
"HopperProtocolError",
|
||||
]);
|
||||
const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
"ProviderSelectionError",
|
||||
]);
|
||||
const CANCELLED_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisCancelledError",
|
||||
"HopperCancelledError",
|
||||
]);
|
||||
const TIMEOUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisTimeoutError",
|
||||
"HopperTimeoutError",
|
||||
]);
|
||||
const ADAPTER_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"ProviderAdapterError",
|
||||
"HopperRemoteError",
|
||||
]);
|
||||
const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"HopperProcessError",
|
||||
"HopperStartError",
|
||||
]);
|
||||
|
||||
const artifactMessage = (reason: ArtifactOperationError["reason"]): string => {
|
||||
if (reason === "cancelled")
|
||||
return "Artifact operation was cancelled. Start it again when ready.";
|
||||
if (reason === "limit")
|
||||
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
|
||||
if (reason === "path")
|
||||
return "Artifact path is not allowed. Choose a path inside the artifact and try again.";
|
||||
if (reason === "unavailable")
|
||||
return "Artifact format is not available on this system. Choose another artifact or supported environment.";
|
||||
if (reason === "format" || reason === "integrity")
|
||||
return "Artifact is invalid or has changed. Get a fresh copy and try again.";
|
||||
return "Artifact could not be read or written. Check file access and try again.";
|
||||
};
|
||||
|
||||
const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => {
|
||||
if (reason === "disabled")
|
||||
return "Evidence file access is disabled. Enable an evidence directory or use inline evidence.";
|
||||
if (reason === "outside-root")
|
||||
return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory.";
|
||||
if (reason === "not-file")
|
||||
return "Evidence path does not point to a file. Choose an evidence file and try again.";
|
||||
if (reason === "too-large")
|
||||
return "Evidence file is too large. Reduce its size and try again.";
|
||||
if (reason === "exists")
|
||||
return "Evidence file already exists. Choose another path or allow overwrite.";
|
||||
if (reason === "invalid-json")
|
||||
return "Evidence file is not valid JSON. Repair or recreate the file and try again.";
|
||||
return "Evidence file could not be accessed. Check file permissions and try again.";
|
||||
};
|
||||
|
||||
const investigationWorkspaceMessage = (
|
||||
reason: InvestigationWorkspaceError["reason"],
|
||||
): string => {
|
||||
if (reason === "disabled")
|
||||
return "Investigation workspace access is disabled. Configure a workspace directory and try again.";
|
||||
if (reason === "outside-root")
|
||||
return "Investigation workspace is outside the allowed directory. Choose a configured workspace directory and try again.";
|
||||
if (reason === "not-file")
|
||||
return "Investigation workspace path is not a file. Choose a workspace file and try again.";
|
||||
if (reason === "too-large")
|
||||
return "Investigation workspace is too large. Reduce its size and try again.";
|
||||
if (reason === "invalid-json" || reason === "integrity")
|
||||
return "Investigation workspace is invalid or has changed. Recreate or repair it, then try again.";
|
||||
if (reason === "locked")
|
||||
return "Another investigation update is in progress. Try again when it finishes.";
|
||||
if (reason === "revision-conflict" || reason === "name-conflict")
|
||||
return "Investigation workspace state changed. Refresh the current state and try again.";
|
||||
return "Investigation workspace could not be accessed. Check file permissions and try again.";
|
||||
};
|
||||
|
||||
const KNOWN_ERROR_TAGS = {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import type { BinaryTarget } from "./binaryTarget.js";
|
|||
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
||||
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const providerSchema = z.object({
|
||||
/** Provider identity schema shared by evidence-bearing persistence formats. */
|
||||
export const providerSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
|
|
@ -40,7 +41,8 @@ const subjectSchema = z.object({
|
|||
local_path: z.string(),
|
||||
});
|
||||
/** Source location attached to an evidence observation. */
|
||||
const evidenceLocationSchema = z.discriminatedUnion("kind", [
|
||||
/** Source-location schema shared by evidence-bearing persistence formats. */
|
||||
export const evidenceLocationSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("address"), address: z.string().min(1) }),
|
||||
z.object({
|
||||
kind: z.literal("address-range"),
|
||||
|
|
|
|||
|
|
@ -104,6 +104,56 @@ export const createEvidenceBundle = (
|
|||
};
|
||||
};
|
||||
|
||||
/** Restrict a bundle to records and complete unknown histories for one artifact. */
|
||||
export const evidenceBundleForTarget = (
|
||||
bundle: EvidenceBundle,
|
||||
sha256: string,
|
||||
): EvidenceBundle => {
|
||||
const recordsById = new Map(
|
||||
bundle.records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
let unknowns = bundle.unknowns.filter(
|
||||
({ scope_digest: scopeDigest }) => scopeDigest === sha256,
|
||||
);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
const ids = new Set(unknowns.map(({ unknown_id: id }) => id));
|
||||
const retained = unknowns.filter(
|
||||
(unknown) =>
|
||||
unknown.relationships.every(({ unknown_id: id }) => ids.has(id)) &&
|
||||
referencedEvidenceIds(unknown).every((id) => {
|
||||
const record = recordsById.get(id);
|
||||
return (
|
||||
record?.subject?.digest.sha256 === sha256 ||
|
||||
(unknown.mutation_evidence_ids.includes(id) &&
|
||||
record?.subject === null &&
|
||||
record.predicate_type === "rea.residual-unknown-mutation/v1")
|
||||
);
|
||||
}),
|
||||
);
|
||||
changed = retained.length !== unknowns.length;
|
||||
unknowns = retained;
|
||||
}
|
||||
const mutationIds = new Set(
|
||||
unknowns.flatMap(({ mutation_evidence_ids: ids }) => ids),
|
||||
);
|
||||
return createEvidenceBundle(
|
||||
bundle.records.filter(
|
||||
(record) =>
|
||||
record.subject?.digest.sha256 === sha256 ||
|
||||
mutationIds.has(record.evidence_id),
|
||||
),
|
||||
unknowns,
|
||||
);
|
||||
};
|
||||
|
||||
const referencedEvidenceIds = (unknown: ResidualUnknown): readonly string[] => [
|
||||
...unknown.supporting_evidence_ids,
|
||||
...unknown.contradicting_evidence_ids,
|
||||
...unknown.mutation_evidence_ids,
|
||||
...(unknown.resolution?.evidence_ids ?? []),
|
||||
];
|
||||
|
||||
/** Parse records, verify semantic IDs, and reject inconsistent manifests. */
|
||||
export const parseEvidenceBundle = (input: unknown): EvidenceBundle => {
|
||||
const parsed = evidenceBundleSchema.parse(input);
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@ import { z } from "zod";
|
|||
/** JSON-safe value shared by domain, application, and adapter boundaries. */
|
||||
export const jsonValueSchema = z.json();
|
||||
|
||||
/** JSON object boundary for caller-visible parameter maps. */
|
||||
export const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
|
||||
|
||||
export type JsonValue = z.infer<typeof jsonValueSchema>;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
|
||||
const coverage = <Schema extends z.ZodType>(item: Schema) =>
|
||||
|
|
@ -112,7 +113,7 @@ export const inspectSignatureSchema = z.object({
|
|||
hash_algorithms: z.array(z.string()),
|
||||
authorities: z.array(z.string()),
|
||||
designated_requirement: z.string().nullable(),
|
||||
entitlements: z.unknown().nullable(),
|
||||
entitlements: jsonValueSchema.nullable(),
|
||||
timestamp: z.string().nullable(),
|
||||
hardened_runtime: z.boolean().nullable(),
|
||||
provenance: z.array(nativeCommandInvocationSchema),
|
||||
|
|
@ -121,7 +122,7 @@ export const inspectSignatureSchema = z.object({
|
|||
|
||||
export const inspectPlistSchema = z.object({
|
||||
format: z.enum(["xml", "binary", "json", "unknown"]),
|
||||
value: z.unknown(),
|
||||
value: jsonValueSchema,
|
||||
bundle: z.object({
|
||||
identifier: z.string().nullable(),
|
||||
executable: z.string().nullable(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
import {
|
||||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
|
|
@ -200,10 +201,10 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
|||
pty_backend: z.literal("node-pty"),
|
||||
started_at: z.iso.datetime(),
|
||||
completed_at: z.iso.datetime(),
|
||||
scenario: z.record(z.string(), z.unknown()),
|
||||
comparison_contract: z.record(z.string(), z.unknown()),
|
||||
shim_plan: z.array(z.unknown()),
|
||||
replay_plan: z.record(z.string(), z.unknown()),
|
||||
scenario: z.record(z.string(), jsonValueSchema),
|
||||
comparison_contract: z.record(z.string(), jsonValueSchema),
|
||||
shim_plan: z.array(jsonValueSchema),
|
||||
replay_plan: z.record(z.string(), jsonValueSchema),
|
||||
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||
|
||||
import type { ProcessCapture } from "./processCapture.js";
|
||||
import { validateProcessCapture } from "./processCapture.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
/** Comparison classification that never equates incomplete evidence. */
|
||||
export const comparisonStatusSchema = z.enum([
|
||||
|
|
@ -42,8 +43,8 @@ export const processCaptureComparisonSchema = z
|
|||
index: z.number().int().nonnegative(),
|
||||
left_at_ms: z.number().int().nonnegative().nullable(),
|
||||
right_at_ms: z.number().int().nonnegative().nullable(),
|
||||
left: z.unknown().nullable(),
|
||||
right: z.unknown().nullable(),
|
||||
left: jsonValueSchema.nullable(),
|
||||
right: jsonValueSchema.nullable(),
|
||||
}),
|
||||
]),
|
||||
limitations: z.array(z.string()),
|
||||
|
|
@ -165,8 +166,8 @@ const firstCollectionDivergence = (
|
|||
index,
|
||||
left_at_ms: eventTime(leftValue),
|
||||
right_at_ms: eventTime(rightValue),
|
||||
left: leftValue,
|
||||
right: rightValue,
|
||||
left: jsonValueSchema.parse(leftValue),
|
||||
right: jsonValueSchema.parse(rightValue),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -382,7 +382,16 @@ export interface ProcessExecutionPolicy {
|
|||
/** Explicit authorization result; denial reasons are safe to show callers. */
|
||||
export type ProcessPolicyDecision =
|
||||
| { readonly allowed: true }
|
||||
| { readonly allowed: false; readonly reason: string };
|
||||
| {
|
||||
readonly allowed: false;
|
||||
readonly reason:
|
||||
| "process capture is disabled"
|
||||
| "host network access is not approved by operator policy"
|
||||
| "executable is outside approved roots"
|
||||
| "working directory is outside approved roots"
|
||||
| "scenario requests an environment variable not allowed by policy"
|
||||
| "filesystem root is outside approved roots";
|
||||
};
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
resolve(candidate) === resolve(root) ||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
|
|||
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
|
||||
`REA_TOKEN = ${JSON.stringify(session.token)}`,
|
||||
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
|
||||
`REA_TARGET_PATH = ${JSON.stringify(this.options.targetPath)}`,
|
||||
`exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { silentLogger, type Logger } from "../logger.js";
|
|||
import { parseResponseLine, responseResult } from "./protocol.js";
|
||||
|
||||
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
||||
const SHUTDOWN_TIMEOUT_MS = 30_000;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
|
||||
/** Dependencies, deadlines, and redacted diagnostics for one bridge client. */
|
||||
|
|
@ -164,9 +165,16 @@ export class HopperClient {
|
|||
try {
|
||||
const socket = this.#socket;
|
||||
if (socket !== undefined && !socket.destroyed) {
|
||||
await this.#request("shutdown", {}, { timeoutMs: 500 }).catch(() =>
|
||||
err(new HopperProcessError(null)),
|
||||
);
|
||||
const shutdown = await this.#request(
|
||||
"shutdown",
|
||||
{},
|
||||
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
|
||||
).catch(() => err(new HopperProcessError(null)));
|
||||
if (!shutdown.ok || !isShutdownAcknowledgement(shutdown.value))
|
||||
this.#logger.warn(
|
||||
{ status: shutdown.ok ? "invalid-acknowledgement" : "failed" },
|
||||
"Hopper document shutdown was not confirmed",
|
||||
);
|
||||
}
|
||||
this.#failAll(new HopperProcessError(null));
|
||||
socket?.destroy();
|
||||
|
|
@ -395,6 +403,14 @@ const connectOnce = async (
|
|||
});
|
||||
});
|
||||
|
||||
const isShutdownAcknowledgement = (value: JsonValue): boolean =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
value.shutdown === true &&
|
||||
value.analysis_stopped === true &&
|
||||
value.document_closed === true;
|
||||
|
||||
const parseServerInfo = (
|
||||
value: JsonValue,
|
||||
expectedRunId: string,
|
||||
|
|
|
|||
93
src/main.ts
93
src/main.ts
|
|
@ -12,16 +12,52 @@ import { parseConfig } from "./config.js";
|
|||
import { createBinarySession } from "./application/runtime.js";
|
||||
import { createServer } from "./server/createServer.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { projectAnalysisError } from "./domain/errors.js";
|
||||
|
||||
const MCP_CONNECTION_LOST =
|
||||
"REA lost its MCP client connection. Restart REA from your MCP client.";
|
||||
const MCP_CONNECTION_START_FAILED =
|
||||
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
|
||||
const MCP_SHUTDOWN_FAILED =
|
||||
"REA could not close cleanly. End the REA process before starting it again.";
|
||||
const SERVER_START_FAILED =
|
||||
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
|
||||
|
||||
/** Process boundaries owned by the MCP executable adapter. */
|
||||
interface RuntimeDependencies {
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly serve: typeof serveStdio;
|
||||
readonly writeStderr: (text: string) => void;
|
||||
readonly setExitCode: (code: number) => void;
|
||||
readonly registerShutdown: (handler: () => void) => void;
|
||||
}
|
||||
|
||||
const runtimeDependencies = (): RuntimeDependencies => ({
|
||||
env: process.env,
|
||||
serve: serveStdio,
|
||||
writeStderr: (text) => process.stderr.write(text),
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
registerShutdown: (handler) => {
|
||||
process.once("SIGINT", handler);
|
||||
process.once("SIGTERM", handler);
|
||||
process.stdin.once("end", handler);
|
||||
process.stdin.once("close", handler);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
|
||||
* The adapter owns its BinarySession for the process lifetime; EOF and process
|
||||
* signals close bridge resources without assuming ownership of the Hopper app.
|
||||
*/
|
||||
export const run = async (): Promise<number> => {
|
||||
const config = parseConfig(process.env);
|
||||
export const run = async (
|
||||
dependencies: RuntimeDependencies = runtimeDependencies(),
|
||||
): Promise<number> => {
|
||||
const config = parseConfig(dependencies.env);
|
||||
if (!config.ok) {
|
||||
process.stderr.write(`${config.error._tag}: ${config.error.message}\n`);
|
||||
dependencies.writeStderr(`${projectAnalysisError(config.error).message}\n`);
|
||||
return 1;
|
||||
}
|
||||
const logger = createLogger("mcp", config.value.logLevel);
|
||||
|
|
@ -38,30 +74,33 @@ export const run = async (): Promise<number> => {
|
|||
{ errorTag: opened.error._tag },
|
||||
"Initial target failed to open",
|
||||
);
|
||||
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
|
||||
dependencies.writeStderr(
|
||||
`${projectAnalysisError(opened.error).message}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let handle: StdioServerHandle;
|
||||
try {
|
||||
handle = serveStdio(
|
||||
handle = dependencies.serve(
|
||||
() =>
|
||||
createServer(session, session, {
|
||||
logger,
|
||||
processPolicy: config.value.processExecutionPolicy,
|
||||
evidenceFilePolicy: config.value.evidenceFilePolicy,
|
||||
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
|
||||
}),
|
||||
{
|
||||
onerror: () => {
|
||||
serverLogger.error("MCP stdio transport error");
|
||||
process.stderr.write("MCP stdio transport error\n");
|
||||
serverLogger.error(MCP_CONNECTION_LOST);
|
||||
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
await session.close();
|
||||
serverLogger.error("Failed to start MCP stdio transport");
|
||||
process.stderr.write("Failed to start MCP stdio transport\n");
|
||||
serverLogger.error(MCP_CONNECTION_START_FAILED);
|
||||
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -75,30 +114,36 @@ export const run = async (): Promise<number> => {
|
|||
};
|
||||
const requestShutdown = (): void => {
|
||||
shutdown().catch(() => {
|
||||
process.exitCode = 1;
|
||||
serverLogger.error("Failed to shut down cleanly");
|
||||
process.stderr.write("Failed to shut down cleanly\n");
|
||||
dependencies.setExitCode(1);
|
||||
serverLogger.error(MCP_SHUTDOWN_FAILED);
|
||||
dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`);
|
||||
});
|
||||
};
|
||||
|
||||
process.once("SIGINT", requestShutdown);
|
||||
process.once("SIGTERM", requestShutdown);
|
||||
process.stdin.once("end", requestShutdown);
|
||||
process.stdin.once("close", requestShutdown);
|
||||
dependencies.registerShutdown(requestShutdown);
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** Run the executable boundary without exposing an unexpected startup cause. */
|
||||
export const runEntrypoint = async (
|
||||
start: () => Promise<number> = () => run(),
|
||||
writeStderr: (text: string) => void = (text) => process.stderr.write(text),
|
||||
setExitCode: (code: number) => void = (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setExitCode(await start());
|
||||
} catch {
|
||||
writeStderr(`${SERVER_START_FAILED}\n`);
|
||||
setExitCode(1);
|
||||
}
|
||||
};
|
||||
|
||||
const entryPath = process.argv[1];
|
||||
if (
|
||||
entryPath !== undefined &&
|
||||
pathToFileURL(realpathSync(entryPath)).href === import.meta.url
|
||||
) {
|
||||
run()
|
||||
.then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
})
|
||||
.catch(() => {
|
||||
process.stderr.write("Unexpected server startup failure\n");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
void runEntrypoint();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface CreateServerOptions {
|
|||
readonly logger?: Logger;
|
||||
readonly processPolicy?: ProcessExecutionPolicy;
|
||||
readonly evidenceFilePolicy?: EvidenceFilePolicy;
|
||||
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { Logger } from "../logger.js";
|
|||
import { logToolExecution } from "./toolLogging.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import { UnknownRegistryError } from "../domain/errors.js";
|
||||
|
||||
|
|
@ -69,15 +69,9 @@ const registerEnhancedTool = (
|
|||
annotations: contract.annotations,
|
||||
},
|
||||
async (input, context) => {
|
||||
const parsedInput = jsonValueSchema.parse(
|
||||
const parsedInput = jsonObjectSchema.parse(
|
||||
enhancedInputSchemas[name].parse(input),
|
||||
);
|
||||
if (
|
||||
typeof parsedInput !== "object" ||
|
||||
parsedInput === null ||
|
||||
Array.isArray(parsedInput)
|
||||
)
|
||||
throw new Error("Validated enhanced input was not a JSON object");
|
||||
const parameters: Record<string, JsonValue> = { ...parsedInput };
|
||||
const result = await logToolExecution(registration.logger, name, () =>
|
||||
services.execute(name, input, context.mcpReq.signal),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { BinarySessionPort } from "../application/BinarySession.js";
|
|||
import type { ToolContract } from "../contracts/toolContracts.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
|
@ -32,7 +32,9 @@ export const registerEvidenceTools = (
|
|||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) => {
|
||||
const parameters = jsonObject(contract.inputSchema.parse(input));
|
||||
const parameters = jsonObjectSchema.parse(
|
||||
contract.inputSchema.parse(input),
|
||||
);
|
||||
const execution = await logToolExecution(
|
||||
options.logger,
|
||||
contract.name,
|
||||
|
|
@ -62,10 +64,3 @@ export const registerEvidenceTools = (
|
|||
);
|
||||
}
|
||||
};
|
||||
|
||||
const jsonObject = (input: unknown): Readonly<Record<string, JsonValue>> => {
|
||||
const parsed = jsonValueSchema.parse(input);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
throw new TypeError("Provider tool input was not an object");
|
||||
return parsed;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ const functionUnknownInput = ({
|
|||
{
|
||||
operation: "analyze_function",
|
||||
rationale:
|
||||
"Capture complete dossiers with equal providers and analysis limits.",
|
||||
"Capture complete dossiers for both functions under the same target context and analysis limits.",
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ const registerChangedBehavior = (
|
|||
result.behavior_status === "unknown" ||
|
||||
result.behavior_status === "truncated",
|
||||
{
|
||||
question: `Changed behavior result ${evidence.evidence_id} remains ${result.behavior_status}`,
|
||||
question:
|
||||
"Did both versions behave the same under a complete controlled replay?",
|
||||
domain: "changed-behavior",
|
||||
requiredAuthority: "controlled-replay",
|
||||
requiredConfidence: "observed",
|
||||
|
|
@ -184,7 +185,8 @@ const registerCallPath = (
|
|||
parsed.unknown_registry_approved,
|
||||
result.status === "unknown" || result.status === "truncated",
|
||||
{
|
||||
question: `Call path ${parsed.start.address} → ${parsed.goal.address} is ${result.status}`,
|
||||
question:
|
||||
"Can the requested call path be established from complete analysis?",
|
||||
domain: "call-path",
|
||||
requiredAuthority: "shipped-artifact",
|
||||
requiredConfidence: "derived",
|
||||
|
|
@ -239,7 +241,8 @@ const registerStaticRuntime = (
|
|||
parsed.unknown_registry_approved,
|
||||
result.status === "unknown" || result.status === "truncated",
|
||||
{
|
||||
question: `Static/runtime correlation result ${evidence.evidence_id} is ${result.status}`,
|
||||
question:
|
||||
"Does runtime behavior match the available static analysis?",
|
||||
domain: "static-runtime-correlation",
|
||||
requiredAuthority: null,
|
||||
requiredConfidence: "derived",
|
||||
|
|
@ -318,7 +321,7 @@ const registerReconstruction = (
|
|||
parsed.unknown_registry_approved,
|
||||
result.status === "unknown",
|
||||
{
|
||||
question: `Reconstruction ${result.specification_sha256} has unknown claims`,
|
||||
question: "Does the reconstruction satisfy every declared claim?",
|
||||
domain: "reconstruction-verification",
|
||||
requiredAuthority: null,
|
||||
requiredConfidence: "derived",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import type { McpServer } from "@modelcontextprotocol/server";
|
|||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../domain/jsonValue.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
|
|
@ -92,9 +96,10 @@ const registerOfficialTool = (
|
|||
) {
|
||||
const unknown = registration.recordUnknown({
|
||||
approved: true,
|
||||
question: `${contract.name} is unavailable: ${result.error.reason}`,
|
||||
question:
|
||||
"The requested analysis is unavailable for the current target.",
|
||||
severity: "medium",
|
||||
domain: "provider-capability",
|
||||
domain: "analysis-capability",
|
||||
supporting_evidence_ids: [],
|
||||
contradicting_evidence_ids: [],
|
||||
required_authority: "shipped-artifact",
|
||||
|
|
@ -104,7 +109,7 @@ const registerOfficialTool = (
|
|||
{
|
||||
operation: contract.name,
|
||||
rationale:
|
||||
"Use a provider that declares this capability available.",
|
||||
"Choose another analysis or target that can answer this question.",
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
|
|
@ -127,21 +132,12 @@ const projectOfficialArguments = (
|
|||
contract: (typeof OFFICIAL_TOOL_CONTRACTS)[number],
|
||||
input: unknown,
|
||||
): Readonly<Record<string, JsonValue>> => {
|
||||
const parsed = jsonValueSchema.safeParse(input);
|
||||
if (
|
||||
!parsed.success ||
|
||||
typeof parsed.data !== "object" ||
|
||||
parsed.data === null ||
|
||||
Array.isArray(parsed.data)
|
||||
) {
|
||||
// The SDK validates this with the same schema before invoking the callback.
|
||||
throw new Error("Validated MCP tool input was not a JSON object");
|
||||
}
|
||||
const parsed = jsonObjectSchema.parse(contract.inputSchema.parse(input));
|
||||
|
||||
const projected: Record<string, JsonValue> = {};
|
||||
for (const key of Object.keys(contract.inputSchema.shape)) {
|
||||
if (key === "unknown_registry_approved") continue;
|
||||
projected[key] = parsed.data[key] ?? null;
|
||||
projected[key] = jsonValueSchema.parse(parsed[key] ?? null);
|
||||
}
|
||||
return projected;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const validateCaptureSources = (
|
|||
evidence.confidence !== "observed" ||
|
||||
evidence.authority !== "controlled-replay" ||
|
||||
evidenceCapture === undefined ||
|
||||
canonicalJson(evidenceCapture) !== canonicalJson(capture)
|
||||
canonicalize(evidenceCapture) !== canonicalize(capture)
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
|
|
@ -130,13 +130,6 @@ const validateCaptureSources = (
|
|||
return { ok: true as const, value: null };
|
||||
};
|
||||
|
||||
const canonicalJson = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("Process capture could not be canonicalized");
|
||||
return encoded;
|
||||
};
|
||||
|
||||
const comparisonUnknownInput = (
|
||||
parsed: {
|
||||
readonly left_evidence_id: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Register the read-only provider and target status operation. */
|
||||
export const registerSessionStatusTool = (
|
||||
server: McpServer,
|
||||
session: BinarySessionPort,
|
||||
contract: (typeof SESSION_TOOL_CONTRACTS)[2],
|
||||
): void => {
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
{
|
||||
description: contract.description,
|
||||
inputSchema: contract.inputSchema,
|
||||
outputSchema: contract.outputSchema,
|
||||
annotations: contract.annotations,
|
||||
},
|
||||
() => toCallToolResult({ ok: true, value: session.status() }, contract),
|
||||
);
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
import { processScenarioSchema } from "../domain/processCapture.js";
|
||||
import { captureProcessScenario } from "../application/ProcessHarness.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import {
|
||||
recordUnknownInputSchema,
|
||||
updateUnknownInputSchema,
|
||||
|
|
@ -24,6 +25,10 @@ import {
|
|||
readEvidenceBundle,
|
||||
writeEvidenceBundle,
|
||||
} from "../application/EvidenceBundleFiles.js";
|
||||
import {
|
||||
readAnalysisSnapshot,
|
||||
writeAnalysisSnapshot,
|
||||
} from "../application/AnalysisSnapshotFiles.js";
|
||||
import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
DENY_EVIDENCE_FILE_POLICY,
|
||||
|
|
@ -35,6 +40,11 @@ import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool
|
|||
import { registerFunctionComparisonTool } from "./registerFunctionComparisonTool.js";
|
||||
import { registerBundleComparisonTool } from "./registerBundleComparisonTool.js";
|
||||
import { registerInvestigationTools } from "./registerInvestigationTools.js";
|
||||
import {
|
||||
closeBinaryInputSchema,
|
||||
openBinaryInputSchema,
|
||||
} from "../contracts/sessionLifecycleInputs.js";
|
||||
import { registerSessionStatusTool } from "./registerSessionStatusTool.js";
|
||||
|
||||
const recordProcessResidualUnknowns = (
|
||||
session: BinarySessionPort,
|
||||
|
|
@ -46,7 +56,7 @@ const recordProcessResidualUnknowns = (
|
|||
for (const residual of residuals) {
|
||||
const unknown = session.recordUnknown({
|
||||
approved: true,
|
||||
question: `${residual.scope}: ${residual.reason}`,
|
||||
question: `Was ${residual.scope} behavior fully observed during capture?`,
|
||||
severity: "medium",
|
||||
domain: `process-${residual.scope}`,
|
||||
supporting_evidence_ids: [evidence.evidence_id],
|
||||
|
|
@ -305,16 +315,25 @@ const registerUnknownTools = ({
|
|||
);
|
||||
};
|
||||
|
||||
const registerLifecycleTools = (
|
||||
server: McpServer,
|
||||
session: BinarySessionPort,
|
||||
logger: Logger,
|
||||
contracts: readonly [
|
||||
interface LifecycleToolRegistration {
|
||||
readonly server: McpServer;
|
||||
readonly session: BinarySessionPort;
|
||||
readonly logger: Logger;
|
||||
readonly contracts: readonly [
|
||||
(typeof SESSION_TOOL_CONTRACTS)[0],
|
||||
(typeof SESSION_TOOL_CONTRACTS)[1],
|
||||
(typeof SESSION_TOOL_CONTRACTS)[2],
|
||||
],
|
||||
): void => {
|
||||
];
|
||||
readonly snapshotFilePolicy: EvidenceFilePolicy;
|
||||
}
|
||||
|
||||
const registerLifecycleTools = ({
|
||||
server,
|
||||
session,
|
||||
logger,
|
||||
contracts,
|
||||
snapshotFilePolicy,
|
||||
}: LifecycleToolRegistration): void => {
|
||||
const [openContract, closeContract, statusContract] = contracts;
|
||||
server.registerTool(
|
||||
openContract.name,
|
||||
|
|
@ -325,9 +344,21 @@ const registerLifecycleTools = (
|
|||
annotations: openContract.annotations,
|
||||
},
|
||||
async (input, context) => {
|
||||
const parsed = z.object({ path: z.string().min(1) }).parse(input);
|
||||
const parsed = openBinaryInputSchema.parse(input);
|
||||
let snapshot: AnalysisSnapshot | undefined;
|
||||
if (parsed.snapshot_path !== undefined) {
|
||||
const loaded = await readAnalysisSnapshot(
|
||||
parsed.snapshot_path,
|
||||
snapshotFilePolicy,
|
||||
);
|
||||
if (!loaded.ok) return toCallToolResult(loaded, openContract);
|
||||
snapshot = loaded.value;
|
||||
}
|
||||
const opened = await logToolExecution(logger, openContract.name, () =>
|
||||
session.open(parsed.path, { signal: context.mcpReq.signal }),
|
||||
session.open(parsed.path, {
|
||||
signal: context.mcpReq.signal,
|
||||
...(snapshot === undefined ? {} : { snapshot }),
|
||||
}),
|
||||
);
|
||||
return opened.ok
|
||||
? toCallToolResult(
|
||||
|
|
@ -355,31 +386,48 @@ const registerLifecycleTools = (
|
|||
outputSchema: closeContract.outputSchema,
|
||||
annotations: closeContract.annotations,
|
||||
},
|
||||
async () =>
|
||||
toCallToolResult(
|
||||
async (input) => {
|
||||
const parsed = closeBinaryInputSchema.parse(input);
|
||||
if (parsed.snapshot_path !== undefined) {
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) return toCallToolResult(snapshot, closeContract);
|
||||
const written = await writeAnalysisSnapshot(
|
||||
snapshot.value,
|
||||
parsed.snapshot_path,
|
||||
parsed.overwrite,
|
||||
snapshotFilePolicy,
|
||||
);
|
||||
if (!written.ok) return toCallToolResult(written, closeContract);
|
||||
const closed = await logToolExecution(logger, closeContract.name, () =>
|
||||
session.close(),
|
||||
);
|
||||
return closed.ok
|
||||
? toCallToolResult(
|
||||
ok({
|
||||
path: written.value.path,
|
||||
bytes: written.value.bytes,
|
||||
entries: snapshot.value.entries.length,
|
||||
}),
|
||||
closeContract,
|
||||
)
|
||||
: toCallToolResult(closed, closeContract);
|
||||
}
|
||||
return toCallToolResult(
|
||||
await logToolExecution(logger, closeContract.name, () =>
|
||||
session.close(),
|
||||
),
|
||||
closeContract,
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
statusContract.name,
|
||||
{
|
||||
description: statusContract.description,
|
||||
inputSchema: statusContract.inputSchema,
|
||||
outputSchema: statusContract.outputSchema,
|
||||
annotations: statusContract.annotations,
|
||||
);
|
||||
},
|
||||
() =>
|
||||
toCallToolResult({ ok: true, value: session.status() }, statusContract),
|
||||
);
|
||||
registerSessionStatusTool(server, session, statusContract);
|
||||
};
|
||||
|
||||
/** Register MCP-only target lifecycle operations on a long-lived session. */
|
||||
export interface SessionToolOptions {
|
||||
readonly processPolicy?: ProcessExecutionPolicy;
|
||||
readonly evidenceFilePolicy?: EvidenceFilePolicy;
|
||||
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
|
||||
}
|
||||
|
||||
export const registerSessionTools = (
|
||||
|
|
@ -391,6 +439,8 @@ export const registerSessionTools = (
|
|||
const processPolicy = options.processPolicy ?? DENY_PROCESS_POLICY;
|
||||
const evidenceFilePolicy =
|
||||
options.evidenceFilePolicy ?? DENY_EVIDENCE_FILE_POLICY;
|
||||
const analysisSnapshotFilePolicy =
|
||||
options.analysisSnapshotFilePolicy ?? DENY_EVIDENCE_FILE_POLICY;
|
||||
const [
|
||||
openContract,
|
||||
closeContract,
|
||||
|
|
@ -411,11 +461,13 @@ export const registerSessionTools = (
|
|||
updateUnknownContract,
|
||||
verifyUnknownContract,
|
||||
] = SESSION_TOOL_CONTRACTS;
|
||||
registerLifecycleTools(server, session, logger, [
|
||||
openContract,
|
||||
closeContract,
|
||||
statusContract,
|
||||
]);
|
||||
registerLifecycleTools({
|
||||
server,
|
||||
session,
|
||||
logger,
|
||||
contracts: [openContract, closeContract, statusContract],
|
||||
snapshotFilePolicy: analysisSnapshotFilePolicy,
|
||||
});
|
||||
registerEvidenceTools({
|
||||
server,
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const resolveSessionEvidence = (
|
|||
for (const item of supplied) {
|
||||
const parsed = parseEvidence(item);
|
||||
const owned = session.evidenceById(parsed.evidence_id);
|
||||
if (owned === undefined || canonicalJson(owned) !== canonicalJson(parsed))
|
||||
if (owned === undefined || canonicalize(owned) !== canonicalize(parsed))
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Comparison input does not match its session-owned Evidence",
|
||||
|
|
@ -34,12 +34,5 @@ export const isSessionEvidence = (
|
|||
input: Evidence,
|
||||
): boolean => {
|
||||
const owned = session.evidenceById(input.evidence_id);
|
||||
return owned !== undefined && canonicalJson(owned) === canonicalJson(input);
|
||||
};
|
||||
|
||||
const canonicalJson = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("Evidence could not be canonicalized");
|
||||
return encoded;
|
||||
return owned !== undefined && canonicalize(owned) === canonicalize(input);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
|
||||
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
AnalysisOutputError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ToolContract } from "../contracts/toolContracts.js";
|
||||
|
|
@ -22,9 +26,10 @@ const errorResult = (error: AnalysisError): CallToolResult => {
|
|||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${projected.tag}: ${projected.message}`,
|
||||
text: projected.message,
|
||||
},
|
||||
],
|
||||
structuredContent: { error: projected },
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
|
|
@ -40,9 +45,17 @@ const successResult = (
|
|||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "AnalysisOutputError: Analysis output does not match the tool contract",
|
||||
text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: projectAnalysisError(
|
||||
new AnalysisOutputError(
|
||||
contract.name,
|
||||
"output does not match the tool contract",
|
||||
),
|
||||
),
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
readAnalysisSnapshot,
|
||||
writeAnalysisSnapshot,
|
||||
} from "../src/application/AnalysisSnapshotFiles.js";
|
||||
import {
|
||||
createAnalysisSnapshotEntry,
|
||||
parseAnalysisSnapshot,
|
||||
snapshotEvidenceForQuery,
|
||||
type AnalysisSnapshot,
|
||||
} from "../src/domain/analysisSnapshot.js";
|
||||
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { createAnalysisExecution } from "../src/application/AnalysisProvider.js";
|
||||
|
||||
let directory: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe("analysis snapshots", () => {
|
||||
it("finds exact CLI evidence for provider-free replay", () => {
|
||||
const target = {
|
||||
path: "/tmp/app",
|
||||
sha256: "b".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
loaderArgs: [],
|
||||
} as const;
|
||||
const evidence = createEvidence(
|
||||
target,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "analyze_function",
|
||||
parameters: { procedure: "main" },
|
||||
result: { summary: "cached" },
|
||||
},
|
||||
);
|
||||
const snapshot: AnalysisSnapshot = {
|
||||
snapshot_version: 1,
|
||||
target: {
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
architecture: target.architecture,
|
||||
loader_args: [],
|
||||
},
|
||||
entries: [],
|
||||
evidence_bundle: createEvidenceBundle([evidence]),
|
||||
};
|
||||
expect(
|
||||
snapshotEvidenceForQuery(snapshot, {
|
||||
target,
|
||||
operation: "analyze_function",
|
||||
parameters: {
|
||||
procedure: "main",
|
||||
},
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
}),
|
||||
).toEqual(evidence);
|
||||
expect(
|
||||
snapshotEvidenceForQuery(snapshot, {
|
||||
target,
|
||||
operation: "analyze_function",
|
||||
parameters: {
|
||||
procedure: "other",
|
||||
},
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes canonical private JSON and rejects a changed query identity", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-analysis-snapshot-"));
|
||||
const path = join(directory, "analysis.json");
|
||||
const target: AnalysisSnapshot["target"] = {
|
||||
sha256: "a".repeat(64),
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
loader_args: [],
|
||||
};
|
||||
const entry = createAnalysisSnapshotEntry(
|
||||
target,
|
||||
"address_name",
|
||||
{ address: "0x1000" },
|
||||
createAnalysisExecution("main", {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
}),
|
||||
);
|
||||
const snapshot: AnalysisSnapshot = {
|
||||
snapshot_version: 1,
|
||||
target,
|
||||
entries: [entry],
|
||||
evidence_bundle: createEvidenceBundle([]),
|
||||
};
|
||||
const policy = {
|
||||
roots: [directory],
|
||||
maxBytes: 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024,
|
||||
maxNodes: 10_000,
|
||||
};
|
||||
const written = await writeAnalysisSnapshot(snapshot, path, false, policy);
|
||||
expect(written.ok).toBe(true);
|
||||
expect((await readFile(path, "utf8")).endsWith("\n")).toBe(true);
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600);
|
||||
expect((await readAnalysisSnapshot(path, policy)).ok).toBe(true);
|
||||
|
||||
const altered = JSON.parse(await readFile(path, "utf8"));
|
||||
altered.entries[0].parameters.address = "0x2000";
|
||||
await writeFile(path, JSON.stringify(altered));
|
||||
const loaded = await readAnalysisSnapshot(path, policy);
|
||||
expect(loaded.ok).toBe(false);
|
||||
if (!loaded.ok) expect(loaded.error._tag).toBe("EvidenceIntegrityError");
|
||||
expect(() => parseAnalysisSnapshot(altered)).toThrow(/identifier/u);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
|
@ -16,6 +17,52 @@ import { createServer } from "../src/server/createServer.js";
|
|||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
|
||||
describe("artifact graph MCP integration", () => {
|
||||
it("returns a safe ASAR integrity error without paths or hashes", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
|
||||
const source = join(root, "source");
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "changed();\n";
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(source, archive, { unpack: "*.js" });
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
||||
|
||||
const session = new BinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toEqual({
|
||||
error: {
|
||||
category: "integrity_mismatch",
|
||||
message:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects altered payloads that reuse session Evidence IDs", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
|
||||
import type { AnalysisProvider } from "../src/application/AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
} from "../src/application/AnalysisProvider.js";
|
||||
import { HopperStartError } from "../src/domain/errors.js";
|
||||
import { err } from "../src/domain/result.js";
|
||||
import { observed as ok } from "./fixtures/analysisExecution.js";
|
||||
|
|
@ -96,6 +99,97 @@ describe("binary session", () => {
|
|||
await session.close();
|
||||
});
|
||||
|
||||
it("replays exact immutable calls from a matching provider-neutral snapshot", async () => {
|
||||
const [first, second] = await targets();
|
||||
const initialCalls: string[] = [];
|
||||
const initial = new BinarySession(cacheProvider(initialCalls));
|
||||
expect((await initial.open(first)).ok).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await initial.execute("address_name", {
|
||||
address: "0x1000",
|
||||
document: "first",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const snapshot = initial.exportAnalysisSnapshot();
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (!snapshot.ok) return;
|
||||
expect(snapshot.value.entries).toHaveLength(1);
|
||||
await initial.close();
|
||||
|
||||
const replayCalls: string[] = [];
|
||||
const replay = new BinarySession(cacheProvider(replayCalls));
|
||||
expect(replay.importAnalysisSnapshot(snapshot.value)).toEqual({
|
||||
ok: true,
|
||||
value: 1,
|
||||
});
|
||||
expect((await replay.open(first)).ok).toBe(true);
|
||||
const cached = await replay.execute("address_name", {
|
||||
address: "0x1000",
|
||||
document: "first",
|
||||
});
|
||||
expect(cached.ok).toBe(true);
|
||||
if (cached.ok)
|
||||
expect(cached.value.limitations).toContainEqual(
|
||||
expect.stringContaining("local REA analysis snapshot"),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await replay.execute("set_address_name", {
|
||||
address: "0x1000",
|
||||
name: "renamed",
|
||||
document: "first",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await replay.execute("address_name", {
|
||||
address: "0x1000",
|
||||
document: "first",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(replayCalls).toEqual(["health", "set_address_name", "address_name"]);
|
||||
expect(replay.exportAnalysisSnapshot().ok).toBe(false);
|
||||
await replay.close();
|
||||
|
||||
const mismatch = new BinarySession(cacheProvider([]));
|
||||
expect(mismatch.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
|
||||
const opened = await mismatch.open(second);
|
||||
expect(opened.ok).toBe(false);
|
||||
if (!opened.ok) expect(opened.error._tag).toBe("EvidenceIntegrityError");
|
||||
await mismatch.close();
|
||||
});
|
||||
|
||||
it("does not snapshot reads that depend on the provider cursor", async () => {
|
||||
const [first] = await targets();
|
||||
const calls: string[] = [];
|
||||
const session = new BinarySession(cacheProvider(calls));
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect((await session.execute("address_name", {})).ok).toBe(true);
|
||||
expect(calls).toEqual(["health", "address_name", "address_name"]);
|
||||
expect(session.exportAnalysisSnapshot()).toMatchObject({
|
||||
ok: true,
|
||||
value: { entries: [] },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("does not replay operations with filesystem side effects", async () => {
|
||||
const [first] = await targets();
|
||||
const calls: string[] = [];
|
||||
const session = new BinarySession(cacheProvider(calls, true));
|
||||
expect((await session.open(first)).ok).toBe(true);
|
||||
const input = { address: "0x1000", document: "first" };
|
||||
expect((await session.execute("address_name", input)).ok).toBe(true);
|
||||
expect((await session.execute("address_name", input)).ok).toBe(true);
|
||||
expect(calls).toEqual(["health", "address_name", "address_name"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("returns typed unavailability without dispatching a partial provider", async () => {
|
||||
const [first] = await targets();
|
||||
const operations: string[] = [];
|
||||
|
|
@ -306,6 +400,62 @@ const client = (fail = false): AnalysisClient => ({
|
|||
close: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const cacheProvider = (
|
||||
calls: string[],
|
||||
mayWriteFilesystem = false,
|
||||
): AnalysisProvider => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
} as const;
|
||||
return {
|
||||
identity: () => identity,
|
||||
capabilities: () => [
|
||||
cacheCapability(identity, "address_name", false, mayWriteFilesystem),
|
||||
cacheCapability(identity, "set_address_name", true),
|
||||
],
|
||||
createClient: () => ({
|
||||
execute: (operation) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(ok(operation));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const cacheCapability = (
|
||||
provider: CapabilityDescriptor["provider"],
|
||||
operation: "address_name" | "set_address_name",
|
||||
mutatesArtifact: boolean,
|
||||
mayWriteFilesystem = false,
|
||||
): CapabilityDescriptor => ({
|
||||
provider,
|
||||
operation,
|
||||
inputContractVersion: 1,
|
||||
outputContractVersion: 1,
|
||||
available: true,
|
||||
reason: null,
|
||||
pagination: "none",
|
||||
exhaustive: true,
|
||||
effects: {
|
||||
mutatesArtifact,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limits: {
|
||||
maxResults: null,
|
||||
maxPayloadBytes: null,
|
||||
timeoutMs: null,
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
class TestClient implements AnalysisClient {
|
||||
closed = 0;
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const bridgePath = fileURLToPath(
|
||||
new URL("../bridge/hopper_bridge.py", import.meta.url),
|
||||
);
|
||||
const probePath = fileURLToPath(
|
||||
new URL("./fixtures/bridgeRegexProbe.py", import.meta.url),
|
||||
);
|
||||
|
||||
const errorResultSchema = z.object({
|
||||
action: z.enum(["match", "search"]),
|
||||
ok: z.literal(false),
|
||||
type: z.string(),
|
||||
diagnostic_type: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
const matchResultSchema = z.object({
|
||||
action: z.literal("match"),
|
||||
ok: z.literal(true),
|
||||
backtracking_paths: z.number().int().positive(),
|
||||
matched: z.boolean(),
|
||||
});
|
||||
const searchResultSchema = z.object({
|
||||
action: z.literal("search"),
|
||||
ok: z.literal(true),
|
||||
result: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
address: z.string(),
|
||||
value: z.string(),
|
||||
value_truncated: z.boolean(),
|
||||
}),
|
||||
),
|
||||
offset: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive(),
|
||||
total: z.number().int().nonnegative(),
|
||||
next_offset: z.number().int().nonnegative().nullable(),
|
||||
has_more: z.boolean(),
|
||||
}),
|
||||
});
|
||||
const probeResultSchema = z.union([
|
||||
errorResultSchema,
|
||||
matchResultSchema,
|
||||
searchResultSchema,
|
||||
]);
|
||||
|
||||
type ProbeInput =
|
||||
| {
|
||||
readonly action: "match";
|
||||
readonly pattern: string;
|
||||
readonly value: string;
|
||||
readonly case_sensitive?: boolean;
|
||||
}
|
||||
| {
|
||||
readonly action: "search";
|
||||
readonly items: readonly (readonly [string, string])[];
|
||||
readonly params: Readonly<Record<string, string | number | boolean>>;
|
||||
};
|
||||
|
||||
const probe = async (input: ProbeInput) => {
|
||||
const result = await execFileAsync(
|
||||
"python3",
|
||||
[probePath, bridgePath, JSON.stringify(input)],
|
||||
{ timeout: 3_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
return probeResultSchema.parse(JSON.parse(result.stdout));
|
||||
};
|
||||
|
||||
const invalidRequest = (action: "match" | "search", message: string) => ({
|
||||
action,
|
||||
ok: false,
|
||||
type: "ValueError",
|
||||
diagnostic_type: "invalid_request",
|
||||
message,
|
||||
});
|
||||
|
||||
describe("Hopper bridge regex bounds", () => {
|
||||
it("accepts useful patterns whose static backtracking budget is bounded", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: String.raw`^rea_(?:main|helper)_[0-9]{1,3}$`,
|
||||
value: "REA_helper_42",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "match",
|
||||
ok: true,
|
||||
backtracking_paths: 6,
|
||||
matched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies bounded regex matching through deterministic pagination", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "search",
|
||||
items: [
|
||||
["0x1000", "REA_main_1"],
|
||||
["0x2000", "unrelated"],
|
||||
["0x3000", "rea_helper_42"],
|
||||
],
|
||||
params: {
|
||||
pattern: String.raw`^rea_(?:main|helper)_[0-9]{1,3}$`,
|
||||
mode: "regex",
|
||||
case_sensitive: false,
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "search",
|
||||
ok: true,
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
address: "0x3000",
|
||||
value: "rea_helper_42",
|
||||
value_truncated: false,
|
||||
},
|
||||
],
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
total: 2,
|
||||
next_offset: null,
|
||||
has_more: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"lookaround",
|
||||
"(?=a)a",
|
||||
"Regex lookarounds and backreferences are not supported",
|
||||
],
|
||||
[
|
||||
"backreference",
|
||||
String.raw`(a)\1`,
|
||||
"Regex lookarounds and backreferences are not supported",
|
||||
],
|
||||
[
|
||||
"nested repeat",
|
||||
"(a{1,2}){1,2}",
|
||||
"Nested regex repetitions are not supported",
|
||||
],
|
||||
[
|
||||
"unbounded repeat",
|
||||
"a+",
|
||||
"Unbounded or excessive regex repetitions are not supported",
|
||||
],
|
||||
[
|
||||
"excessive repeat",
|
||||
"a{1001}",
|
||||
"Unbounded or excessive regex repetitions are not supported",
|
||||
],
|
||||
])("rejects unsupported %s constructs", async (_name, pattern, message) => {
|
||||
await expect(
|
||||
probe({ action: "match", pattern, value: "aaaa", case_sensitive: true }),
|
||||
).resolves.toEqual(invalidRequest("match", message));
|
||||
});
|
||||
|
||||
it.each(["(", "a{999999999999999999999999}"])(
|
||||
"normalizes parser failure %s through the search boundary",
|
||||
async (pattern) => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "search",
|
||||
items: [],
|
||||
params: { pattern, mode: "regex", offset: 0, limit: 1 },
|
||||
}),
|
||||
).resolves.toEqual(invalidRequest("search", "Invalid regex pattern"));
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts the maximum bounded repeat at the exact work budget", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: "a{1000}",
|
||||
value: "a".repeat(1_000),
|
||||
case_sensitive: true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "match",
|
||||
ok: true,
|
||||
backtracking_paths: 1,
|
||||
matched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts expensive matching just below the cumulative work limit", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: "(?:a|aa){1,8}b",
|
||||
value: "a".repeat(115),
|
||||
case_sensitive: true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "match",
|
||||
ok: true,
|
||||
backtracking_paths: 510,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects ambiguous bounded repetition before Python regex execution", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: "(a|aa){1,35}b",
|
||||
value: "a".repeat(35),
|
||||
case_sensitive: true,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
invalidRequest(
|
||||
"match",
|
||||
"Regex exceeds the 10000-path backtracking budget",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects excessive combinations across adjacent optional atoms", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: `${"a?".repeat(14)}b`,
|
||||
value: "a".repeat(14),
|
||||
case_sensitive: true,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
invalidRequest(
|
||||
"match",
|
||||
"Regex exceeds the 10000-path backtracking budget",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds cumulative work before evaluating a costly candidate", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: "(?:a|aa){1,12}b",
|
||||
value: "a".repeat(4_096),
|
||||
case_sensitive: true,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
invalidRequest(
|
||||
"match",
|
||||
"Regex search exceeds the 1000000-unit work budget",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the regex work budget across the full inventory", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "search",
|
||||
items: [
|
||||
["0x1000", "a".repeat(60)],
|
||||
["0x2000", "a".repeat(60)],
|
||||
],
|
||||
params: {
|
||||
pattern: "(?:a|aa){1,8}b",
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
invalidRequest(
|
||||
"search",
|
||||
"Regex search exceeds the 1000000-unit work budget",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized regex candidates instead of matching a prefix", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: "needle",
|
||||
value: `${"x".repeat(4_096)}needle`,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
invalidRequest(
|
||||
"match",
|
||||
"Regex candidate exceeds the 4096-character safety limit",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a regex candidate at the exact character limit", async () => {
|
||||
await expect(
|
||||
probe({
|
||||
action: "match",
|
||||
pattern: "z$",
|
||||
value: `${"a".repeat(4_095)}z`,
|
||||
case_sensitive: true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "match",
|
||||
ok: true,
|
||||
backtracking_paths: 1,
|
||||
matched: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[256, true],
|
||||
[257, false],
|
||||
])("enforces the %i-character pattern boundary", async (length, accepted) => {
|
||||
const pattern = "a".repeat(length);
|
||||
const result = await probe({
|
||||
action: "search",
|
||||
items: [["0x1000", pattern]],
|
||||
params: {
|
||||
pattern,
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
if (accepted) {
|
||||
expect(result).toMatchObject({
|
||||
action: "search",
|
||||
ok: true,
|
||||
result: { total: 1 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
expect(result).toEqual(
|
||||
invalidRequest(
|
||||
"search",
|
||||
"pattern must contain between 1 and 256 characters",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps literal matching exhaustive while bounding returned text", async () => {
|
||||
const result = await probe({
|
||||
action: "search",
|
||||
items: [["0x1000", `${"x".repeat(4_096)}needle`]],
|
||||
params: {
|
||||
pattern: "needle",
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
action: "search",
|
||||
ok: true,
|
||||
result: {
|
||||
total: 1,
|
||||
has_more: false,
|
||||
items: [{ address: "0x1000", value_truncated: true }],
|
||||
},
|
||||
});
|
||||
if (result.action === "search" && result.ok)
|
||||
expect(result.result.items[0]?.value).toHaveLength(4_096);
|
||||
});
|
||||
});
|
||||
|
|
@ -72,6 +72,11 @@ describe("Hopper bridge truthfulness", () => {
|
|||
);
|
||||
expect(bridgeSource).toContain("MAX_SEARCH_PATTERN_LENGTH = 256");
|
||||
expect(bridgeSource).toContain("MAX_SEARCH_VALUE_LENGTH = 4096");
|
||||
expect(bridgeSource).toContain("MAX_REGEX_BACKTRACKING_PATHS = 10000");
|
||||
expect(bridgeSource).toContain("MAX_REGEX_CANDIDATE_LENGTH = 4096");
|
||||
expect(bridgeSource).toContain("MAX_REGEX_SEARCH_WORK_UNITS = 1000000");
|
||||
expect(bridgeSource).toContain("_checked_regex_paths(");
|
||||
expect(bridgeSource).toContain("_bounded_regex_matcher(");
|
||||
expect(bridgeSource).toContain('"value_truncated"');
|
||||
expect(bridgeSource).toContain("page_end = offset + limit");
|
||||
expect(bridgeSource).toContain("if offset <= total < page_end:");
|
||||
|
|
@ -91,4 +96,33 @@ describe("Hopper bridge truthfulness", () => {
|
|||
expect(bridgeSource).toContain('"type": _diagnostic_type(error)');
|
||||
expect(bridgeSource).not.toContain("traceback.format_exc");
|
||||
});
|
||||
|
||||
it("stops background analysis and closes the session-owned document", () => {
|
||||
expect(bridgeSource).toContain("def _session_document():");
|
||||
expect(bridgeSource).toContain("os.path.realpath(REA_TARGET_PATH)");
|
||||
expect(bridgeSource).toContain("document.getExecutableFilePath()");
|
||||
expect(bridgeSource).toContain("document.getDatabaseFilePath()");
|
||||
expect(bridgeSource).toContain("document.backgroundProcessActive()");
|
||||
expect(bridgeSource).toContain("document.requestBackgroundProcessStop()");
|
||||
expect(bridgeSource).toContain("document.waitForBackgroundProcessToEnd()");
|
||||
expect(bridgeSource).toContain("document.closeDocument()");
|
||||
expect(bridgeSource).toContain(
|
||||
"document_closed = _session_document() is None",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns deterministic addresses for direct procedure relationships", () => {
|
||||
expect(bridgeSource).toContain(
|
||||
"_hex(item.getEntryPoint()) for item in procedure.getAllCallerProcedures()",
|
||||
);
|
||||
expect(bridgeSource).toContain(
|
||||
"_hex(item.getEntryPoint()) for item in procedure.getAllCalleeProcedures()",
|
||||
);
|
||||
expect(bridgeSource).not.toContain(
|
||||
"_procedure_name(item) for item in procedure.getAllCallerProcedures()",
|
||||
);
|
||||
expect(bridgeSource).not.toContain(
|
||||
"_procedure_name(item) for item in procedure.getAllCalleeProcedures()",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeCliOutput } from "../src/cliOutput.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe("CLI output boundary", () => {
|
||||
it("preserves normal output and sanitizes text and JSON validation errors", () => {
|
||||
expect(sanitizeCliOutput("result: ok\n")).toBe("result: ok\n");
|
||||
expect(sanitizeCliOutput("result: VALIDATION_ERROR\n")).toBe(
|
||||
"result: VALIDATION_ERROR\n",
|
||||
);
|
||||
const raw =
|
||||
'code: VALIDATION_ERROR\nmessage: "raw Zod details"\nfieldErrors: SECRET\n';
|
||||
expect(sanitizeCliOutput(raw)).toBe(
|
||||
'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
|
||||
);
|
||||
expect(
|
||||
JSON.parse(
|
||||
sanitizeCliOutput(
|
||||
JSON.stringify({
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "raw Zod details",
|
||||
fieldErrors: [{ code: "invalid_type" }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
code: "VALIDATION_ERROR",
|
||||
message:
|
||||
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.",
|
||||
});
|
||||
expect(
|
||||
JSON.parse(
|
||||
sanitizeCliOutput(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "raw Zod details",
|
||||
fieldErrors: [{ code: "invalid_type" }],
|
||||
},
|
||||
meta: { command: "analyze" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message:
|
||||
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.",
|
||||
},
|
||||
meta: { command: "analyze" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes a real missing-argument dispatcher failure", async () => {
|
||||
await expect(
|
||||
execFileAsync(process.execPath, ["scripts/rea.mjs", "analyze"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
stdout:
|
||||
'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
|
||||
});
|
||||
await expect(
|
||||
execFileAsync(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "--full-output", "--json", "analyze"],
|
||||
{ cwd: process.cwd() },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
stdout: expect.not.stringContaining("fieldErrors"),
|
||||
});
|
||||
await expect(
|
||||
execFileAsync(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "--full-output", "analyze"],
|
||||
{ cwd: process.cwd() },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
stdout:
|
||||
'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,13 @@ describe("runtime configuration", () => {
|
|||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
},
|
||||
analysisSnapshotFilePolicy: {
|
||||
roots: [],
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
maxDepth: 64,
|
||||
maxStringLength: 1024 * 1024,
|
||||
maxNodes: 1_000_000,
|
||||
},
|
||||
referenceSourcePolicy: {
|
||||
roots: [],
|
||||
secretPatterns: [],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ import {
|
|||
sessionOutputSchemas,
|
||||
} from "../src/contracts/toolOutputSchemas.js";
|
||||
|
||||
const emptySchemaPaths = (value: unknown, path = "$"): string[] => {
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item, index) =>
|
||||
emptySchemaPaths(item, `${path}[${String(index)}]`),
|
||||
);
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return [path];
|
||||
return entries.flatMap(([key, item]) =>
|
||||
emptySchemaPaths(item, `${path}.${key}`),
|
||||
);
|
||||
};
|
||||
|
||||
describe("tool contract surface", () => {
|
||||
it("advertises complete typed schemas and annotations for all analysis tools", () => {
|
||||
const contracts = [
|
||||
|
|
@ -122,4 +135,22 @@ describe("tool contract surface", () => {
|
|||
expect(contract.description).toMatch(/[.;]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes no unconstrained output-schema holes across all 68 tools", () => {
|
||||
const contracts = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
];
|
||||
expect(contracts).toHaveLength(68);
|
||||
for (const contract of contracts) {
|
||||
const schema = z.toJSONSchema(contract.outputSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
});
|
||||
expect(emptySchemaPaths(schema), contract.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,28 @@ const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
|
|||
});
|
||||
|
||||
describe("doctor", () => {
|
||||
it("returns exact recovery for every failed diagnostic", async () => {
|
||||
const result = await runDoctor(
|
||||
"/missing/app",
|
||||
host({
|
||||
nodeVersion: "20.0.0",
|
||||
macosVersion: () => Promise.resolve("11.7"),
|
||||
executable: () => Promise.resolve(false),
|
||||
validTarget: () => Promise.resolve(false),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
result.checks.map(({ name, remediation }) => [name, remediation]),
|
||||
),
|
||||
).toEqual({
|
||||
node: "Install Node.js 22.19+ or 24.11+.",
|
||||
host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
|
||||
hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
target: "Supply a readable local app or program path.",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["11.7", undefined])(
|
||||
"rejects unsupported macOS version %s",
|
||||
async (version) => {
|
||||
|
|
|
|||
|
|
@ -311,9 +311,9 @@ describe("enhanced MCP tools", () => {
|
|||
address: "0x2",
|
||||
status: "error",
|
||||
error: {
|
||||
tag: "AnalysisOutputError",
|
||||
message: "Invalid analysis output for decompile: failed",
|
||||
details: { operation: "decompile", reason: "failed" },
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -352,7 +352,11 @@ describe("enhanced MCP tools", () => {
|
|||
{
|
||||
address: "0x1",
|
||||
status: "error",
|
||||
error: { tag: "AnalysisOutputError" },
|
||||
error: {
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -551,8 +555,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"non-advancing pagination offset",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -598,8 +602,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"HopperProtocolError",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -619,8 +623,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"AnalysisOutputError",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -655,8 +659,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"AnalysisOutputError",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ describe("analysis error projection", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("exhaustively projects stable tags without causes or local paths", () => {
|
||||
it("projects every typed failure without internal details", () => {
|
||||
const secretCause = new Error("secret-token");
|
||||
const byTag = {
|
||||
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
|
||||
|
|
@ -103,17 +103,42 @@ describe("analysis error projection", () => {
|
|||
} satisfies Readonly<Record<AnalysisErrorTag, AnalysisError>>;
|
||||
const errors = Object.values(byTag);
|
||||
const projected = errors.map(projectAnalysisError);
|
||||
expect(new Set(projected.map(({ tag }) => tag)).size).toBe(errors.length);
|
||||
expect(projected).toHaveLength(24);
|
||||
expect(JSON.stringify(projected)).not.toContain("secret-token");
|
||||
expect(JSON.stringify(projected)).not.toContain("/secret/local/path");
|
||||
const serialized = JSON.stringify(projected);
|
||||
for (const hidden of [
|
||||
"secret-token",
|
||||
"/secret/local/path",
|
||||
"fixture",
|
||||
"overview",
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
"main.js",
|
||||
"declaredSha256",
|
||||
])
|
||||
expect(serialized).not.toContain(hidden);
|
||||
expect(
|
||||
projected.find(({ tag }) => tag === "HopperTimeoutError"),
|
||||
).toMatchObject({ details: { timeoutMs: 100 } });
|
||||
expect(
|
||||
projected.find(({ tag }) => tag === "ProviderAdapterError"),
|
||||
projectAnalysisError(byTag.AnalysisCapabilityUnavailableError),
|
||||
).toMatchObject({
|
||||
details: { providerId: "fixture", operation: "overview" },
|
||||
category: "unsupported_provider",
|
||||
});
|
||||
expect(
|
||||
projectAnalysisError(
|
||||
new ArtifactOperationError("inventory_artifact", "integrity", {
|
||||
logicalPath: "main.js",
|
||||
declaredSha256: "a".repeat(64),
|
||||
calculatedSha256: "b".repeat(64),
|
||||
unpacked: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
category: "integrity_mismatch",
|
||||
});
|
||||
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
|
||||
expect(
|
||||
projected.every(({ message }) =>
|
||||
/try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy/u.test(
|
||||
message,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
"""Execute REA's bridge regex helpers without starting a Hopper socket."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def _load_bridge(path):
|
||||
source = Path(path).read_text(encoding="utf-8")
|
||||
prefix, marker, suffix = source.rpartition("\n_run()")
|
||||
if not marker or suffix.strip():
|
||||
raise RuntimeError("bridge entrypoint shape changed")
|
||||
namespace = {"__file__": path}
|
||||
exec(compile(prefix, path, "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
def _probe(namespace, payload):
|
||||
action = payload["action"]
|
||||
try:
|
||||
if action == "match":
|
||||
parsed = namespace["sre_parse"].parse(payload["pattern"])
|
||||
paths, steps = namespace["_validate_regex_node"](parsed)
|
||||
flags = (
|
||||
0
|
||||
if payload.get("case_sensitive", False)
|
||||
else namespace["re"].IGNORECASE
|
||||
)
|
||||
expression = namespace["re"].compile(payload["pattern"], flags)
|
||||
matcher = namespace["_bounded_regex_matcher"](expression, paths, steps)
|
||||
matched = matcher(payload["value"])
|
||||
return {
|
||||
"action": action,
|
||||
"ok": True,
|
||||
"backtracking_paths": paths,
|
||||
"matched": matched,
|
||||
}
|
||||
if action == "search":
|
||||
inventory = tuple(tuple(item) for item in payload["items"])
|
||||
namespace["_search_inventory"] = lambda _document, _kind: inventory
|
||||
return {
|
||||
"action": action,
|
||||
"ok": True,
|
||||
"result": namespace["_search_page"](
|
||||
object(), "string", payload["params"]
|
||||
),
|
||||
}
|
||||
raise ValueError("unknown probe action")
|
||||
except Exception as error:
|
||||
return {
|
||||
"action": action,
|
||||
"ok": False,
|
||||
"type": type(error).__name__,
|
||||
"diagnostic_type": namespace["_diagnostic_type"](error),
|
||||
"message": str(error),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
bridge = _load_bridge(sys.argv[1])
|
||||
request = json.loads(sys.argv[2])
|
||||
print(json.dumps(_probe(bridge, request), sort_keys=True))
|
||||
|
|
@ -126,7 +126,14 @@ const server = createServer((socket) => {
|
|||
true,
|
||||
);
|
||||
} else if (request.method === "shutdown") {
|
||||
send({ id: request.id, result: { shutdown: true } });
|
||||
send({
|
||||
id: request.id,
|
||||
result: {
|
||||
shutdown: true,
|
||||
analysis_stopped: true,
|
||||
document_closed: true,
|
||||
},
|
||||
});
|
||||
setTimeout(() => server.close(), 2);
|
||||
} else if (request.method === "hang") {
|
||||
// Deliberately leave the request pending.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,17 @@ describe("function comparison MCP integration", () => {
|
|||
arguments: { domain: "function-comparison" },
|
||||
});
|
||||
expect(unknowns.structuredContent).toMatchObject({
|
||||
result: [expect.objectContaining({ domain: "function-comparison" })],
|
||||
result: [
|
||||
expect.objectContaining({
|
||||
domain: "function-comparison",
|
||||
recommended_probes: [
|
||||
expect.objectContaining({
|
||||
rationale:
|
||||
"Capture complete dossiers for both functions under the same target context and analysis limits.",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -64,14 +65,94 @@ describe("curl installer scenarios", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["unsupported Node", { FAKE_NODE_VERSION: "20.0.0" }, "unsupported"],
|
||||
["npm failure", { FAKE_NPM_FAIL: "1" }, "npm package installation failed"],
|
||||
["version mismatch", { FAKE_REA_VERSION: "9.9.9" }, "does not match"],
|
||||
[
|
||||
"unsupported Node",
|
||||
{ FAKE_NODE_VERSION: "20.0.0" },
|
||||
"REA installation failed: Node.js 20.0.0 is unsupported; use Node.js 22.19+ or 24.11+.\n",
|
||||
],
|
||||
[
|
||||
"npm failure",
|
||||
{ FAKE_NPM_FAIL: "1" },
|
||||
"REA installation failed: npm could not install REA. Check registry access and npm permissions, then retry.\n",
|
||||
],
|
||||
[
|
||||
"version mismatch",
|
||||
{ FAKE_REA_VERSION: "9.9.9" },
|
||||
"REA installation failed: installed version 9.9.9 does not match 0.3.0.\n",
|
||||
],
|
||||
] as const)("fails closed on %s", async (_name, overrides, message) => {
|
||||
const fixture = await createFixture();
|
||||
await expect(
|
||||
runInstaller(fixture, ["--version", "0.3.0"], overrides),
|
||||
).rejects.toMatchObject({ stderr: expect.stringContaining(message) });
|
||||
).rejects.toMatchObject({ stderr: message });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"unreadable Node version",
|
||||
["--version", "0.3.0"],
|
||||
{ FAKE_NODE_VERSION: "invalid" },
|
||||
"REA installation failed: the active Node.js version could not be read. Check that node works and is on PATH, then retry.\n",
|
||||
],
|
||||
[
|
||||
"release lookup",
|
||||
[],
|
||||
{ FAKE_CURL_FAIL: "1" },
|
||||
"REA installation failed: the latest REA release could not be resolved. Check network access or pass --version VERSION, then retry.\n",
|
||||
],
|
||||
[
|
||||
"release response",
|
||||
[],
|
||||
{ FAKE_CURL_BODY: "not-json" },
|
||||
"REA installation failed: the release response was invalid. Retry later or pass --version VERSION.\n",
|
||||
],
|
||||
[
|
||||
"release tag",
|
||||
[],
|
||||
{ FAKE_CURL_BODY: '{"tag_name":"unrelated-1.0.0"}' },
|
||||
"REA installation failed: the latest release tag was invalid. Retry later or pass --version VERSION.\n",
|
||||
],
|
||||
[
|
||||
"npm prefix",
|
||||
["--version", "0.3.0"],
|
||||
{ FAKE_PLATFORM: "Darwin", FAKE_NPM_PREFIX_FAIL: "1" },
|
||||
"REA installation failed: the npm global prefix could not be read. Repair the npm configuration, then retry.\n",
|
||||
],
|
||||
[
|
||||
"missing installed command",
|
||||
["--version", "0.3.0"],
|
||||
{ FAKE_NPM_SKIP_BINARY: "1" },
|
||||
"REA installation failed: npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry.\n",
|
||||
],
|
||||
[
|
||||
"unreadable installed version",
|
||||
["--version", "0.3.0"],
|
||||
{ FAKE_REA_VERSION_FAIL: "1" },
|
||||
"REA installation failed: the installed REA version could not be read. Reinstall the requested version, then retry.\n",
|
||||
],
|
||||
] as const)(
|
||||
"reports exact recovery for %s failure",
|
||||
async (_name, args, overrides, message) => {
|
||||
const fixture = await createFixture();
|
||||
await expect(
|
||||
runInstaller(fixture, args, overrides),
|
||||
).rejects.toMatchObject({
|
||||
stderr: message,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports exact recovery when curl is missing", async () => {
|
||||
const fixture = await createFixture();
|
||||
await rm(join(fixture.bin, "curl"));
|
||||
await expect(
|
||||
runInstaller(fixture, ["--version", "0.3.0"], {
|
||||
PATH: fixture.bin,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
stderr:
|
||||
"REA installation failed: curl is required. Install curl, then rerun this installer.\n",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -94,7 +175,7 @@ const createFixture = async (): Promise<InstallerFixture> => {
|
|||
await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]);
|
||||
await executable(
|
||||
join(bin, "uname"),
|
||||
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo Linux\n',
|
||||
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || printf \'%s\\n\' "${FAKE_PLATFORM:-Linux}"\n',
|
||||
);
|
||||
await executable(
|
||||
join(bin, "node"),
|
||||
|
|
@ -106,12 +187,14 @@ if [ "$1" = "-p" ]; then printf '%s\n' "\${FAKE_NODE_VERSION:-24.18.0}"; else ex
|
|||
join(bin, "npm"),
|
||||
`#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$FAKE_NPM_LOG"
|
||||
[ "$1" = "prefix" ] && { [ "\${FAKE_NPM_PREFIX_FAIL:-}" = "1" ] && exit 1; printf '%s\n' "$FAKE_NPM_PREFIX"; exit 0; }
|
||||
[ "\${FAKE_NPM_FAIL:-}" = "1" ] && exit 1
|
||||
prefix="$HOME/.local"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--prefix" ]; then shift; prefix="$1"; fi
|
||||
shift
|
||||
done
|
||||
[ "\${FAKE_NPM_SKIP_BINARY:-}" = "1" ] && exit 0
|
||||
mkdir -p "$prefix/bin"
|
||||
cp "$FAKE_REA_SOURCE" "$prefix/bin/rea"
|
||||
chmod +x "$prefix/bin/rea"
|
||||
|
|
@ -119,15 +202,22 @@ chmod +x "$prefix/bin/rea"
|
|||
);
|
||||
await executable(
|
||||
join(bin, "curl"),
|
||||
'#!/bin/sh\nprintf \'{"tag_name":"rea-agents-0.3.0"}\'\n',
|
||||
`#!/bin/sh
|
||||
[ "\${FAKE_CURL_FAIL:-}" = "1" ] && exit 1
|
||||
if [ -n "\${FAKE_CURL_BODY:-}" ]; then printf '%s' "$FAKE_CURL_BODY"; else printf '%s' '{"tag_name":"rea-agents-0.3.0"}'; fi
|
||||
`,
|
||||
);
|
||||
await executable(
|
||||
join(bin, "rea-source"),
|
||||
`#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$FAKE_REA_LOG"
|
||||
[ "\${FAKE_REA_VERSION_FAIL:-}" = "1" ] && exit 1
|
||||
[ "$1" = "--version" ] && printf '%s\n' "\${FAKE_REA_VERSION:-0.3.0}"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
for (const command of ["chmod", "cp", "mkdir", "tr"])
|
||||
await symlink(`/usr/bin/${command}`, join(bin, command));
|
||||
return { home, bin, temporary, npmLog, reaLog };
|
||||
};
|
||||
|
||||
|
|
@ -150,6 +240,7 @@ const runInstaller = async (
|
|||
FAKE_NPM_LOG: fixture.npmLog,
|
||||
FAKE_REA_LOG: fixture.reaLog,
|
||||
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
|
||||
FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ describe("configuration parsing integration", () => {
|
|||
HOPPER_TARGET_KIND: "executable",
|
||||
HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]',
|
||||
REA_EVIDENCE_ROOTS_JSON: '["/tmp/evidence"]',
|
||||
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: '["/tmp/analysis"]',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("unreachable");
|
||||
|
|
@ -34,6 +35,9 @@ describe("configuration parsing integration", () => {
|
|||
"--aarch64",
|
||||
]);
|
||||
expect(result.value.evidenceFilePolicy.roots).toEqual(["/tmp/evidence"]);
|
||||
expect(result.value.analysisSnapshotFilePolicy.roots).toEqual([
|
||||
"/tmp/analysis",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects target kind values outside the enum", () => {
|
||||
|
|
|
|||
|
|
@ -252,6 +252,11 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
},
|
||||
});
|
||||
expect(unavailable.isError).toBe(true);
|
||||
expect(structured(unavailable)).toMatchObject({
|
||||
error: {
|
||||
category: "unsupported_provider",
|
||||
},
|
||||
});
|
||||
expect(received[0]).not.toHaveProperty("unknown_registry_approved");
|
||||
expect(
|
||||
structured(
|
||||
|
|
@ -260,9 +265,15 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
).toMatchObject({
|
||||
result: [
|
||||
{
|
||||
domain: "provider-capability",
|
||||
domain: "analysis-capability",
|
||||
question:
|
||||
"procedure_pseudo_code is unavailable: Decompiler is not installed.",
|
||||
"The requested analysis is unavailable for the current target.",
|
||||
recommended_probes: [
|
||||
{
|
||||
rationale:
|
||||
"Choose another analysis or target that can answer this question.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -354,7 +365,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
expect(names).not.toContain("open_binary");
|
||||
});
|
||||
|
||||
it("propagates remote Hopper errors with structured content", async () => {
|
||||
it("projects remote failures without provider or bridge details", async () => {
|
||||
const client = await connect({
|
||||
execute: () =>
|
||||
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
|
||||
|
|
@ -365,8 +376,14 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(text(result)).toContain("HopperRemoteError");
|
||||
expect(text(result)).toContain("bridge timeout");
|
||||
expect(structured(result)).toMatchObject({
|
||||
error: {
|
||||
category: "execution_failure",
|
||||
},
|
||||
});
|
||||
expect(text(result)).toBe(
|
||||
"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles concurrent tool calls without corruption", async () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
} from "../src/contracts/investigationExamples.js";
|
||||
import { parseEvidence } from "../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../src/domain/evidence.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../src/domain/jsonValue.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
import { readInvestigationWorkspace } from "../src/application/InvestigationWorkspaceStore.js";
|
||||
|
|
@ -102,7 +103,7 @@ describe("investigation MCP workflows", () => {
|
|||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const evidence = parseEvidence(
|
||||
z.object({ result: z.unknown() }).parse(response.structuredContent)
|
||||
.result,
|
||||
|
|
@ -111,9 +112,14 @@ describe("investigation MCP workflows", () => {
|
|||
provider: { id: "rea-changed-behavior" },
|
||||
normalized_result: { behavior_status: "unknown" },
|
||||
});
|
||||
expect(session.listUnknowns({ domain: "changed-behavior" })).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
session.listUnknowns({ domain: "changed-behavior" }),
|
||||
).toMatchObject([
|
||||
{
|
||||
question:
|
||||
"Did both versions behave the same under a complete controlled replay?",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
|
|
@ -148,6 +154,66 @@ describe("investigation MCP workflows", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("records a safe unresolved call-path question", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
const base = FUNCTION_COMPARISON_EXAMPLE.left;
|
||||
if (base.subject === null) throw new Error("missing dossier subject");
|
||||
const dossier = createEvidence(
|
||||
{
|
||||
path: base.subject.local_path,
|
||||
sha256: base.subject.digest.sha256,
|
||||
format: base.subject.format,
|
||||
...(base.subject.architecture === null
|
||||
? {}
|
||||
: { architecture: base.subject.architecture }),
|
||||
},
|
||||
base.provider,
|
||||
{
|
||||
predicateType: base.predicate_type,
|
||||
operation: base.operation,
|
||||
parameters: base.parameters,
|
||||
result: jsonValueSchema.parse({
|
||||
...jsonObjectSchema.parse(base.normalized_result),
|
||||
callees: {
|
||||
items: [{ address: "0x2000", name: "next" }],
|
||||
total: 1,
|
||||
returned: 1,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
}),
|
||||
rawResult: base.raw_result,
|
||||
confidence: base.confidence,
|
||||
authority: base.authority,
|
||||
limitations: base.limitations,
|
||||
locations: base.locations,
|
||||
evidenceLinks: base.evidence_links,
|
||||
},
|
||||
);
|
||||
expect(session.recordEvidence(dossier).ok).toBe(true);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "build_call_path",
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.build_call_path,
|
||||
functions: [dossier],
|
||||
start: { address: "0x1000" },
|
||||
goal: { address: "0x3000" },
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([
|
||||
{
|
||||
question:
|
||||
"Can the requested call path be established from complete analysis?",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("records an explicit non-causal static/runtime hypothesis", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
|
|
@ -182,6 +248,51 @@ describe("investigation MCP workflows", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("records a safe unresolved static/runtime question", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||
FUNCTION_COMPARISON_EXAMPLE.right,
|
||||
FUNCTION_COMPARISON_EVIDENCE,
|
||||
PROCESS_CAPTURE_REFERENCE,
|
||||
PROCESS_CAPTURE_RECONSTRUCTION,
|
||||
PROCESS_COMPARISON_EVIDENCE,
|
||||
])
|
||||
expect(session.recordEvidence(evidence).ok).toBe(true);
|
||||
const mapping =
|
||||
INVESTIGATION_EXAMPLES.correlate_static_and_runtime.mappings[0];
|
||||
if (mapping === undefined) throw new Error("missing correlation fixture");
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "correlate_static_and_runtime",
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
|
||||
mappings: [
|
||||
{
|
||||
...mapping,
|
||||
static: {
|
||||
...mapping.static,
|
||||
selector: { kind: "function", dimension: "assembly" },
|
||||
},
|
||||
},
|
||||
],
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
session.listUnknowns({ domain: "static-runtime-correlation" }),
|
||||
).toMatchObject([
|
||||
{
|
||||
question:
|
||||
"Does runtime behavior match the available static analysis?",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes only the finite declared reconstruction specification", async () => {
|
||||
const { session, server, client } = await connected();
|
||||
for (const evidence of [
|
||||
|
|
@ -193,7 +304,10 @@ describe("investigation MCP workflows", () => {
|
|||
try {
|
||||
const response = await client.callTool({
|
||||
name: "verify_reconstruction",
|
||||
arguments: INVESTIGATION_EXAMPLES.verify_reconstruction,
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.verify_reconstruction,
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(
|
||||
|
|
@ -243,7 +357,10 @@ describe("investigation MCP workflows", () => {
|
|||
try {
|
||||
const response = await client.callTool({
|
||||
name: "verify_reconstruction",
|
||||
arguments: INVESTIGATION_EXAMPLES.verify_reconstruction,
|
||||
arguments: {
|
||||
...INVESTIGATION_EXAMPLES.verify_reconstruction,
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = parseEvidence(
|
||||
|
|
@ -254,6 +371,13 @@ describe("investigation MCP workflows", () => {
|
|||
status: "unknown",
|
||||
summary: { unknown: 1 },
|
||||
});
|
||||
expect(
|
||||
session.listUnknowns({ domain: "reconstruction-verification" }),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
question: "Does the reconstruction satisfy every declared claim?",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await close(session, server, client);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import type { ServeStdioOptions } from "@modelcontextprotocol/server/stdio";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { run, runEntrypoint } from "../src/main.js";
|
||||
|
||||
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
|
||||
|
||||
const nextTurn = (): Promise<void> =>
|
||||
new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const dependencies = (
|
||||
serve: RuntimeDependencies["serve"],
|
||||
output: string[],
|
||||
shutdown: Array<() => void>,
|
||||
exitCodes: number[],
|
||||
): RuntimeDependencies => ({
|
||||
env: {},
|
||||
serve,
|
||||
writeStderr: (text) => output.push(text),
|
||||
setExitCode: (code) => exitCodes.push(code),
|
||||
registerShutdown: (handler) => shutdown.push(handler),
|
||||
});
|
||||
|
||||
describe("MCP runtime errors", () => {
|
||||
it("reports connection loss without the transport cause", async () => {
|
||||
const output: string[] = [];
|
||||
const shutdown: Array<() => void> = [];
|
||||
let options: ServeStdioOptions | undefined;
|
||||
let closeCalls = 0;
|
||||
const close = (): Promise<void> => {
|
||||
closeCalls += 1;
|
||||
return Promise.resolve();
|
||||
};
|
||||
const exitCodes: number[] = [];
|
||||
expect(
|
||||
await run(
|
||||
dependencies(
|
||||
(_factory, received) => {
|
||||
options = received;
|
||||
return { close };
|
||||
},
|
||||
output,
|
||||
shutdown,
|
||||
exitCodes,
|
||||
),
|
||||
),
|
||||
).toBe(0);
|
||||
options?.onerror?.(new Error("SECRET transport stack"));
|
||||
expect(output).toEqual([
|
||||
"REA lost its MCP client connection. Restart REA from your MCP client.\n",
|
||||
]);
|
||||
shutdown[0]?.();
|
||||
await nextTurn();
|
||||
expect(closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("reports transport startup failure without its cause", async () => {
|
||||
const output: string[] = [];
|
||||
expect(
|
||||
await run(
|
||||
dependencies(
|
||||
() => {
|
||||
throw new Error("SECRET startup stack");
|
||||
},
|
||||
output,
|
||||
[],
|
||||
[],
|
||||
),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(output).toEqual([
|
||||
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports shutdown failure and sets a failing exit code", async () => {
|
||||
const output: string[] = [];
|
||||
const shutdown: Array<() => void> = [];
|
||||
const exitCodes: number[] = [];
|
||||
await run(
|
||||
dependencies(
|
||||
() => ({ close: () => Promise.reject(new Error("SECRET close")) }),
|
||||
output,
|
||||
shutdown,
|
||||
exitCodes,
|
||||
),
|
||||
);
|
||||
shutdown[0]?.();
|
||||
await nextTurn();
|
||||
expect(exitCodes).toEqual([1]);
|
||||
expect(output).toEqual([
|
||||
"REA could not close cleanly. End the REA process before starting it again.\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an unexpected entrypoint failure without its cause", async () => {
|
||||
const output: string[] = [];
|
||||
const exitCodes: number[] = [];
|
||||
await runEntrypoint(
|
||||
() => Promise.reject(new Error("SECRET entrypoint stack")),
|
||||
(text) => output.push(text),
|
||||
(code) => exitCodes.push(code),
|
||||
);
|
||||
expect(output).toEqual([
|
||||
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.\n",
|
||||
]);
|
||||
expect(exitCodes).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
|
@ -199,7 +199,7 @@ describe("official Hopper proxy tools", () => {
|
|||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "HopperRemoteError: Hopper request failed (-1): denied",
|
||||
text: "Analysis could not complete. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
captureProcessScenarioFile,
|
||||
compareProcessEvidenceFiles,
|
||||
projectProcessCliError,
|
||||
} from "../src/application/ProcessCli.js";
|
||||
import { createEvidence } from "../src/domain/evidence.js";
|
||||
import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
const fixture = async (): Promise<string> => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-process-cli-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
};
|
||||
|
||||
describe("process CLI errors", () => {
|
||||
it("exits unsuccessfully without writing failure-shaped evidence", async () => {
|
||||
await expect(
|
||||
execFileAsync(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "capture-process", "/missing/scenario.json"],
|
||||
{ cwd: process.cwd() },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stdout: expect.stringContaining("PROCESS_COMMAND_FAILED"),
|
||||
});
|
||||
});
|
||||
|
||||
it("projects unexpected causes without their message or stack", () => {
|
||||
const projected = projectProcessCliError(
|
||||
new Error("SECRET internal failure /private/path"),
|
||||
);
|
||||
expect(projected).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Process command could not complete. Check the input files and run `rea doctor`, then try again.",
|
||||
});
|
||||
expect(JSON.stringify(projected)).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("projects missing, malformed JSON, and invalid scenario files", async () => {
|
||||
const root = await fixture();
|
||||
const malformed = join(root, "malformed.json");
|
||||
const invalid = join(root, "invalid.json");
|
||||
await writeFile(malformed, "not-json");
|
||||
await writeFile(invalid, "{}");
|
||||
|
||||
expect(
|
||||
await captureProcessScenarioFile(join(root, "missing.json")),
|
||||
).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Process input file could not be read. Check that the path exists and is readable.",
|
||||
});
|
||||
expect(await captureProcessScenarioFile(malformed)).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Process input file is not valid JSON. Repair the file, then try again.",
|
||||
});
|
||||
expect(await captureProcessScenarioFile(invalid)).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Process scenario is invalid. Check its required fields and limits, then try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the exact process-capture policy recovery", async () => {
|
||||
const root = await fixture();
|
||||
const scenario = join(root, "scenario.json");
|
||||
await writeFile(
|
||||
scenario,
|
||||
JSON.stringify({
|
||||
approved: true,
|
||||
executable: "/bin/sh",
|
||||
working_directory: "/tmp",
|
||||
}),
|
||||
);
|
||||
expect(await captureProcessScenarioFile(scenario)).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "permission_required",
|
||||
message:
|
||||
"Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversized process input before parsing", async () => {
|
||||
const root = await fixture();
|
||||
const path = join(root, "large.json");
|
||||
const handle = await open(path, "w");
|
||||
await handle.truncate(64 * 1024 * 1024 + 1);
|
||||
await handle.close();
|
||||
expect(await captureProcessScenarioFile(path)).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "truncated",
|
||||
message:
|
||||
"Process input file is too large. Reduce it below 64 MiB, then try again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("explains unsupported and unrelated capture evidence", async () => {
|
||||
const root = await fixture();
|
||||
const legacy = join(root, "legacy.json");
|
||||
const unrelated = join(root, "unrelated.json");
|
||||
await writeFile(
|
||||
legacy,
|
||||
JSON.stringify(
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture/v3",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: {},
|
||||
result: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
unrelated,
|
||||
JSON.stringify(
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "unrelated/v1",
|
||||
operation: "other",
|
||||
parameters: {},
|
||||
result: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const legacyResult = await compareProcessEvidenceFiles(legacy, legacy);
|
||||
expect(legacyResult).toMatchObject({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
});
|
||||
expect(JSON.stringify(legacyResult)).toContain(
|
||||
"Process Capture v3 is unsupported",
|
||||
);
|
||||
expect(await compareProcessEvidenceFiles(unrelated, unrelated)).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
message:
|
||||
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,11 @@ import { add, commit, init } from "isomorphic-git";
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { importReferenceSource } from "../src/application/ReferenceSourceImport.js";
|
||||
import { projectReferenceSourceEntryFailure } from "../src/application/ReferenceSourceImportEntries.js";
|
||||
import {
|
||||
projectReferenceSourceImportError,
|
||||
type ReferenceSourceImportError,
|
||||
} from "../src/application/ReferenceSourceImportTypes.js";
|
||||
import { createHistoricalSourceManifest } from "../src/domain/referenceSourceGraph.js";
|
||||
|
||||
const limits = {
|
||||
|
|
@ -43,6 +48,52 @@ const importTree = (root: string, approvedRoot: string, signal?: AbortSignal) =>
|
|||
});
|
||||
|
||||
describe("reference source import", () => {
|
||||
it("projects entry failures without low-level reader diagnostics", () => {
|
||||
for (const [kind, code] of [
|
||||
["directory", "io"],
|
||||
["symlink", "io"],
|
||||
["file", "io"],
|
||||
["file", "cancelled"],
|
||||
["file", "limit"],
|
||||
["unknown", "unsupported"],
|
||||
] as const) {
|
||||
const message = projectReferenceSourceEntryFailure({
|
||||
status: "failed",
|
||||
path: "safe/path",
|
||||
kind,
|
||||
code,
|
||||
message: "SECRET internal diagnostic /private/path",
|
||||
});
|
||||
expect(message).not.toContain("SECRET");
|
||||
expect(message).not.toContain("/private/path");
|
||||
expect(message).toMatch(/Check|try again|when ready|smaller|supported/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("projects every import failure without raw parser or policy text", () => {
|
||||
const expectedCategories = {
|
||||
cancelled: "cancelled",
|
||||
"invalid-limits": "invalid_input",
|
||||
"invalid-root": "invalid_input",
|
||||
io: "execution_failure",
|
||||
parse: "execution_failure",
|
||||
policy: "permission_required",
|
||||
} as const;
|
||||
for (const [code, category] of Object.entries(expectedCategories)) {
|
||||
const projected = projectReferenceSourceImportError({
|
||||
tag: "reference-source-import",
|
||||
code: code as ReferenceSourceImportError["code"],
|
||||
message: "SECRET parser stack and /private/path",
|
||||
});
|
||||
expect(projected.category).toBe(category);
|
||||
expect(projected.message).not.toContain("SECRET");
|
||||
expect(projected.message).not.toContain("/private/path");
|
||||
expect(projected.message).toMatch(
|
||||
/try again|when ready|positive integer|Check that|REA_REFERENCE_ROOTS_JSON/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("is relocation-stable, resolves imports, and excludes secrets before capture", async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), "rea-reference-import-"));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -45,6 +45,13 @@ describe("target-free MCP lifecycle", () => {
|
|||
maxStringLength: 1024,
|
||||
maxNodes: 10_000,
|
||||
},
|
||||
analysisSnapshotFilePolicy: {
|
||||
roots: [directory],
|
||||
maxBytes: 1024 * 1024,
|
||||
maxDepth: 68,
|
||||
maxStringLength: 1024,
|
||||
maxNodes: 10_000,
|
||||
},
|
||||
});
|
||||
const mcp = new Client({ name: "session-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
|
|
@ -58,7 +65,9 @@ describe("target-free MCP lifecycle", () => {
|
|||
arguments: {},
|
||||
});
|
||||
expect(before.isError).toBe(true);
|
||||
expect(text(before)).toContain("NoBinaryOpenError");
|
||||
expect(text(before)).toBe(
|
||||
"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",
|
||||
);
|
||||
expect((await mcp.listTools()).tools).toHaveLength(68);
|
||||
const deniedCapture = await mcp.callTool({
|
||||
name: "capture_process_scenario",
|
||||
|
|
@ -69,7 +78,14 @@ describe("target-free MCP lifecycle", () => {
|
|||
},
|
||||
});
|
||||
expect(deniedCapture.isError).toBe(true);
|
||||
expect(text(deniedCapture)).toContain("process capture is disabled");
|
||||
expect(structured(deniedCapture)).toMatchObject({
|
||||
error: {
|
||||
category: "permission_required",
|
||||
},
|
||||
});
|
||||
expect(text(deniedCapture)).toBe(
|
||||
"Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.",
|
||||
);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
|
|
@ -171,10 +187,42 @@ describe("target-free MCP lifecycle", () => {
|
|||
expect(
|
||||
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
|
||||
).toContain("second.hop");
|
||||
await mcp.callTool({ name: "close_binary", arguments: {} });
|
||||
const snapshotPath = join(directory, "analysis.json");
|
||||
expect(
|
||||
structured(
|
||||
await mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: snapshotPath },
|
||||
}),
|
||||
).result,
|
||||
).toMatchObject({ path: snapshotPath, entries: 0 });
|
||||
expect(JSON.parse(await readFile(snapshotPath, "utf8"))).toMatchObject({
|
||||
evidence_bundle: { records: [], unknowns: [] },
|
||||
});
|
||||
expect(
|
||||
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
|
||||
).toContain('"open": false');
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: first, snapshot_path: snapshotPath },
|
||||
})
|
||||
).isError,
|
||||
).toBe(true);
|
||||
expect(
|
||||
structured(await mcp.callTool({ name: "list_unknowns", arguments: {} }))
|
||||
.result,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: second, snapshot_path: snapshotPath },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
await mcp.callTool({ name: "close_binary", arguments: {} });
|
||||
}, 10_000);
|
||||
|
||||
it("records approved process residuals in the unknown registry", async () => {
|
||||
|
|
@ -219,8 +267,7 @@ describe("target-free MCP lifecycle", () => {
|
|||
),
|
||||
).result;
|
||||
expect(listed).toContainEqual({
|
||||
question:
|
||||
"network: External network isolation is not enforced by this adapter.",
|
||||
question: "Was network behavior fully observed during capture?",
|
||||
domain: "process-network",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
|
|||
|
||||
class FakeSetupHost implements SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion = "25.1.0";
|
||||
nodeVersion = "25.1.0";
|
||||
version: string | undefined = "14.5";
|
||||
distribution: LinuxDistribution | undefined;
|
||||
hopper: string | undefined;
|
||||
|
|
@ -22,6 +22,7 @@ class FakeSetupHost implements SetupHost {
|
|||
clientResults = new Map<string, ClientConfigurationResult>();
|
||||
hopperInstalls = 0;
|
||||
configurations = 0;
|
||||
doctorHealthy: boolean | undefined;
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
|
|
@ -55,7 +56,7 @@ class FakeSetupHost implements SetupHost {
|
|||
checks: readonly DoctorCheck[];
|
||||
}> =>
|
||||
Promise.resolve({
|
||||
healthy: this.hopper !== undefined,
|
||||
healthy: this.doctorHealthy ?? this.hopper !== undefined,
|
||||
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
||||
checks: [],
|
||||
});
|
||||
|
|
@ -80,6 +81,9 @@ describe("setup workflow", () => {
|
|||
]);
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configurations).toBe(0);
|
||||
expect(result.remediation).toBe(
|
||||
"Review the setup plan, then rerun interactively or with --yes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies an accepted interactive plan including Hopper", async () => {
|
||||
|
|
@ -95,6 +99,9 @@ describe("setup workflow", () => {
|
|||
"installed_skill",
|
||||
]);
|
||||
expect(host.hopperInstalls).toBe(1);
|
||||
expect(result.remediation).toBe(
|
||||
"Open Hopper, complete its one-time activation, then rerun rea doctor --json.",
|
||||
);
|
||||
});
|
||||
|
||||
it("declines an interactive plan without mutation", async () => {
|
||||
|
|
@ -116,6 +123,9 @@ describe("setup workflow", () => {
|
|||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.appliedActions).toEqual(["installed_skill"]);
|
||||
expect(result.remediation).toContain("--install-hopper");
|
||||
expect(result.remediation).toBe(
|
||||
"Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs Hopper when unattended authorization is explicit", async () => {
|
||||
|
|
@ -151,10 +161,76 @@ describe("setup workflow", () => {
|
|||
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
||||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toContain("failed");
|
||||
expect(result.remediation).toBe(
|
||||
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
|
||||
);
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"backup",
|
||||
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",
|
||||
],
|
||||
[
|
||||
"write",
|
||||
"Agent configuration could not be updated. Check file permissions, then rerun setup.",
|
||||
],
|
||||
[
|
||||
"readback",
|
||||
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
||||
],
|
||||
] as const)("explains %s configuration recovery", async (reason, message) => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "internal_client_key", configPath: "/agent.json" }];
|
||||
host.clientResults.set("internal_client_key", { status: "failed", reason });
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(message);
|
||||
expect(result.remediation).not.toContain("internal_client_key");
|
||||
});
|
||||
|
||||
it("explains skill installation recovery", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.skill = "failed";
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.remediation).toBe(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates remaining unhealthy checks to doctor remediation", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.doctorHealthy = false;
|
||||
const result = await runSetup(options(true), host);
|
||||
expect(result.remediation).toBe(
|
||||
"Run rea doctor and apply each reported remediation.",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains unsupported Node and macOS recovery", async () => {
|
||||
const platformHost = new FakeSetupHost("win32");
|
||||
expect((await runSetup(options(true), platformHost)).remediation).toBe(
|
||||
"REA supports Hopper on macOS and selected 64-bit Linux distributions.",
|
||||
);
|
||||
|
||||
const nodeHost = new FakeSetupHost();
|
||||
nodeHost.nodeVersion = "20.0.0";
|
||||
expect((await runSetup(options(true), nodeHost)).remediation).toBe(
|
||||
"Install Node.js 22.19+ or 24.11+ and rerun setup.",
|
||||
);
|
||||
|
||||
const macHost = new FakeSetupHost();
|
||||
macHost.version = "11.7";
|
||||
expect((await runSetup(options(true), macHost)).remediation).toBe(
|
||||
"Upgrade to macOS 12 or newer.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported hosts before mutation", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
|
|
@ -166,5 +242,8 @@ describe("setup workflow", () => {
|
|||
const result = await runSetup(options(true, true), host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(result.remediation).toBe(
|
||||
"REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
|
@ -10,6 +18,7 @@ import {
|
|||
import {
|
||||
runUninstall,
|
||||
systemUninstallHost,
|
||||
type UninstallFileSystem,
|
||||
} from "../src/application/Uninstall.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
|
@ -110,7 +119,12 @@ describe("agent lifecycle", () => {
|
|||
mcpServers: { other: { command: "other" } },
|
||||
});
|
||||
expect(first.items).toContainEqual(
|
||||
expect.objectContaining({ name: "cache", status: "retained" }),
|
||||
expect.objectContaining({
|
||||
name: "cache",
|
||||
status: "retained",
|
||||
detail:
|
||||
"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",
|
||||
}),
|
||||
);
|
||||
expect((await runUninstall(true, systemUninstallHost(home))).status).toBe(
|
||||
"complete",
|
||||
|
|
@ -123,9 +137,111 @@ describe("agent lifecycle", () => {
|
|||
const config = join(home, ".cursor/mcp.json");
|
||||
await mkdir(dirname(config), { recursive: true });
|
||||
await writeFile(config, "not-json");
|
||||
expect((await runUninstall(false, systemUninstallHost(home))).status).toBe(
|
||||
"failed",
|
||||
const result = await runUninstall(false, systemUninstallHost(home));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.items).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "cursor",
|
||||
status: "failed",
|
||||
detail:
|
||||
"Configuration is not valid JSON and was not changed. Repair it, then rerun uninstall.",
|
||||
}),
|
||||
);
|
||||
expect(await readFile(config, "utf8")).toBe("not-json");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"read",
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
],
|
||||
[
|
||||
"backup",
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
],
|
||||
[
|
||||
"update",
|
||||
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
||||
],
|
||||
[
|
||||
"restore",
|
||||
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
||||
],
|
||||
] as const)(
|
||||
"reports an actionable client %s failure",
|
||||
async (failure, detail) => {
|
||||
const { home, config } = await uninstallFixture();
|
||||
let writes = 0;
|
||||
const fileSystem: UninstallFileSystem = {
|
||||
...testFileSystem,
|
||||
readText: (path) =>
|
||||
failure === "read" && path === config
|
||||
? Promise.reject(new Error("SECRET read failure"))
|
||||
: readFile(path, "utf8"),
|
||||
copy: (source, destination) =>
|
||||
failure === "backup"
|
||||
? Promise.reject(new Error("SECRET backup failure"))
|
||||
: copyFile(source, destination),
|
||||
writeText: async (path, contents) => {
|
||||
writes += 1;
|
||||
if ((failure === "update" && writes === 1) || failure === "restore")
|
||||
throw new Error("SECRET write failure");
|
||||
await writeFile(path, contents);
|
||||
},
|
||||
};
|
||||
const result = await runUninstall(
|
||||
false,
|
||||
systemUninstallHost(home, fileSystem),
|
||||
);
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.items).toContainEqual(
|
||||
expect.objectContaining({ name: "cursor", status: "failed", detail }),
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a managed-path removal failure", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-remove-"));
|
||||
roots.push(home);
|
||||
const skillRoot = join(home, ".agents/skills/rea-analysis");
|
||||
await mkdir(skillRoot, { recursive: true });
|
||||
const result = await runUninstall(
|
||||
false,
|
||||
systemUninstallHost(home, {
|
||||
...testFileSystem,
|
||||
remove: () => Promise.reject(new Error("SECRET removal failure")),
|
||||
}),
|
||||
);
|
||||
expect(result.items).toContainEqual({
|
||||
name: "skill",
|
||||
status: "failed",
|
||||
detail:
|
||||
"This item could not be removed. Check file permissions, then rerun uninstall.",
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("SECRET");
|
||||
});
|
||||
});
|
||||
|
||||
const testFileSystem: UninstallFileSystem = {
|
||||
readText: (path) => readFile(path, "utf8"),
|
||||
copy: (source, destination) => copyFile(source, destination),
|
||||
writeText: (path, contents) => writeFile(path, contents),
|
||||
stat: (path) => lstat(path),
|
||||
remove: (path) => rm(path, { recursive: true }),
|
||||
};
|
||||
|
||||
const uninstallFixture = async (): Promise<{
|
||||
readonly home: string;
|
||||
readonly config: string;
|
||||
}> => {
|
||||
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-failure-"));
|
||||
roots.push(home);
|
||||
const config = join(home, ".cursor/mcp.json");
|
||||
await mkdir(dirname(config), { recursive: true });
|
||||
await writeFile(
|
||||
config,
|
||||
JSON.stringify({ mcpServers: { rea: { command: "rea", args: ["mcp"] } } }),
|
||||
);
|
||||
return { home, config };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,9 +26,16 @@ describe("tool result projection", () => {
|
|||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "AnalysisOutputError: Analysis output does not match the tool contract",
|
||||
text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: {
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ describe("CLI upgrade", () => {
|
|||
expect(await runUpgrade("0.5.0", host)).toMatchObject({
|
||||
status: "failed",
|
||||
reason: "install",
|
||||
remediation:
|
||||
"REA could not update through npm. Check npm registry access and global install permissions, then run: npm install --global rea-agents@latest",
|
||||
});
|
||||
expect(host.installs).toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue