rea-agents 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +124 -20
  2. package/bridge/hopper_bridge.py +141 -13
  3. package/dist/application/AnalysisProvider.js +12 -1
  4. package/dist/application/ArtifactExtraction.js +166 -0
  5. package/dist/application/ArtifactGraphConstruction.js +257 -0
  6. package/dist/application/ArtifactInventory.js +253 -0
  7. package/dist/application/BinarySession.js +164 -14
  8. package/dist/application/CompositeProvider.js +73 -0
  9. package/dist/application/DirectAnalysis.js +27 -3
  10. package/dist/application/Doctor.js +35 -6
  11. package/dist/application/EnhancedTools.js +8 -7
  12. package/dist/application/EvidenceBundleCommands.js +29 -0
  13. package/dist/application/EvidenceBundleFiles.js +127 -0
  14. package/dist/application/EvidenceLedger.js +225 -18
  15. package/dist/application/FilesystemSnapshot.js +124 -0
  16. package/dist/application/LinuxHopper.js +186 -0
  17. package/dist/application/LoopbackReplay.js +195 -37
  18. package/dist/application/ProcessHarness.js +200 -191
  19. package/dist/application/ProcessNormalization.js +44 -0
  20. package/dist/application/ProcessOwnership.js +84 -0
  21. package/dist/application/ProcessSampling.js +284 -0
  22. package/dist/application/RealHopperAssertions.js +105 -0
  23. package/dist/application/ReferenceSourceImport.js +182 -0
  24. package/dist/application/ReferenceSourceImportEntries.js +122 -0
  25. package/dist/application/ReferenceSourceImportPolicy.js +73 -0
  26. package/dist/application/ReferenceSourceImportTypes.js +18 -0
  27. package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
  28. package/dist/application/Setup.js +192 -41
  29. package/dist/application/Uninstall.js +130 -0
  30. package/dist/application/runtime.js +8 -1
  31. package/dist/artifacts/ArtifactPaths.js +51 -0
  32. package/dist/artifacts/ArtifactProvider.js +130 -0
  33. package/dist/artifacts/ArtifactReader.js +9 -0
  34. package/dist/artifacts/AsarArtifactReader.js +62 -0
  35. package/dist/artifacts/DirectoryArtifactReader.js +107 -0
  36. package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
  37. package/dist/artifacts/SafeOutputTree.js +199 -0
  38. package/dist/artifacts/StreamBytes.js +10 -0
  39. package/dist/artifacts/ZipArtifactReader.js +109 -0
  40. package/dist/cli.js +153 -6
  41. package/dist/config.js +35 -1
  42. package/dist/contracts/artifactComparisonExample.js +95 -0
  43. package/dist/contracts/artifactToolContracts.js +84 -0
  44. package/dist/contracts/enhancedInputs.js +4 -0
  45. package/dist/contracts/functionComparisonExample.js +57 -0
  46. package/dist/contracts/investigationExamples.js +118 -0
  47. package/dist/contracts/nativeToolContracts.js +52 -0
  48. package/dist/contracts/processCaptureExample.js +25 -0
  49. package/dist/contracts/toolContractExamples.js +69 -0
  50. package/dist/contracts/toolContracts.js +99 -36
  51. package/dist/contracts/toolOutputSchemas.js +154 -82
  52. package/dist/contracts/unknownContractExamples.js +33 -0
  53. package/dist/domain/artifactComparison.js +273 -0
  54. package/dist/domain/artifactGraph.js +194 -0
  55. package/dist/domain/artifactInventoryEvidence.js +150 -0
  56. package/dist/domain/binaryTarget.js +55 -0
  57. package/dist/domain/bundleComparison.js +266 -0
  58. package/dist/domain/callPath.js +346 -0
  59. package/dist/domain/changedBehavior.js +294 -0
  60. package/dist/domain/errors.js +192 -4
  61. package/dist/domain/evidence.js +41 -10
  62. package/dist/domain/evidenceBundle.js +187 -6
  63. package/dist/domain/functionComparison.js +201 -0
  64. package/dist/domain/functionComparisonNormalization.js +112 -0
  65. package/dist/domain/functionComparisonResults.js +54 -0
  66. package/dist/domain/functionComparisonSchemas.js +82 -0
  67. package/dist/domain/functionDossierEvidence.js +171 -0
  68. package/dist/domain/hopperValues.js +14 -7
  69. package/dist/domain/nativeInspection.js +142 -0
  70. package/dist/domain/processCapture.js +152 -54
  71. package/dist/domain/processComparison.js +106 -0
  72. package/dist/domain/reconstructionUnknowns.js +90 -0
  73. package/dist/domain/reconstructionVerification.js +285 -0
  74. package/dist/domain/reconstructionVerificationSchemas.js +126 -0
  75. package/dist/domain/referenceSourceClassification.js +496 -0
  76. package/dist/domain/referenceSourceGraph.js +376 -0
  77. package/dist/domain/referenceSourceImportParsing.js +235 -0
  78. package/dist/domain/residualUnknown.js +239 -0
  79. package/dist/domain/staticRuntimeCorrelation.js +375 -0
  80. package/dist/hopper/BridgeLauncher.js +40 -3
  81. package/dist/hopper/HopperClient.js +14 -5
  82. package/dist/hopper/HopperProvider.js +57 -22
  83. package/dist/identity.js +1 -0
  84. package/dist/main.js +5 -1
  85. package/dist/native/CommandRunner.js +156 -0
  86. package/dist/native/NativeMacOSProvider.js +306 -0
  87. package/dist/native/NativeMachoInspection.js +135 -0
  88. package/dist/native/parsers/codesign.js +55 -0
  89. package/dist/native/parsers/demangle.js +26 -0
  90. package/dist/native/parsers/dyldInfo.js +25 -0
  91. package/dist/native/parsers/lipo.js +67 -0
  92. package/dist/native/parsers/otool.js +193 -0
  93. package/dist/native/parsers/plist.js +23 -0
  94. package/dist/reference/ReferenceSourceReader.js +73 -0
  95. package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
  96. package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
  97. package/dist/reference/ReferenceSourceReaderFile.js +119 -0
  98. package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
  99. package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
  100. package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
  101. package/dist/server/createServer.js +31 -5
  102. package/dist/server/recordDerivedEvidence.js +10 -0
  103. package/dist/server/registerArtifactComparisonTool.js +62 -0
  104. package/dist/server/registerArtifactTools.js +6 -0
  105. package/dist/server/registerBundleComparisonTool.js +47 -0
  106. package/dist/server/registerEnhancedTools.js +64 -12
  107. package/dist/server/registerEvidenceTools.js +36 -0
  108. package/dist/server/registerFunctionComparisonTool.js +68 -0
  109. package/dist/server/registerInvestigationTools.js +224 -0
  110. package/dist/server/registerNativeTools.js +6 -0
  111. package/dist/server/registerOfficialTools.js +47 -14
  112. package/dist/server/registerProcessComparisonTool.js +106 -0
  113. package/dist/server/registerSessionTools.js +179 -70
  114. package/dist/server/sessionEvidence.js +28 -0
  115. package/dist/server/sessionToolPolicies.js +64 -0
  116. package/dist/server/toolRegistrationOptions.js +7 -0
  117. package/dist/server/toolResult.js +8 -5
  118. package/install.sh +198 -0
  119. package/package.json +19 -2
  120. package/scripts/rea.mjs +5 -1
  121. package/skills/rea-analysis/SKILL.md +77 -2
  122. /package/dist/{application/HopperToolPort.js → domain/referenceSourcePolicy.js} +0 -0
package/README.md CHANGED
@@ -10,15 +10,15 @@
10
10
 
11
11
  [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
13
- [![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-tools-for-investigation)
13
+ [![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-tools-for-investigation)
14
14
  [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
15
15
  [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
16
16
 
17
- [Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [50 tools](#50-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
17
+ [Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [68 tools](#68-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
18
18
 
19
19
  <br />
20
20
 
21
- <code>npx skills add morluto/rea</code>
21
+ <code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
22
22
 
23
23
  </div>
24
24
 
@@ -41,8 +41,8 @@ npx skills add morluto/rea
41
41
  Then ask:
42
42
 
43
43
  ```text
44
- Set up REA and reverse engineer the Notes app. Explain how search works,
45
- show me how you know, and build a similar feature for my project.
44
+ Use REA to understand how search works in the Notes app, show me the
45
+ evidence, and build a similar feature for my project.
46
46
  ```
47
47
 
48
48
  Notes is only an example. Name any app you want to understand, or ask the agent to start with an overview.
@@ -81,6 +81,16 @@ REA shows how it reached its conclusions. It does not claim to recover original
81
81
 
82
82
  ## Quick start
83
83
 
84
+ ### One-command install — recommended
85
+
86
+ ```bash
87
+ curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
88
+ ```
89
+
90
+ The installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. It installs compatible Node.js 24/npm 11 prerequisites, the exact latest `rea-agents-*` release, the official Hopper package, detected client registrations, and the bundled skill, then verifies the result. macOS uses Homebrew; Linux uses the native package manager and may show a system authorization prompt. Pin a reproducible release with `REA_VERSION=0.3.0`; `REA_VERSION` is the only supported installer environment override.
91
+
92
+ REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It configures detected clients with documented local MCP files; Devin is reported as skipped because its public documentation does not define a local configuration file. You can safely rerun the installer or `rea setup --yes`. If REA's local analysis engine needs one-time activation, installation remains intact and setup reports the exact remaining action rather than claiming full readiness.
93
+
84
94
  ### With a coding agent — recommended
85
95
 
86
96
  ```bash
@@ -89,22 +99,36 @@ npx skills add morluto/rea
89
99
 
90
100
  Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset.
91
101
 
92
- ### From Terminal
102
+ Approve installation if needed, complete the one-time activation when prompted, then describe the app or feature you want to understand. REA handles the binary-analysis tools behind the scenes.
103
+
104
+ ### From Terminal — no installation
93
105
 
94
106
  ```bash
95
107
  npx -y rea-agents setup --yes
108
+ npx -y rea-agents doctor
109
+ npx -y rea-agents analyze /Applications/Notes.app
96
110
  ```
97
111
 
98
112
  If macOS or an installer asks for confirmation, complete the prompt and run the same command again. Restart a configured coding agent so it loads REA.
99
113
 
100
- ### What setup handles
114
+ ### From Terminal — install the `rea` command
101
115
 
102
- - macOS 12 or newer
103
- - Node.js 24.18.x with npm 11.16.x (`nvm use` selects the pinned version)
116
+ ```bash
117
+ npm install --global rea-agents
118
+ rea setup --yes
119
+ rea doctor
120
+ rea analyze /Applications/Notes.app
121
+ ```
122
+
123
+ Choose either the no-install commands or the global installation. You do not need both.
124
+
125
+ ### Requirements
104
126
 
105
- If process capture reports that its native PTY backend is unavailable, install Xcode command-line tools and run `npm run rebuild:native`. Linux source builds require Python, `make`, and a C++ toolchain. Compatible packaged binaries do not require this rebuild.
127
+ - macOS 12 or newer
128
+ - Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux
129
+ - Node.js 24.18.x with npm 11.16.x
106
130
 
107
- You do not need to install the reverse-engineering tools manually. Setup installs Homebrew and [Hopper](https://www.hopperapp.com/) when needed, configures detected Claude Desktop and Cursor installations, and installs the REA skill. Hopper is separate software and requires its own license; setup installs it but does not provide a license.
131
+ You do not need to choose or install binary-analysis tools yourself. REA setup handles them when needed. Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. REA can install the official macOS or Linux package, but you must approve installation and complete its one-time activation.
108
132
 
109
133
  If something is not working, run:
110
134
 
@@ -112,12 +136,63 @@ If something is not working, run:
112
136
  npx -y rea-agents doctor
113
137
  ```
114
138
 
139
+ `rea doctor --json` is read-only and distinguishes unsupported hosts, missing dependencies, a missing local analysis engine, configuration drift, and healthy checks. Fresh-install activation is reported by setup because Hopper does not expose a reliable noninteractive activation probe.
140
+
141
+ ### Linux installation and troubleshooting
142
+
143
+ On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
144
+
145
+ The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere:
146
+
147
+ ```bash
148
+ export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
149
+ rea doctor --json
150
+ ```
151
+
152
+ If doctor reports a missing analysis engine even though the file exists, inspect shared-library resolution with:
153
+
154
+ ```bash
155
+ ldd /opt/hopper/bin/Hopper | grep 'not found'
156
+ ```
157
+
158
+ Install the missing distribution packages and rerun `rea setup --yes`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
159
+
160
+ REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
161
+
162
+ To remove only REA-owned MCP registrations and the managed skill:
163
+
164
+ ```bash
165
+ rea uninstall
166
+ rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
167
+ ```
168
+
169
+ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
170
+
115
171
  ### CLI or coding agent?
116
172
 
117
- | If you want to… | Use |
118
- | --------------------------------------------------------- | ------------------------------------------ |
119
- | Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
120
- | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
173
+ | If you want to… | Use |
174
+ | --------------------------------------------------------- | ---------------------------------------------- |
175
+ | Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
176
+ | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
177
+ | Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` |
178
+ | Import source as historical reference | `rea import-reference-source` |
179
+
180
+ Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
181
+
182
+ ```bash
183
+ export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
184
+ rea evidence-import /absolute/path/to/evidence/bundle.json
185
+ rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
186
+ ```
187
+
188
+ Historical source import requires a separate allowlist and never treats source as current behavioral authority:
189
+
190
+ ```bash
191
+ export REA_REFERENCE_ROOTS_JSON='["/absolute/path/to/source"]'
192
+ rea import-reference-source /absolute/path/to/source
193
+ ```
194
+
195
+ 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.
121
196
 
122
197
  ## One prompt, a full investigation
123
198
 
@@ -151,26 +226,38 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
151
226
  - Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
152
227
  - Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
153
228
 
154
- ## 50 tools for investigation
229
+ ## 68 tools for investigation
155
230
 
156
231
  | Tool family | Count | Examples |
157
232
  | ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------- |
158
233
  | Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
159
234
  | Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
160
- | Workspace and observation | 7 | target lifecycle, Evidence v2 bundle import/export, deterministic process capture and comparison |
235
+ | Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
236
+ | Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
237
+ | Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
161
238
 
162
- The public interface describes what the agent is trying to learn. Providers decide how to answer. Hopper currently implements the native-analysis capabilities; the process harness implements controlled behavioral capture. Future providers can satisfy the same capability without changing the agent's investigation workflow.
239
+ The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture.
163
240
 
164
241
  ## Current status
165
242
 
166
243
  REA is already useful for native application investigation on macOS:
167
244
 
168
- - Open Mach-O, ELF, PE, `.app`, and Hopper database targets.
245
+ - Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets.
246
+ - Traverse content-addressed artifact graphs without extraction; materialize only approved occurrences into absent output roots.
169
247
  - Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
170
248
  - Search and trace features across symbols, strings, metadata, references, and call paths.
171
249
  - Record every successful result as deterministic Evidence v2 with artifact and provider identity, confidence, authority, limitations, and locations.
172
250
  - Export and import evidence bundles across sessions.
173
251
  - Capture approved PTY scenarios, child processes, filesystem changes, and loopback HTTP/WebSocket exchanges, then compare normalized captures.
252
+ - Compare complete artifact inventories by stable path, content, metadata, and relations; incomplete evidence never implies equivalence.
253
+ - Compare explicit function dossiers across text, calls, references, strings, and address-normalized CFG topology with per-facet unknowns.
254
+ - Compare canonical Evidence bundles by exact membership, explicit observation pairs, and residual-unknown histories without turning omissions into behavioral absence.
255
+ - Aggregate runtime comparisons into observed behavior changes while keeping static artifact/function differences labeled as candidates.
256
+ - Build bounded, Evidence-cited direct call paths by exact address without treating missing dossiers as graph leaves.
257
+ - Correlate exact static/runtime findings through explicit hypotheses without claiming causality from cochange.
258
+ - Verify finite behavioral and structural reconstruction specifications with pass, fail, and unknown kept distinct.
259
+ - Track residual unknowns through immutable CAS revisions, evidence-qualified resolution, contradictions, probes, and validated dependency relationships.
260
+ - With explicit `unknown_registry_approved: true`, record bounded trace/capture residuals, typed provider unavailability, and capture disagreements automatically.
174
261
 
175
262
  Hopper is the first provider, not the boundary of the project. Some current workflows still require Hopper and macOS; every evidence record identifies the provider and limitations behind its result.
176
263
 
@@ -215,10 +302,14 @@ flowchart LR
215
302
  REA --> Workspace["Investigation workspace<br/>evidence + artifacts + captures"]
216
303
  Workspace --> Router["Capability router"]
217
304
  Router --> Hopper["Hopper provider"]
305
+ Router --> Native["Native macOS provider"]
306
+ Router --> Artifact["Artifact graph provider"]
218
307
  Router --> Process["Process capture provider"]
219
- Router -. roadmap .-> More["Artifact, browser, dynamic,<br/>and additional static providers"]
308
+ Router -. roadmap .-> More["Browser, dynamic,<br/>and additional static providers"]
220
309
  Hopper --> Target["Target software"]
221
310
  Process --> Target
311
+ Native --> Target
312
+ Artifact --> Target
222
313
  ```
223
314
 
224
315
  The CLI and MCP server use the same application workflows and evidence contracts. A provider declares which capabilities it supports and the side effects those capabilities may have. Terminal commands are short-lived; an MCP session can retain an active target and evidence ledger across an investigation.
@@ -251,6 +342,19 @@ REA derives explicit format and architecture arguments to prevent common FAT and
251
342
 
252
343
  Closing a REA session shuts down its bridge and removes its private socket directory. It does not quit a Hopper application the user may be using.
253
344
 
345
+ ## Advanced process-capture setup
346
+
347
+ Process capture is disabled by default. Enabling it requires
348
+ `REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in
349
+ `REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an
350
+ environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY
351
+ adapter uses host networking, it also requires
352
+ `REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`.
353
+
354
+ If the native PTY backend is unavailable, install Xcode command-line tools and
355
+ run `npm run rebuild:native`. Linux source builds require Python, `make`, and a
356
+ C++ toolchain. Compatible packaged binaries do not require this rebuild.
357
+
254
358
  ## Security model
255
359
 
256
360
  REA does not provide a hosted analysis service. Hopper communication uses an authenticated private local socket. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. REA is not a security sandbox: providers and launched targets run with the current user's permissions, and each capability reports its side effects and limitations. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
@@ -9,11 +9,15 @@ import json
9
9
  import hmac
10
10
  import os
11
11
  import re
12
+ import sre_parse
12
13
  import socket
13
14
 
14
15
  MAX_LINE_BYTES = 10 * 1024 * 1024
15
16
  BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None)
16
17
  _selected_document = None
18
+ _search_inventory_cache = {}
19
+ MAX_SEARCH_PATTERN_LENGTH = 256
20
+ MAX_SEARCH_VALUE_LENGTH = 4096
17
21
 
18
22
 
19
23
  def _hex(value):
@@ -89,6 +93,17 @@ def _procedure_identity(procedure):
89
93
  return {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure)}
90
94
 
91
95
 
96
+ def _procedure_locals(procedure):
97
+ """Project opaque Hopper local-variable objects into an exact public shape."""
98
+ return [
99
+ {
100
+ "description": str(local),
101
+ "provenance": "hopper-public-python-api",
102
+ }
103
+ for local in procedure.getLocalVariableList()
104
+ ]
105
+
106
+
92
107
  def _containing_procedure(document, address):
93
108
  segment = document.getSegmentAtAddress(address)
94
109
  if segment is None:
@@ -182,6 +197,105 @@ def _strings(document):
182
197
  return result
183
198
 
184
199
 
200
+ def _invalidate_search_inventory(document):
201
+ """Discard derived names after analysis metadata changes."""
202
+ document_id = id(document)
203
+ for key in list(_search_inventory_cache):
204
+ if key[0] == document_id:
205
+ del _search_inventory_cache[key]
206
+
207
+
208
+ def _search_inventory(document, kind):
209
+ """Cache an immutable, address-sorted inventory for an unchanged document."""
210
+ key = (id(document), kind)
211
+ inventory = _search_inventory_cache.get(key)
212
+ if inventory is None:
213
+ values = _procedure_map(document) if kind == "procedure" else _strings(document)
214
+ inventory = tuple(sorted(values.items(), key=lambda item: int(item[0], 16)))
215
+ _search_inventory_cache[key] = inventory
216
+ return inventory
217
+
218
+
219
+ def _validate_regex_node(node, inside_repeat=False):
220
+ """Reject regex structures with disproportionate or non-local evaluation cost."""
221
+ forbidden = {
222
+ sre_parse.ASSERT,
223
+ sre_parse.ASSERT_NOT,
224
+ sre_parse.GROUPREF,
225
+ sre_parse.GROUPREF_EXISTS,
226
+ }
227
+ repeat_tokens = {sre_parse.MAX_REPEAT, sre_parse.MIN_REPEAT}
228
+ possessive = getattr(sre_parse, "POSSESSIVE_REPEAT", None)
229
+ if possessive is not None:
230
+ repeat_tokens.add(possessive)
231
+ for operation, argument in node:
232
+ if operation in forbidden:
233
+ raise ValueError("Regex lookarounds and backreferences are not supported")
234
+ if operation in repeat_tokens:
235
+ if inside_repeat:
236
+ raise ValueError("Nested regex repetitions are not supported")
237
+ minimum, maximum, child = argument
238
+ if maximum == sre_parse.MAXREPEAT or maximum > 1000:
239
+ raise ValueError("Unbounded or excessive regex repetitions are not supported")
240
+ _validate_regex_node(child, True)
241
+ elif operation == sre_parse.SUBPATTERN:
242
+ _validate_regex_node(argument[-1], inside_repeat)
243
+ elif operation == sre_parse.BRANCH:
244
+ for branch in argument[1]:
245
+ _validate_regex_node(branch, inside_repeat)
246
+
247
+
248
+ def _search_page(document, kind, params):
249
+ pattern = params.get("pattern")
250
+ if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_SEARCH_PATTERN_LENGTH:
251
+ raise ValueError("pattern must contain between 1 and 256 characters")
252
+ mode = params.get("mode", "literal")
253
+ if mode not in ("literal", "regex"):
254
+ raise ValueError("mode must be literal or regex")
255
+ case_sensitive = params.get("case_sensitive", False)
256
+ if not isinstance(case_sensitive, bool):
257
+ raise ValueError("case_sensitive must be a boolean")
258
+ offset = params.get("offset", 0)
259
+ limit = params.get("limit", 100)
260
+ if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
261
+ raise ValueError("offset must be a non-negative integer")
262
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100:
263
+ raise ValueError("limit must be an integer between 1 and 100")
264
+
265
+ if mode == "literal":
266
+ needle = pattern if case_sensitive else pattern.casefold()
267
+ matches = lambda value: needle in (value if case_sensitive else value.casefold())
268
+ else:
269
+ try:
270
+ parsed = sre_parse.parse(pattern)
271
+ _validate_regex_node(parsed)
272
+ expression = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
273
+ except re.error as error:
274
+ raise ValueError("Invalid regex pattern") from error
275
+ matches = lambda value: expression.search(value) is not None
276
+
277
+ matching = [item for item in _search_inventory(document, kind) if matches(item[1])]
278
+ selected = matching[offset:offset + limit]
279
+ total = len(matching)
280
+ next_offset = offset + len(selected)
281
+ has_more = next_offset < total
282
+ return {
283
+ "items": [
284
+ {
285
+ "address": address,
286
+ "value": value[:MAX_SEARCH_VALUE_LENGTH],
287
+ "value_truncated": len(value) > MAX_SEARCH_VALUE_LENGTH,
288
+ }
289
+ for address, value in selected
290
+ ],
291
+ "offset": offset,
292
+ "limit": limit,
293
+ "total": total,
294
+ "next_offset": next_offset if has_more else None,
295
+ "has_more": has_more,
296
+ }
297
+
298
+
185
299
  def _page(values, offset, limit):
186
300
  """Return one deterministically address-sorted page without crossing an unbounded map."""
187
301
  if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
@@ -354,7 +468,7 @@ def _analyze_function(document, params):
354
468
  def collection(name, items, scan_limited=False):
355
469
  return _bounded(items, _collection_offset(params, name), limit, None, scan_limited)
356
470
  return {
357
- "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())},
471
+ "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _procedure_locals(procedure)},
358
472
  "pseudocode": {"text": pseudo_text, "total_chars": len(pseudo), "returned_chars": len(pseudo_text), "truncated": pseudo_next < len(pseudo), "next_offset": pseudo_next if pseudo_next < len(pseudo) else None},
359
473
  "assembly": _bounded(assembly_lines, assembly_offset, max_instructions),
360
474
  "comments": collection("comments", comments, instruction_scan_truncated),
@@ -372,7 +486,7 @@ def _dispatch(method, params):
372
486
  """Dispatch only the closed operation set implemented by REA's public tools."""
373
487
  global _selected_document
374
488
  if method == "health":
375
- return {"name": "REA Hopper bridge", "version": "1.0.0"}
489
+ return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
376
490
  if method == "shutdown":
377
491
  return {"shutdown": True}
378
492
  if method == "list_documents":
@@ -426,18 +540,30 @@ def _dispatch(method, params):
426
540
  return _hex(result)
427
541
  if method == "list_segments":
428
542
  result = []
543
+ permission_limitation = _unavailable(
544
+ "Hopper's public Python API does not expose segment or section permissions"
545
+ )
429
546
  for segment in document.getSegmentsList():
430
547
  start = segment.getStartingAddress()
431
- sections = [{"name": section.getName(), "start": _hex(section.getStartingAddress()), "end": _hex(section.getStartingAddress() + section.getLength())} for section in segment.getSectionsList()]
548
+ sections = [{
549
+ "name": section.getName(),
550
+ "start": _hex(section.getStartingAddress()),
551
+ "end": _hex(section.getStartingAddress() + section.getLength()),
552
+ "readable": None,
553
+ "writable": None,
554
+ "executable": None,
555
+ "permissions": permission_limitation,
556
+ "provenance": "hopper-public-python-api",
557
+ } for section in segment.getSectionsList()]
432
558
  result.append({
433
559
  "name": segment.getName(),
434
560
  "start": _hex(start),
435
561
  "end": _hex(start + segment.getLength()),
562
+ "readable": None,
436
563
  "writable": None,
437
564
  "executable": None,
438
- "permissions": _unavailable(
439
- "Hopper's public Python API does not expose segment permissions"
440
- ),
565
+ "permissions": permission_limitation,
566
+ "provenance": "hopper-public-python-api",
441
567
  "sections": sections,
442
568
  })
443
569
  return result
@@ -461,10 +587,8 @@ def _dispatch(method, params):
461
587
  result = {key: result[key]} if key in result else {}
462
588
  return _page(result, params.get("offset", 0), params.get("limit", 100))
463
589
  if method in ("search_procedures", "search_strings"):
464
- flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE
465
- expression = re.compile(params["pattern"], flags)
466
- values = _procedure_map(document) if method == "search_procedures" else _strings(document)
467
- return {key: value for key, value in values.items() if expression.search(value)}
590
+ kind = "procedure" if method == "search_procedures" else "string"
591
+ return _search_page(document, kind, params)
468
592
  if method.startswith("procedure_"):
469
593
  procedure = _procedure(document, params.get("procedure"))
470
594
  if method == "procedure_address":
@@ -480,12 +604,16 @@ def _dispatch(method, params):
480
604
  if method == "procedure_info":
481
605
  blocks = list(procedure.basicBlockIterator())
482
606
  length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks)
483
- return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": procedure.getLocalVariableList()}
607
+ return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": _procedure_locals(procedure)}
484
608
  if method == "set_address_name":
485
609
  address = _address(document, params.get("address"))
486
- return document.setNameAtAddress(address, params["name"])
610
+ result = document.setNameAtAddress(address, params["name"])
611
+ _invalidate_search_inventory(document)
612
+ return result
487
613
  if method == "set_addresses_names":
488
- return {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
614
+ result = {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
615
+ _invalidate_search_inventory(document)
616
+ return result
489
617
  if method in ("set_comment", "set_inline_comment"):
490
618
  address = _address(document, params.get("address"))
491
619
  segment = _segment(document, address)
@@ -1 +1,12 @@
1
- export {};
1
+ import { jsonValueSchema } from "../domain/jsonValue.js";
2
+ /** Build a validated atomic provider observation at an adapter boundary. */
3
+ export const createAnalysisExecution = (result, provider, options = {}) => ({
4
+ result: jsonValueSchema.parse(result),
5
+ rawResult: options.rawResult === undefined
6
+ ? jsonValueSchema.parse(result)
7
+ : jsonValueSchema.parse(options.rawResult),
8
+ provider,
9
+ limitations: [...(options.limitations ?? [])],
10
+ locations: [...(options.locations ?? [])],
11
+ subject: options.subject ?? null,
12
+ });
@@ -0,0 +1,166 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js";
3
+ import { ArtifactPathRegistry, normalizeArtifactPath, } from "../artifacts/ArtifactPaths.js";
4
+ import { ArtifactReaderFailure, } from "../artifacts/ArtifactReader.js";
5
+ import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
6
+ import { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
7
+ import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
8
+ import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
9
+ import { artifactExtractionResultSchema, } from "../domain/artifactGraph.js";
10
+ import { scanArtifactInventory } from "./ArtifactInventory.js";
11
+ import { digestCanonical, pageOf, toOutputLimits, } from "./ArtifactGraphConstruction.js";
12
+ const PAGE_SIZE = 500;
13
+ /** Extract selected inventory occurrences into an exclusively owned absent root. */
14
+ export const extractArtifact = async (input, signal) => {
15
+ validateSelection(input.occurrenceIds);
16
+ const sourcePath = await realpath(input.inputPath);
17
+ const selectedIds = new Set(input.occurrenceIds);
18
+ const inventory = await loadInventory(sourcePath, input.limits, selectedIds, signal);
19
+ const selected = input.occurrenceIds.map((id) => {
20
+ const occurrence = inventory.occurrences.get(id);
21
+ if (occurrence === undefined)
22
+ throw new ArtifactReaderFailure("unavailable", `Selected artifact occurrence was not found: ${id}`);
23
+ if ((occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
24
+ occurrence.artifact_id === null ||
25
+ occurrence.encrypted ||
26
+ occurrence.logical_path === ".")
27
+ throw new ArtifactReaderFailure("format", `Selected occurrence is not an extractable regular child file: ${id}`);
28
+ const node = inventory.nodes.get(occurrence.artifact_id);
29
+ if (node === undefined)
30
+ throw new ArtifactReaderFailure("integrity", `Selected occurrence has no inventory node: ${id}`);
31
+ return { occurrence, node };
32
+ });
33
+ return materializeSelection({
34
+ input,
35
+ sourcePath,
36
+ inventory,
37
+ selected,
38
+ signal,
39
+ });
40
+ };
41
+ const materializeSelection = async ({ input, sourcePath, inventory, selected, signal, }) => {
42
+ const byPath = new Map(selected.map((item) => [item.occurrence.logical_path, item]));
43
+ const reader = await createReader(sourcePath, input.inputFormat);
44
+ const output = await SafeOutputTree.create(input.outputRoot, input.limits);
45
+ let readerClosed = false;
46
+ const extracted = [];
47
+ try {
48
+ const found = new Set();
49
+ const registry = new ArtifactPathRegistry();
50
+ let entryCount = 0;
51
+ for await (const entry of reader.entries(signal)) {
52
+ entryCount += 1;
53
+ if (entryCount > input.limits.maxEntries)
54
+ throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded during extraction");
55
+ const path = normalizeArtifactPath(entry.path, input.limits);
56
+ registry.add(path, entry.kind);
57
+ const selectedItem = byPath.get(path);
58
+ if (selectedItem === undefined)
59
+ continue;
60
+ preflight(entry, input.limits);
61
+ const stream = await reader.open(entry, signal);
62
+ const written = await output.write(path, stream, selectedItem.node.sha256, signal);
63
+ extracted.push({
64
+ artifact_id: selectedItem.node.artifact_id,
65
+ relative_path: written.relativePath,
66
+ sha256: written.sha256,
67
+ bytes_written: written.bytesWritten,
68
+ created: true,
69
+ });
70
+ found.add(path);
71
+ }
72
+ if (found.size !== selected.length)
73
+ throw new ArtifactReaderFailure("integrity", "Selected inventory occurrence disappeared before extraction");
74
+ await reader.close();
75
+ readerClosed = true;
76
+ extracted.sort((left, right) => left.relative_path.localeCompare(right.relative_path, "en"));
77
+ const result = createExtractionResult(input, inventory, selected, extracted);
78
+ await output.commit();
79
+ return result;
80
+ }
81
+ catch (cause) {
82
+ if (!readerClosed)
83
+ await reader.close().catch(() => undefined);
84
+ await output.rollback();
85
+ throw cause;
86
+ }
87
+ };
88
+ const createExtractionResult = (input, inventory, selected, extracted) => {
89
+ const extractionSemantic = {
90
+ schema_version: 1,
91
+ source_manifest_id: inventory.manifest.manifest_id,
92
+ selected_occurrence_ids: selected
93
+ .map(({ occurrence }) => occurrence.occurrence_id)
94
+ .sort((left, right) => left.localeCompare(right)),
95
+ files_sha256: digestCanonical(extracted),
96
+ output_root_alias: "$OUTPUT_ROOT",
97
+ };
98
+ return artifactExtractionResultSchema.parse({
99
+ manifest: inventory.manifest,
100
+ extraction_manifest: {
101
+ ...extractionSemantic,
102
+ extraction_id: `aex_${digestCanonical(extractionSemantic)}`,
103
+ },
104
+ output_root: "$OUTPUT_ROOT",
105
+ artifacts: pageOf(extracted, input.offset, input.limit),
106
+ containment_verified: true,
107
+ cleanup: { attempted: false, verified: true, residual_paths: [] },
108
+ limits: toOutputLimits(input.limits),
109
+ provenance: [],
110
+ limitations: [
111
+ "Only caller-selected regular file occurrences were materialized.",
112
+ ],
113
+ });
114
+ };
115
+ const loadInventory = async (path, limits, selectedIds, signal) => {
116
+ const snapshot = await scanArtifactInventory(path, limits, signal);
117
+ const occurrences = new Map();
118
+ const neededNodes = new Set();
119
+ collectOccurrences(snapshot.occurrences, selectedIds, occurrences, neededNodes);
120
+ const nodes = new Map();
121
+ collectNodes(snapshot.nodes, neededNodes, nodes);
122
+ return { manifest: snapshot.manifest, occurrences, nodes };
123
+ };
124
+ const collectOccurrences = (items, selected, output, neededNodes) => {
125
+ for (const item of items) {
126
+ if (!selected.has(item.occurrence_id))
127
+ continue;
128
+ output.set(item.occurrence_id, item);
129
+ if (item.artifact_id !== null)
130
+ neededNodes.add(item.artifact_id);
131
+ }
132
+ };
133
+ const collectNodes = (items, selected, output) => {
134
+ for (const item of items)
135
+ if (selected.has(item.artifact_id))
136
+ output.set(item.artifact_id, item);
137
+ };
138
+ const createReader = async (path, format) => {
139
+ if ((await lstat(path)).isDirectory())
140
+ return new DirectoryArtifactReader(path);
141
+ if (format === "asar")
142
+ return new AsarArtifactReader(path);
143
+ if (format === "ipa" || format === "apk" || format === "zip")
144
+ return new ZipArtifactReader(path, format);
145
+ if (format === "mach-o")
146
+ return new MachOSliceArtifactReader(path);
147
+ throw new ArtifactReaderFailure("unavailable", `Artifact format has no extraction reader: ${format}`);
148
+ };
149
+ const validateSelection = (ids) => {
150
+ if (ids.length === 0 || ids.length > PAGE_SIZE)
151
+ throw new ArtifactReaderFailure("limit", "Extraction requires 1 to 500 explicitly selected occurrences");
152
+ if (new Set(ids).size !== ids.length)
153
+ throw new ArtifactReaderFailure("path", "Extraction occurrence selection contains duplicates");
154
+ };
155
+ const preflight = (entry, limits) => {
156
+ if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
157
+ throw new ArtifactReaderFailure("format", `Selected artifact entry cannot be read: ${entry.path}`);
158
+ if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
159
+ throw new ArtifactReaderFailure("limit", `Selected artifact exceeds byte limit: ${entry.path}`);
160
+ if (entry.declaredSize !== null &&
161
+ entry.compressedSize !== null &&
162
+ (entry.compressedSize === 0
163
+ ? entry.declaredSize > 0
164
+ : entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio))
165
+ throw new ArtifactReaderFailure("limit", `Selected artifact exceeds compression ratio limit: ${entry.path}`);
166
+ };