rea-agents 0.4.0 → 1.0.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.
- package/README.md +33 -7
- package/bridge/hopper_bridge.py +18 -4
- package/dist/application/BinarySession.js +11 -1
- package/dist/application/DirectAnalysis.js +19 -2
- package/dist/application/EnhancedTools.js +46 -36
- package/dist/application/EvidenceBundleCommands.js +22 -0
- package/dist/application/ProcessOwnership.js +21 -0
- package/dist/application/RealHopperAssertions.js +11 -0
- package/dist/application/Upgrade.js +106 -0
- package/dist/cli.js +130 -64
- package/dist/cliEvidenceCommands.js +68 -0
- package/dist/cliLogging.js +21 -0
- package/dist/contracts/toolContracts.js +3 -3
- package/dist/contracts/toolOutputSchemas.js +55 -15
- package/dist/domain/errors.js +34 -2
- package/dist/domain/hopperValues.js +23 -1
- package/dist/hopper/BridgeLauncher.js +9 -10
- package/dist/hopper/protocol.js +13 -2
- package/dist/native/NativeMacOSProvider.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,6 +120,17 @@ rea doctor
|
|
|
120
120
|
rea analyze /Applications/Notes.app
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
Update that global installation in place:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
rea upgrade
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
REA checks npm for the latest release and verifies that the running package is
|
|
130
|
+
the global installation it will replace. Source, local, and `npx` copies report
|
|
131
|
+
the manual `npm install --global rea-agents@latest` command instead of updating
|
|
132
|
+
an unrelated global package.
|
|
133
|
+
|
|
123
134
|
Choose either the no-install commands or the global installation. You do not need both.
|
|
124
135
|
|
|
125
136
|
### Requirements
|
|
@@ -170,12 +181,12 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid
|
|
|
170
181
|
|
|
171
182
|
### CLI or coding agent?
|
|
172
183
|
|
|
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
|
|
178
|
-
| Import source as historical reference | `rea import-reference-source`
|
|
184
|
+
| If you want to… | Use |
|
|
185
|
+
| --------------------------------------------------------- | -------------------------------------------------------------- |
|
|
186
|
+
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
|
|
187
|
+
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
|
188
|
+
| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
|
189
|
+
| Import source as historical reference | `rea import-reference-source` |
|
|
179
190
|
|
|
180
191
|
Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
|
|
181
192
|
|
|
@@ -183,6 +194,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator
|
|
|
183
194
|
export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
|
|
184
195
|
rea evidence-import /absolute/path/to/evidence/bundle.json
|
|
185
196
|
rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
|
|
197
|
+
rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
|
|
186
198
|
```
|
|
187
199
|
|
|
188
200
|
Historical source import requires a separate allowlist and never treats source as current behavioral authority:
|
|
@@ -276,6 +288,8 @@ REA is growing into a toolkit for understanding software across static artifacts
|
|
|
276
288
|
|
|
277
289
|
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
|
|
278
290
|
|
|
291
|
+
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
|
|
292
|
+
|
|
279
293
|
## Using REA with other coding agents
|
|
280
294
|
|
|
281
295
|
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
|
|
@@ -320,15 +334,27 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f
|
|
|
320
334
|
|
|
321
335
|
```bash
|
|
322
336
|
npx -y rea-agents analyze /Applications/Notes.app
|
|
337
|
+
npx -y rea-agents inspect /Applications/Notes.app
|
|
338
|
+
npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20
|
|
339
|
+
npx -y rea-agents search /Applications/Notes.app "offline"
|
|
340
|
+
npx -y rea-agents function /Applications/Notes.app 0x1000
|
|
341
|
+
npx -y rea-agents xrefs /Applications/Notes.app 0x1000
|
|
342
|
+
npx -y rea-agents trace /Applications/Notes.app "offline"
|
|
343
|
+
npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
|
|
344
|
+
npx -y rea-agents capabilities
|
|
345
|
+
npx -y rea-agents providers
|
|
323
346
|
```
|
|
324
347
|
|
|
325
|
-
Run `npx -y rea-agents --help` for direct decompilation
|
|
348
|
+
Run `npx -y rea-agents --help` for direct decompilation, bounded search and
|
|
349
|
+
other options. `analyze` and `inspect` share the same overview workflow;
|
|
350
|
+
`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
|
|
326
351
|
|
|
327
352
|
Or install the `rea` command globally:
|
|
328
353
|
|
|
329
354
|
```bash
|
|
330
355
|
npm install --global rea-agents
|
|
331
356
|
rea --help
|
|
357
|
+
rea upgrade
|
|
332
358
|
rea mcp
|
|
333
359
|
```
|
|
334
360
|
|
package/bridge/hopper_bridge.py
CHANGED
|
@@ -274,9 +274,15 @@ def _search_page(document, kind, params):
|
|
|
274
274
|
raise ValueError("Invalid regex pattern") from error
|
|
275
275
|
matches = lambda value: expression.search(value) is not None
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
277
|
+
selected = []
|
|
278
|
+
total = 0
|
|
279
|
+
page_end = offset + limit
|
|
280
|
+
for item in _search_inventory(document, kind):
|
|
281
|
+
if not matches(item[1]):
|
|
282
|
+
continue
|
|
283
|
+
if offset <= total < page_end:
|
|
284
|
+
selected.append(item)
|
|
285
|
+
total += 1
|
|
280
286
|
next_offset = offset + len(selected)
|
|
281
287
|
has_more = next_offset < total
|
|
282
288
|
return {
|
|
@@ -654,7 +660,7 @@ def _serve_connection(connection):
|
|
|
654
660
|
should_stop = request["method"] == "shutdown"
|
|
655
661
|
response = {"id": request_id, "result": _json_safe(result)}
|
|
656
662
|
except Exception as error:
|
|
657
|
-
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512]}}
|
|
663
|
+
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
|
|
658
664
|
file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
659
665
|
file.flush()
|
|
660
666
|
if should_stop:
|
|
@@ -663,6 +669,14 @@ def _serve_connection(connection):
|
|
|
663
669
|
connection.close()
|
|
664
670
|
|
|
665
671
|
|
|
672
|
+
def _diagnostic_type(error):
|
|
673
|
+
if isinstance(error, PermissionError):
|
|
674
|
+
return "authorization"
|
|
675
|
+
if isinstance(error, (ValueError, TypeError, KeyError)):
|
|
676
|
+
return "invalid_request"
|
|
677
|
+
return "bridge_exception"
|
|
678
|
+
|
|
679
|
+
|
|
666
680
|
def _run():
|
|
667
681
|
"""Own a permission-restricted, single-client Unix socket for this bridge."""
|
|
668
682
|
if os.path.exists(REA_SOCKET):
|
|
@@ -180,6 +180,15 @@ export class BinarySession {
|
|
|
180
180
|
name: this.#providerIdentity.name,
|
|
181
181
|
version: this.#providerIdentity.version,
|
|
182
182
|
};
|
|
183
|
+
const providers = new Map();
|
|
184
|
+
if (this.#capabilities === undefined)
|
|
185
|
+
providers.set(provider.id, provider);
|
|
186
|
+
else
|
|
187
|
+
for (const descriptor of this.#capabilities.values())
|
|
188
|
+
providers.set(descriptor.provider.id, descriptor.provider);
|
|
189
|
+
const providerList = [...providers.values()]
|
|
190
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
191
|
+
.map(({ id, name, version }) => ({ id, name, version }));
|
|
183
192
|
const capabilities = this.#capabilities === undefined
|
|
184
193
|
? []
|
|
185
194
|
: [...this.#capabilities.values()]
|
|
@@ -209,10 +218,11 @@ export class BinarySession {
|
|
|
209
218
|
limitations: [...descriptor.limitations],
|
|
210
219
|
}));
|
|
211
220
|
return target === undefined
|
|
212
|
-
? { open: false, provider, capabilities }
|
|
221
|
+
? { open: false, provider, providers: providerList, capabilities }
|
|
213
222
|
: {
|
|
214
223
|
open: true,
|
|
215
224
|
provider,
|
|
225
|
+
providers: providerList,
|
|
216
226
|
capabilities,
|
|
217
227
|
path: target.path,
|
|
218
228
|
sha256: target.sha256,
|
|
@@ -16,6 +16,19 @@ const WORKFLOW_PROVIDER = {
|
|
|
16
16
|
export const runDirectAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
|
|
17
17
|
/** Execute one provider-native semantic operation with atomic provenance. */
|
|
18
18
|
export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
|
|
19
|
+
/** Describe configured providers without opening a target or launching Hopper. */
|
|
20
|
+
export const runSessionStatus = async (logger = silentLogger) => {
|
|
21
|
+
const config = parseConfig(process.env);
|
|
22
|
+
if (!config.ok)
|
|
23
|
+
return { error: config.error._tag, message: config.error.message };
|
|
24
|
+
const session = createBinarySession(config.value, logger);
|
|
25
|
+
try {
|
|
26
|
+
return session.status();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await session.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
19
32
|
const runAnalysis = async (path, tool, arguments_, logger) => {
|
|
20
33
|
const config = parseConfig(process.env);
|
|
21
34
|
if (!config.ok)
|
|
@@ -25,10 +38,14 @@ const runAnalysis = async (path, tool, arguments_, logger) => {
|
|
|
25
38
|
const opened = await session.open(path);
|
|
26
39
|
if (!opened.ok)
|
|
27
40
|
return { error: opened.error._tag, message: opened.error.message };
|
|
28
|
-
if (tool === "binary_overview"
|
|
41
|
+
if (tool === "binary_overview" ||
|
|
42
|
+
tool === "analyze_function" ||
|
|
43
|
+
tool === "trace_feature") {
|
|
29
44
|
const result = await new EnhancedTools(session).execute(tool, arguments_);
|
|
30
45
|
return result.ok
|
|
31
|
-
? createEvidence(opened.value,
|
|
46
|
+
? createEvidence(opened.value, tool === "analyze_function"
|
|
47
|
+
? session.providerIdentity(tool)
|
|
48
|
+
: WORKFLOW_PROVIDER, {
|
|
32
49
|
operation: tool,
|
|
33
50
|
parameters: arguments_,
|
|
34
51
|
result: result.value,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
|
2
|
-
import { AnalysisInputError, AnalysisOutputError, } from "../domain/errors.js";
|
|
3
|
-
import { parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
|
|
2
|
+
import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, projectAnalysisError, } from "../domain/errors.js";
|
|
3
|
+
import { addressDistance, parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
|
|
4
4
|
import { err, ok } from "../domain/result.js";
|
|
5
5
|
import { categorizeSwiftTypes, discoverObjcClasses, discoverObjcProtocols, discoverSwiftClasses, } from "../domain/symbolAnalysis.js";
|
|
6
6
|
/**
|
|
@@ -89,20 +89,29 @@ export class EnhancedTools {
|
|
|
89
89
|
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
|
|
90
90
|
}
|
|
91
91
|
async #batchDecompile(addresses, signal) {
|
|
92
|
-
|
|
93
|
-
return ok({ error: "No addresses provided" });
|
|
94
|
-
const entries = await Promise.all(addresses.map(async (address) => {
|
|
92
|
+
const items = await Promise.all(addresses.map(async (address) => {
|
|
95
93
|
const result = await this.#call("procedure_pseudo_code", { procedure: address }, signal);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
if (!result.ok)
|
|
95
|
+
return {
|
|
96
|
+
address,
|
|
97
|
+
status: "error",
|
|
98
|
+
error: projectAnalysisError(result.error),
|
|
99
|
+
};
|
|
100
|
+
if (typeof result.value !== "string" || result.value.length === 0)
|
|
101
|
+
return {
|
|
102
|
+
address,
|
|
103
|
+
status: "error",
|
|
104
|
+
error: projectAnalysisError(new AnalysisOutputError("procedure_pseudo_code", "provider returned empty pseudocode")),
|
|
105
|
+
};
|
|
106
|
+
return { address, status: "ok", pseudocode: result.value };
|
|
104
107
|
}));
|
|
105
|
-
|
|
108
|
+
const succeeded = items.filter(({ status }) => status === "ok").length;
|
|
109
|
+
return ok({
|
|
110
|
+
items,
|
|
111
|
+
total: items.length,
|
|
112
|
+
succeeded,
|
|
113
|
+
failed: items.length - succeeded,
|
|
114
|
+
});
|
|
106
115
|
}
|
|
107
116
|
async #callGraph(input, signal) {
|
|
108
117
|
const relation = input.direction === "forward" ? "callees" : "callers";
|
|
@@ -124,7 +133,8 @@ export class EnhancedTools {
|
|
|
124
133
|
if (!result.ok) {
|
|
125
134
|
graph[level].push({
|
|
126
135
|
address: current.address,
|
|
127
|
-
|
|
136
|
+
status: "error",
|
|
137
|
+
error: projectAnalysisError(result.error),
|
|
128
138
|
});
|
|
129
139
|
continue;
|
|
130
140
|
}
|
|
@@ -132,12 +142,14 @@ export class EnhancedTools {
|
|
|
132
142
|
if (!related.ok) {
|
|
133
143
|
graph[level].push({
|
|
134
144
|
address: current.address,
|
|
135
|
-
|
|
145
|
+
status: "error",
|
|
146
|
+
error: projectAnalysisError(related.error),
|
|
136
147
|
});
|
|
137
148
|
continue;
|
|
138
149
|
}
|
|
139
150
|
graph[level].push({
|
|
140
151
|
address: current.address,
|
|
152
|
+
status: "ok",
|
|
141
153
|
calls: [...related.value],
|
|
142
154
|
});
|
|
143
155
|
if (current.depth + 1 < input.depth) {
|
|
@@ -158,16 +170,24 @@ export class EnhancedTools {
|
|
|
158
170
|
: procedures;
|
|
159
171
|
}
|
|
160
172
|
async #findXrefs(name, signal) {
|
|
161
|
-
const
|
|
162
|
-
if (!
|
|
163
|
-
return
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
return ok({
|
|
167
|
-
const xrefs = await this.#call("xrefs", { address }, signal);
|
|
173
|
+
const names = await this.#allAddressed("list_names", signal);
|
|
174
|
+
if (!names.ok)
|
|
175
|
+
return names;
|
|
176
|
+
const resolved = names.value.find((entry) => entry.name === name);
|
|
177
|
+
if (resolved === undefined)
|
|
178
|
+
return ok({ status: "unresolved", name, reason: "name_not_found" });
|
|
179
|
+
const xrefs = await this.#call("xrefs", { address: resolved.address }, signal);
|
|
168
180
|
if (!xrefs.ok)
|
|
169
181
|
return xrefs;
|
|
170
|
-
|
|
182
|
+
if (!Array.isArray(xrefs.value) ||
|
|
183
|
+
xrefs.value.some((xref) => typeof xref !== "string"))
|
|
184
|
+
return err(new AnalysisOutputError("xrefs", "provider returned an invalid address list"));
|
|
185
|
+
return ok({
|
|
186
|
+
status: "resolved",
|
|
187
|
+
name,
|
|
188
|
+
address: resolved.address,
|
|
189
|
+
xrefs: xrefs.value,
|
|
190
|
+
});
|
|
171
191
|
}
|
|
172
192
|
async #binaryOverview(input, signal) {
|
|
173
193
|
const [segmentsResult, documentsResult, procedures, stringsResult] = await Promise.all([
|
|
@@ -337,6 +357,8 @@ export class EnhancedTools {
|
|
|
337
357
|
}
|
|
338
358
|
}
|
|
339
359
|
async #call(name, arguments_, signal) {
|
|
360
|
+
if (signal?.aborted === true)
|
|
361
|
+
return err(new AnalysisCancelledError(name));
|
|
340
362
|
const execution = await this.analysis.execute(name, arguments_, signal === undefined ? {} : { signal });
|
|
341
363
|
return execution.ok ? ok(execution.value.result) : execution;
|
|
342
364
|
}
|
|
@@ -344,15 +366,3 @@ export class EnhancedTools {
|
|
|
344
366
|
const invalidInput = (name, cause) => Promise.resolve(err(new AnalysisInputError(name, {
|
|
345
367
|
cause,
|
|
346
368
|
})));
|
|
347
|
-
const resolveAddress = (value) => {
|
|
348
|
-
if (typeof value === "string" && value.length > 0)
|
|
349
|
-
return value;
|
|
350
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
351
|
-
return undefined;
|
|
352
|
-
}
|
|
353
|
-
const candidate = value.address ?? value.name;
|
|
354
|
-
return typeof candidate === "string" && candidate.length > 0
|
|
355
|
-
? candidate
|
|
356
|
-
: undefined;
|
|
357
|
-
};
|
|
358
|
-
const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { EvidenceIntegrityError, } from "../domain/errors.js";
|
|
2
|
+
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
1
3
|
import { err, ok } from "../domain/result.js";
|
|
4
|
+
import { compareBundles } from "../domain/bundleComparison.js";
|
|
2
5
|
import { EvidenceLedger } from "./EvidenceLedger.js";
|
|
3
6
|
import { readEvidenceBundle, writeEvidenceBundle, } from "./EvidenceBundleFiles.js";
|
|
4
7
|
/** Validate and merge one bundle using the same bounded ledger as MCP. */
|
|
@@ -19,6 +22,25 @@ export const exportEvidenceBundleCommand = async (sourcePath, outputPath, overwr
|
|
|
19
22
|
return loaded;
|
|
20
23
|
return projectWrite(loaded.value, await writeEvidenceBundle(loaded.value, outputPath, overwrite, policy));
|
|
21
24
|
};
|
|
25
|
+
/** Compare two validated canonical Evidence v2 bundles without session state. */
|
|
26
|
+
export const compareEvidenceBundlesCommand = async (input) => {
|
|
27
|
+
const [left, right] = await Promise.all([
|
|
28
|
+
readEvidenceBundle(input.leftPath, input.policy),
|
|
29
|
+
readEvidenceBundle(input.rightPath, input.policy),
|
|
30
|
+
]);
|
|
31
|
+
if (!left.ok)
|
|
32
|
+
return left;
|
|
33
|
+
if (!right.ok)
|
|
34
|
+
return right;
|
|
35
|
+
try {
|
|
36
|
+
return ok(jsonValueSchema.parse(compareBundles(left.value, right.value, [], input.offset, input.limit)));
|
|
37
|
+
}
|
|
38
|
+
catch (cause) {
|
|
39
|
+
return err(new EvidenceIntegrityError("Evidence bundle comparison failed", {
|
|
40
|
+
cause,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
22
44
|
const createLedger = () => new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024 });
|
|
23
45
|
const projectWrite = (bundle, written) => written.ok
|
|
24
46
|
? ok({
|
|
@@ -54,6 +54,21 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
54
54
|
}
|
|
55
55
|
if (members.length === 0)
|
|
56
56
|
return { cleaned: true, signaled: false };
|
|
57
|
+
const leader = members.find((member) => member.pid === ownership.leaderPid);
|
|
58
|
+
if (leader !== undefined) {
|
|
59
|
+
if (ownership.expectedParentPid !== undefined &&
|
|
60
|
+
leader.parentPid !== ownership.expectedParentPid)
|
|
61
|
+
return {
|
|
62
|
+
cleaned: false,
|
|
63
|
+
reason: "owned launcher parent identity did not match",
|
|
64
|
+
};
|
|
65
|
+
if (ownership.expectedCommand !== undefined &&
|
|
66
|
+
!commandMatches(leader.command, ownership.expectedCommand))
|
|
67
|
+
return {
|
|
68
|
+
cleaned: false,
|
|
69
|
+
reason: "owned launcher command identity did not match",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
57
72
|
for (const member of members) {
|
|
58
73
|
let environment;
|
|
59
74
|
try {
|
|
@@ -82,3 +97,9 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
|
|
|
82
97
|
}
|
|
83
98
|
return { cleaned: true, signaled: true };
|
|
84
99
|
};
|
|
100
|
+
const commandMatches = (actual, expected) => {
|
|
101
|
+
const normalizedActual = actual.trim();
|
|
102
|
+
const normalizedExpected = expected.trim();
|
|
103
|
+
return (normalizedActual === normalizedExpected ||
|
|
104
|
+
normalizedActual.startsWith(`${normalizedExpected} `));
|
|
105
|
+
};
|
|
@@ -25,6 +25,17 @@ export const firstProcedureAddress = (input) => {
|
|
|
25
25
|
throw new TypeError("Procedure page was empty");
|
|
26
26
|
return first.address;
|
|
27
27
|
};
|
|
28
|
+
/** Reject two target paths that resolve to the same binary content. */
|
|
29
|
+
export const requireDistinctTargetHashes = (firstHash, secondHash) => {
|
|
30
|
+
if (typeof firstHash !== "string" ||
|
|
31
|
+
typeof secondHash !== "string" ||
|
|
32
|
+
firstHash.length === 0 ||
|
|
33
|
+
secondHash.length === 0) {
|
|
34
|
+
throw new TypeError("Target hashes must be non-empty strings");
|
|
35
|
+
}
|
|
36
|
+
if (firstHash === secondHash)
|
|
37
|
+
throw new TypeError("Real-Hopper verification requires distinct binaries");
|
|
38
|
+
};
|
|
28
39
|
/** Reject empty and success-shaped embedded decompilation failures. */
|
|
29
40
|
export const requirePseudocode = (input, operation) => {
|
|
30
41
|
if (typeof input !== "string" || input.trim().length === 0)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const registryResponseSchema = z.object({ version: z.string().min(1) });
|
|
10
|
+
const UPGRADE_COMMAND = "npm install --global rea-agents@latest";
|
|
11
|
+
/** Check the npm registry and update the same global REA installation. */
|
|
12
|
+
export const runUpgrade = async (currentVersion, host = systemUpgradeHost(), output = "human") => {
|
|
13
|
+
const latestVersion = await host.latestVersion();
|
|
14
|
+
if (latestVersion === currentVersion)
|
|
15
|
+
return {
|
|
16
|
+
status: "current",
|
|
17
|
+
currentVersion,
|
|
18
|
+
latestVersion,
|
|
19
|
+
installMethod: "npm",
|
|
20
|
+
};
|
|
21
|
+
const installation = await host.installation();
|
|
22
|
+
if (installation === undefined)
|
|
23
|
+
return {
|
|
24
|
+
status: "failed",
|
|
25
|
+
currentVersion,
|
|
26
|
+
latestVersion: latestVersion ?? null,
|
|
27
|
+
reason: "unknown-install-method",
|
|
28
|
+
remediation: `Update manually with: ${UPGRADE_COMMAND}`,
|
|
29
|
+
};
|
|
30
|
+
if (!(await host.installLatest(installation, output)))
|
|
31
|
+
return {
|
|
32
|
+
status: "failed",
|
|
33
|
+
currentVersion,
|
|
34
|
+
latestVersion: latestVersion ?? null,
|
|
35
|
+
reason: "install",
|
|
36
|
+
remediation: `${UPGRADE_COMMAND} failed.`,
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
status: "upgraded",
|
|
40
|
+
previousVersion: currentVersion,
|
|
41
|
+
latestVersion: latestVersion ?? null,
|
|
42
|
+
versionCheck: latestVersion === undefined ? "unavailable" : "available",
|
|
43
|
+
installMethod: "npm",
|
|
44
|
+
command: UPGRADE_COMMAND,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/** Create the npm registry and subprocess effects for a production upgrade. */
|
|
48
|
+
export const systemUpgradeHost = () => ({
|
|
49
|
+
latestVersion: async () => {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`https://registry.npmjs.org/${PRODUCT_IDENTITY.packageName}/latest`, { signal: AbortSignal.timeout(10_000) });
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
return undefined;
|
|
54
|
+
const parsed = registryResponseSchema.safeParse(await response.json());
|
|
55
|
+
return parsed.success ? parsed.data.version : undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
installation: () => detectNpmInstallation(fileURLToPath(new URL("../..", import.meta.url)), systemNpmInstallationHost),
|
|
62
|
+
installLatest: ({ prefix }, output) => runCommand("npm", [
|
|
63
|
+
"install",
|
|
64
|
+
"--global",
|
|
65
|
+
"--prefix",
|
|
66
|
+
prefix,
|
|
67
|
+
`${PRODUCT_IDENTITY.packageName}@latest`,
|
|
68
|
+
], output),
|
|
69
|
+
});
|
|
70
|
+
const systemNpmInstallationHost = {
|
|
71
|
+
canonicalPath: realpath,
|
|
72
|
+
globalRoot: async () => (await execFileAsync("npm", ["root", "--global"])).stdout.trim(),
|
|
73
|
+
globalPrefix: async () => (await execFileAsync("npm", ["prefix", "--global"])).stdout.trim(),
|
|
74
|
+
};
|
|
75
|
+
/** Identify the npm prefix only when it owns the running package. */
|
|
76
|
+
export const detectNpmInstallation = async (packageRoot, host) => {
|
|
77
|
+
try {
|
|
78
|
+
const canonicalPackageRoot = await host.canonicalPath(packageRoot);
|
|
79
|
+
const npmRoot = await host.globalRoot();
|
|
80
|
+
const globalPackageRoot = await host.canonicalPath(resolve(npmRoot, PRODUCT_IDENTITY.packageName));
|
|
81
|
+
if (canonicalPackageRoot === globalPackageRoot) {
|
|
82
|
+
const prefix = await host.globalPrefix();
|
|
83
|
+
return prefix.length === 0 ? undefined : { prefix };
|
|
84
|
+
}
|
|
85
|
+
return inferUnixGlobalPrefix(canonicalPackageRoot);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return inferUnixGlobalPrefix(packageRoot);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const inferUnixGlobalPrefix = (packageRoot) => {
|
|
92
|
+
const nodeModules = dirname(packageRoot);
|
|
93
|
+
const library = dirname(nodeModules);
|
|
94
|
+
if (basename(packageRoot) !== PRODUCT_IDENTITY.packageName ||
|
|
95
|
+
basename(nodeModules) !== "node_modules" ||
|
|
96
|
+
basename(library) !== "lib")
|
|
97
|
+
return undefined;
|
|
98
|
+
return { prefix: dirname(library) };
|
|
99
|
+
};
|
|
100
|
+
const runCommand = (command, arguments_, output) => new Promise((resolveResult) => {
|
|
101
|
+
const child = spawn(command, [...arguments_], {
|
|
102
|
+
stdio: output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
|
|
103
|
+
});
|
|
104
|
+
child.once("error", () => resolveResult(false));
|
|
105
|
+
child.once("exit", (code) => resolveResult(code === 0));
|
|
106
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { Cli, z } from "incur";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { runDoctor } from "./application/Doctor.js";
|
|
4
|
-
import { runDirectAnalysis, runProviderAnalysis, } from "./application/DirectAnalysis.js";
|
|
4
|
+
import { runDirectAnalysis, runProviderAnalysis, runSessionStatus, } from "./application/DirectAnalysis.js";
|
|
5
5
|
import { runSetup } from "./application/Setup.js";
|
|
6
6
|
import { runUninstall } from "./application/Uninstall.js";
|
|
7
|
+
import { runUpgrade, systemUpgradeHost } from "./application/Upgrade.js";
|
|
7
8
|
import { PRODUCT_IDENTITY } from "./identity.js";
|
|
8
9
|
import { createLogger, parseLogLevel } from "./logger.js";
|
|
10
|
+
import { logCliCommand } from "./cliLogging.js";
|
|
9
11
|
import { parseConfig } from "./config.js";
|
|
10
|
-
import { exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js";
|
|
11
12
|
import { importReferenceSource } from "./application/ReferenceSourceImport.js";
|
|
13
|
+
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
|
12
14
|
/**
|
|
13
15
|
* Build the one-shot Incur CLI without starting Hopper at import time.
|
|
14
16
|
* Analysis commands acquire and close their own sessions; bare `mcp` and
|
|
@@ -35,6 +37,23 @@ export const createCli = () => {
|
|
|
35
37
|
],
|
|
36
38
|
},
|
|
37
39
|
});
|
|
40
|
+
registerCoreCommands(cli, logger);
|
|
41
|
+
registerFunctionCommand(cli, logger);
|
|
42
|
+
registerSearchCommand(cli, logger);
|
|
43
|
+
registerXrefsCommand(cli, logger);
|
|
44
|
+
registerTraceCommand(cli, logger);
|
|
45
|
+
registerCapabilityCommands(cli, logger);
|
|
46
|
+
registerNativeCommands(cli, logger);
|
|
47
|
+
registerArtifactCommands(cli, logger);
|
|
48
|
+
registerEvidenceCommands(cli, logger);
|
|
49
|
+
registerReferenceSourceCommand(cli, logger);
|
|
50
|
+
return cli;
|
|
51
|
+
};
|
|
52
|
+
const registerCoreCommands = (cli, logger) => {
|
|
53
|
+
const overviewOptions = z.object({
|
|
54
|
+
detail: z.enum(["concise", "detailed"]).default("concise"),
|
|
55
|
+
limit: z.number().int().min(1).max(50).default(10),
|
|
56
|
+
});
|
|
38
57
|
cli.command("setup", {
|
|
39
58
|
description: "Install requirements and configure coding agents",
|
|
40
59
|
options: z.object({
|
|
@@ -64,12 +83,25 @@ export const createCli = () => {
|
|
|
64
83
|
alias: { purgeData: "purge-data" },
|
|
65
84
|
run: ({ options }) => logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
|
|
66
85
|
});
|
|
86
|
+
cli.command("upgrade", {
|
|
87
|
+
description: "Upgrade a global npm installation to the latest REA release",
|
|
88
|
+
run: ({ formatExplicit }) => logCliCommand(logger, "upgrade", () => runUpgrade(process.env.REA_PACKAGE_VERSION ?? "0.0.0-development", systemUpgradeHost(), formatExplicit ? "structured" : "human")),
|
|
89
|
+
});
|
|
67
90
|
cli.command("analyze", {
|
|
68
91
|
description: "Get an overview of an app",
|
|
69
92
|
args: z.object({
|
|
70
93
|
path: z.string().describe("App, program, or Hopper database path"),
|
|
71
94
|
}),
|
|
72
|
-
|
|
95
|
+
options: overviewOptions,
|
|
96
|
+
run: ({ args, options }) => logCliCommand(logger, "analyze", () => runDirectAnalysis(args.path, "binary_overview", { detail: options.detail, limit: options.limit }, logger)),
|
|
97
|
+
});
|
|
98
|
+
cli.command("inspect", {
|
|
99
|
+
description: "Inspect an app overview with evidence",
|
|
100
|
+
args: z.object({
|
|
101
|
+
path: z.string().describe("App, program, or Hopper database path"),
|
|
102
|
+
}),
|
|
103
|
+
options: overviewOptions,
|
|
104
|
+
run: ({ args, options }) => logCliCommand(logger, "inspect", () => runDirectAnalysis(args.path, "binary_overview", { detail: options.detail, limit: options.limit }, logger)),
|
|
73
105
|
});
|
|
74
106
|
cli.command("decompile", {
|
|
75
107
|
description: "Read one part of an app as code",
|
|
@@ -79,11 +111,101 @@ export const createCli = () => {
|
|
|
79
111
|
}),
|
|
80
112
|
run: ({ args }) => logCliCommand(logger, "decompile", () => runDirectAnalysis(args.path, "procedure_pseudo_code", { procedure: args.address }, logger)),
|
|
81
113
|
});
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
114
|
+
};
|
|
115
|
+
const registerXrefsCommand = (cli, logger) => {
|
|
116
|
+
cli.command("xrefs", {
|
|
117
|
+
description: "List bounded references to an analyzed address",
|
|
118
|
+
args: z.object({
|
|
119
|
+
path: z.string().describe("App or program path"),
|
|
120
|
+
address: z.string().describe("Hexadecimal address"),
|
|
121
|
+
}),
|
|
122
|
+
run: ({ args }) => logCliCommand(logger, "xrefs", () => runDirectAnalysis(args.path, "xrefs", { address: args.address }, logger)),
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const registerTraceCommand = (cli, logger) => {
|
|
126
|
+
cli.command("trace", {
|
|
127
|
+
description: "Trace a bounded literal feature through analyzed references",
|
|
128
|
+
args: z.object({
|
|
129
|
+
path: z.string().describe("App or program path"),
|
|
130
|
+
query: z.string().min(1).describe("Literal feature query"),
|
|
131
|
+
}),
|
|
132
|
+
options: z.object({
|
|
133
|
+
caseSensitive: z.boolean().default(false),
|
|
134
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
135
|
+
maxOperations: z.number().int().min(1).max(100).default(20),
|
|
136
|
+
}),
|
|
137
|
+
alias: {
|
|
138
|
+
caseSensitive: "case-sensitive",
|
|
139
|
+
maxOperations: "max-operations",
|
|
140
|
+
},
|
|
141
|
+
run: ({ args, options }) => logCliCommand(logger, "trace", () => runDirectAnalysis(args.path, "trace_feature", {
|
|
142
|
+
query: args.query,
|
|
143
|
+
case_sensitive: options.caseSensitive,
|
|
144
|
+
limit: options.limit,
|
|
145
|
+
max_operations: options.maxOperations,
|
|
146
|
+
}, logger)),
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const registerCapabilityCommands = (cli, logger) => {
|
|
150
|
+
for (const command of ["capabilities", "providers"]) {
|
|
151
|
+
cli.command(command, {
|
|
152
|
+
description: command === "capabilities"
|
|
153
|
+
? "List provider capabilities and side effects"
|
|
154
|
+
: "List configured analysis providers",
|
|
155
|
+
run: () => logCliCommand(logger, command, () => runSessionStatus(logger)),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const registerFunctionCommand = (cli, logger) => {
|
|
160
|
+
cli.command("function", {
|
|
161
|
+
description: "Analyze one bounded function with evidence",
|
|
162
|
+
args: z.object({
|
|
163
|
+
path: z.string().describe("App or program path"),
|
|
164
|
+
address: z.string().describe("Procedure name or address"),
|
|
165
|
+
}),
|
|
166
|
+
options: z.object({
|
|
167
|
+
includeAssembly: z.boolean().default(false),
|
|
168
|
+
limit: z.number().int().min(1).max(500).default(100),
|
|
169
|
+
maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
|
|
170
|
+
maxInstructions: z.number().int().min(1).max(5_000).default(500),
|
|
171
|
+
}),
|
|
172
|
+
alias: {
|
|
173
|
+
includeAssembly: "include-assembly",
|
|
174
|
+
maxPseudocodeChars: "max-pseudocode-chars",
|
|
175
|
+
maxInstructions: "max-instructions",
|
|
176
|
+
},
|
|
177
|
+
run: ({ args, options }) => logCliCommand(logger, "function", () => runDirectAnalysis(args.path, "analyze_function", {
|
|
178
|
+
procedure: args.address,
|
|
179
|
+
include_assembly: options.includeAssembly,
|
|
180
|
+
limit: options.limit,
|
|
181
|
+
max_pseudocode_chars: options.maxPseudocodeChars,
|
|
182
|
+
max_instructions: options.maxInstructions,
|
|
183
|
+
}, logger)),
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
const registerSearchCommand = (cli, logger) => {
|
|
187
|
+
cli.command("search", {
|
|
188
|
+
description: "Search bounded analyzed strings or procedure names",
|
|
189
|
+
args: z.object({
|
|
190
|
+
path: z.string().describe("App or program path"),
|
|
191
|
+
pattern: z.string().min(1).describe("Literal text or regex pattern"),
|
|
192
|
+
}),
|
|
193
|
+
options: z.object({
|
|
194
|
+
kind: z.enum(["strings", "procedures"]).default("strings"),
|
|
195
|
+
mode: z.enum(["literal", "regex"]).default("literal"),
|
|
196
|
+
caseSensitive: z.boolean().default(false),
|
|
197
|
+
offset: z.number().int().min(0).default(0),
|
|
198
|
+
limit: z.number().int().min(1).max(100).default(100),
|
|
199
|
+
}),
|
|
200
|
+
alias: { caseSensitive: "case-sensitive" },
|
|
201
|
+
run: ({ args, options }) => logCliCommand(logger, "search", () => runDirectAnalysis(args.path, options.kind === "strings" ? "search_strings" : "search_procedures", {
|
|
202
|
+
pattern: args.pattern,
|
|
203
|
+
mode: options.mode,
|
|
204
|
+
case_sensitive: options.caseSensitive,
|
|
205
|
+
offset: options.offset,
|
|
206
|
+
limit: options.limit,
|
|
207
|
+
}, logger)),
|
|
208
|
+
});
|
|
87
209
|
};
|
|
88
210
|
const registerReferenceSourceCommand = (cli, logger) => {
|
|
89
211
|
cli.command("import-reference-source", {
|
|
@@ -177,59 +299,3 @@ const registerNativeCommands = (cli, logger) => {
|
|
|
177
299
|
run: ({ args }) => logCliCommand(logger, "demangle-swift", () => runProviderAnalysis(args.path, "demangle_swift", { symbols: args.symbols }, logger)),
|
|
178
300
|
});
|
|
179
301
|
};
|
|
180
|
-
const registerEvidenceCommands = (cli, logger) => {
|
|
181
|
-
cli.command("evidence-import", {
|
|
182
|
-
description: "Validate and import a bounded local Evidence v2 bundle",
|
|
183
|
-
args: z.object({
|
|
184
|
-
path: z.string().describe("Evidence bundle JSON path"),
|
|
185
|
-
}),
|
|
186
|
-
run: ({ args }) => logCliCommand(logger, "evidence-import", async () => {
|
|
187
|
-
const config = parseConfig(process.env);
|
|
188
|
-
if (!config.ok)
|
|
189
|
-
return { error: config.error._tag, message: config.error.message };
|
|
190
|
-
const imported = await importEvidenceBundleCommand(args.path, config.value.evidenceFilePolicy);
|
|
191
|
-
return imported.ok
|
|
192
|
-
? imported.value
|
|
193
|
-
: { error: imported.error._tag, message: imported.error.message };
|
|
194
|
-
}),
|
|
195
|
-
});
|
|
196
|
-
cli.command("evidence-export", {
|
|
197
|
-
description: "Validate and atomically export canonical Evidence v2 JSON",
|
|
198
|
-
args: z.object({
|
|
199
|
-
source: z.string().describe("Existing evidence bundle JSON path"),
|
|
200
|
-
output: z.string().describe("Canonical output JSON path"),
|
|
201
|
-
}),
|
|
202
|
-
options: z.object({
|
|
203
|
-
overwrite: z.boolean().default(false).describe("Replace output file"),
|
|
204
|
-
}),
|
|
205
|
-
run: ({ args, options }) => logCliCommand(logger, "evidence-export", async () => {
|
|
206
|
-
const config = parseConfig(process.env);
|
|
207
|
-
if (!config.ok)
|
|
208
|
-
return { error: config.error._tag, message: config.error.message };
|
|
209
|
-
const exported = await exportEvidenceBundleCommand(args.source, args.output, options.overwrite, config.value.evidenceFilePolicy);
|
|
210
|
-
return exported.ok
|
|
211
|
-
? exported.value
|
|
212
|
-
: { error: exported.error._tag, message: exported.error.message };
|
|
213
|
-
}),
|
|
214
|
-
});
|
|
215
|
-
};
|
|
216
|
-
const logCliCommand = async (logger, command, execute) => {
|
|
217
|
-
const startedAt = performance.now();
|
|
218
|
-
try {
|
|
219
|
-
const value = await execute();
|
|
220
|
-
logger.info({
|
|
221
|
-
command,
|
|
222
|
-
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
223
|
-
status: "ok",
|
|
224
|
-
}, "CLI command completed");
|
|
225
|
-
return value;
|
|
226
|
-
}
|
|
227
|
-
catch (cause) {
|
|
228
|
-
logger.error({
|
|
229
|
-
command,
|
|
230
|
-
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
231
|
-
status: "error",
|
|
232
|
-
}, "CLI command failed");
|
|
233
|
-
throw cause;
|
|
234
|
-
}
|
|
235
|
-
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Cli, z } from "incur";
|
|
2
|
+
import { compareEvidenceBundlesCommand, exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js";
|
|
3
|
+
import { parseConfig } from "./config.js";
|
|
4
|
+
import { logCliCommand } from "./cliLogging.js";
|
|
5
|
+
/** Register filesystem-gated Evidence v2 commands. */
|
|
6
|
+
export const registerEvidenceCommands = (cli, logger) => {
|
|
7
|
+
cli.command("evidence-import", {
|
|
8
|
+
description: "Validate and import a bounded local Evidence v2 bundle",
|
|
9
|
+
args: z.object({
|
|
10
|
+
path: z.string().describe("Evidence bundle JSON path"),
|
|
11
|
+
}),
|
|
12
|
+
run: ({ args }) => logCliCommand(logger, "evidence-import", async () => {
|
|
13
|
+
const config = parseConfig(process.env);
|
|
14
|
+
if (!config.ok)
|
|
15
|
+
return { error: config.error._tag, message: config.error.message };
|
|
16
|
+
const imported = await importEvidenceBundleCommand(args.path, config.value.evidenceFilePolicy);
|
|
17
|
+
return imported.ok
|
|
18
|
+
? imported.value
|
|
19
|
+
: { error: imported.error._tag, message: imported.error.message };
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
cli.command("evidence-export", {
|
|
23
|
+
description: "Validate and atomically export canonical Evidence v2 JSON",
|
|
24
|
+
args: z.object({
|
|
25
|
+
source: z.string().describe("Existing evidence bundle JSON path"),
|
|
26
|
+
output: z.string().describe("Canonical output JSON path"),
|
|
27
|
+
}),
|
|
28
|
+
options: z.object({
|
|
29
|
+
overwrite: z.boolean().default(false).describe("Replace output file"),
|
|
30
|
+
}),
|
|
31
|
+
run: ({ args, options }) => logCliCommand(logger, "evidence-export", async () => {
|
|
32
|
+
const config = parseConfig(process.env);
|
|
33
|
+
if (!config.ok)
|
|
34
|
+
return { error: config.error._tag, message: config.error.message };
|
|
35
|
+
const exported = await exportEvidenceBundleCommand(args.source, args.output, options.overwrite, config.value.evidenceFilePolicy);
|
|
36
|
+
return exported.ok
|
|
37
|
+
? exported.value
|
|
38
|
+
: { error: exported.error._tag, message: exported.error.message };
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
cli.command("compare", {
|
|
42
|
+
aliases: ["compare-bundles"],
|
|
43
|
+
description: "Compare two canonical Evidence v2 bundles",
|
|
44
|
+
args: z.object({
|
|
45
|
+
left: z.string().describe("Left Evidence bundle JSON path"),
|
|
46
|
+
right: z.string().describe("Right Evidence bundle JSON path"),
|
|
47
|
+
}),
|
|
48
|
+
options: z.object({
|
|
49
|
+
offset: z.number().int().min(0).default(0),
|
|
50
|
+
limit: z.number().int().min(1).max(500).default(100),
|
|
51
|
+
}),
|
|
52
|
+
run: ({ args, options }) => logCliCommand(logger, "compare", async () => {
|
|
53
|
+
const config = parseConfig(process.env);
|
|
54
|
+
if (!config.ok)
|
|
55
|
+
return { error: config.error._tag, message: config.error.message };
|
|
56
|
+
const compared = await compareEvidenceBundlesCommand({
|
|
57
|
+
leftPath: args.left,
|
|
58
|
+
rightPath: args.right,
|
|
59
|
+
offset: options.offset,
|
|
60
|
+
limit: options.limit,
|
|
61
|
+
policy: config.value.evidenceFilePolicy,
|
|
62
|
+
});
|
|
63
|
+
return compared.ok
|
|
64
|
+
? compared.value
|
|
65
|
+
: { error: compared.error._tag, message: compared.error.message };
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Log one CLI command with duration and a stable success or failure status. */
|
|
2
|
+
export const logCliCommand = async (logger, command, execute) => {
|
|
3
|
+
const startedAt = performance.now();
|
|
4
|
+
try {
|
|
5
|
+
const value = await execute();
|
|
6
|
+
logger.info({
|
|
7
|
+
command,
|
|
8
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
9
|
+
status: "ok",
|
|
10
|
+
}, "CLI command completed");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
catch (cause) {
|
|
14
|
+
logger.error({
|
|
15
|
+
command,
|
|
16
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
17
|
+
status: "error",
|
|
18
|
+
}, "CLI command failed");
|
|
19
|
+
throw cause;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -146,10 +146,10 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
|
|
146
146
|
enhanced("swift_classes", "Discover legacy-mangled Swift class procedures after exhaustively paging analyzed procedures. Returns at most 100 entries and scans at most 5,000 symbols; use analyze_swift_types for other Swift kinds.", enhancedInputSchemas.swift_classes),
|
|
147
147
|
enhanced("get_objc_classes", "Discover and deduplicate Objective-C class labels after exhaustively paging names, optionally filtering by literal substring. Returns at most 100 classes; inspect matching metadata and references next.", enhancedInputSchemas.get_objc_classes),
|
|
148
148
|
enhanced("get_objc_protocols", "Discover and deduplicate Objective-C and Swift protocol labels after exhaustively paging names. Returns at most 100 entries; use xrefs or analyze_function to connect a protocol to implementations.", enhancedInputSchemas.get_objc_protocols),
|
|
149
|
-
enhanced("batch_decompile", "Decompile up to 20 explicit procedure symbols or addresses concurrently.
|
|
150
|
-
enhanced("get_call_graph", "Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels.
|
|
149
|
+
enhanced("batch_decompile", "Decompile up to 20 explicit procedure symbols or addresses concurrently. Returns ordered per-item ok/error variants and aggregate counts; use analyze_function for a richer single-function dossier.", enhancedInputSchemas.batch_decompile),
|
|
150
|
+
enhanced("get_call_graph", "Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels. Every node has an ok/error status and failures use safe typed projections; indirect calls may be missing and results are not a whole-program CFG.", enhancedInputSchemas.get_call_graph),
|
|
151
151
|
enhanced("analyze_swift_types", "Categorize exhaustively paged procedure names into Swift classes, structs, enums, protocols, extensions, and other symbols. Scans at most 5,000 names and returns at most 50 entries per category.", enhancedInputSchemas.analyze_swift_types),
|
|
152
|
-
enhanced("find_xrefs_to_name", "Resolve
|
|
152
|
+
enhanced("find_xrefs_to_name", "Resolve an exact name through Hopper's exhaustively paged name inventory and return a resolved or unresolved result. Unresolved names use the stable name_not_found reason; xrefs remain untyped.", enhancedInputSchemas.find_xrefs_to_name),
|
|
153
153
|
enhanced("binary_overview", "Use immediately after opening a target to summarize document, exhaustive procedure/string counts, and a bounded segment sample. detail controls segment fields and limit controls only the returned segment sample.", enhancedInputSchemas.binary_overview),
|
|
154
154
|
enhanced("analyze_function", "Preferred bounded analysis for one procedure symbol or address. Returns identity, pseudocode, optional assembly, comments, calls, incoming references, and blocks; unsupported outgoing references and CFG edges carry explicit unavailable metadata.", enhancedInputSchemas.analyze_function),
|
|
155
155
|
enhanced("trace_feature", "Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; unknown_registry_approved: true records them durably without inferring reference kinds.", enhancedInputSchemas.trace_feature),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ANALYSIS_ERROR_TAGS } from "../domain/errors.js";
|
|
2
3
|
import { evidenceSchema } from "../domain/evidence.js";
|
|
3
4
|
import { processCaptureComparisonSchema, processCaptureSchema, } from "../domain/processCapture.js";
|
|
4
5
|
import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
|
|
@@ -69,12 +70,14 @@ const providerCapability = z.object({
|
|
|
69
70
|
}),
|
|
70
71
|
limitations: z.array(z.string()),
|
|
71
72
|
});
|
|
73
|
+
const providerIdentity = z.object({
|
|
74
|
+
id: z.string(),
|
|
75
|
+
name: z.string(),
|
|
76
|
+
version: z.string().nullable(),
|
|
77
|
+
});
|
|
72
78
|
const sessionProvider = z.object({
|
|
73
|
-
provider:
|
|
74
|
-
|
|
75
|
-
name: z.string(),
|
|
76
|
-
version: z.string().nullable(),
|
|
77
|
-
}),
|
|
79
|
+
provider: providerIdentity,
|
|
80
|
+
providers: z.array(providerIdentity),
|
|
78
81
|
capabilities: z.array(providerCapability),
|
|
79
82
|
});
|
|
80
83
|
const nullableText = z.string().nullable();
|
|
@@ -147,9 +150,24 @@ const symbolDiscoveryOutput = (property) => resultOf(z.object({
|
|
|
147
150
|
count: z.number().int().min(0),
|
|
148
151
|
[property]: z.array(addressedEntry),
|
|
149
152
|
}));
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
const analysisErrorProjectionSchema = z
|
|
154
|
+
.object({
|
|
155
|
+
tag: z.enum(ANALYSIS_ERROR_TAGS),
|
|
156
|
+
message: z.string(),
|
|
157
|
+
details: z.record(z.string(), z.union([z.string(), z.number(), z.null()])),
|
|
158
|
+
})
|
|
159
|
+
.strict();
|
|
160
|
+
const graphNode = z.discriminatedUnion("status", [
|
|
161
|
+
z.object({
|
|
162
|
+
address: z.string(),
|
|
163
|
+
status: z.literal("ok"),
|
|
164
|
+
calls: z.array(z.string()),
|
|
165
|
+
}),
|
|
166
|
+
z.object({
|
|
167
|
+
address: z.string(),
|
|
168
|
+
status: z.literal("error"),
|
|
169
|
+
error: analysisErrorProjectionSchema,
|
|
170
|
+
}),
|
|
153
171
|
]);
|
|
154
172
|
const functionDossierOutput = resultOf(functionDossierSchema);
|
|
155
173
|
/** Exact structured-content schemas for the direct Hopper operations. */
|
|
@@ -205,10 +223,23 @@ export const enhancedOutputSchemas = {
|
|
|
205
223
|
swift_classes: symbolDiscoveryOutput("classes"),
|
|
206
224
|
get_objc_classes: symbolDiscoveryOutput("classes"),
|
|
207
225
|
get_objc_protocols: symbolDiscoveryOutput("protocols"),
|
|
208
|
-
batch_decompile: resultOf(z.
|
|
209
|
-
z.
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
batch_decompile: resultOf(z.object({
|
|
227
|
+
items: z.array(z.discriminatedUnion("status", [
|
|
228
|
+
z.object({
|
|
229
|
+
address: z.string(),
|
|
230
|
+
status: z.literal("ok"),
|
|
231
|
+
pseudocode: z.string().min(1),
|
|
232
|
+
}),
|
|
233
|
+
z.object({
|
|
234
|
+
address: z.string(),
|
|
235
|
+
status: z.literal("error"),
|
|
236
|
+
error: analysisErrorProjectionSchema,
|
|
237
|
+
}),
|
|
238
|
+
])),
|
|
239
|
+
total: z.number().int().min(0),
|
|
240
|
+
succeeded: z.number().int().min(0),
|
|
241
|
+
failed: z.number().int().min(0),
|
|
242
|
+
})),
|
|
212
243
|
get_call_graph: resultOf(z.record(z.string(), z.array(graphNode))),
|
|
213
244
|
analyze_swift_types: resultOf(z.object({
|
|
214
245
|
total: z.number().int().min(0),
|
|
@@ -217,9 +248,18 @@ export const enhancedOutputSchemas = {
|
|
|
217
248
|
items: z.array(addressedEntry),
|
|
218
249
|
})),
|
|
219
250
|
})),
|
|
220
|
-
find_xrefs_to_name: resultOf(z.
|
|
221
|
-
z.object({
|
|
222
|
-
|
|
251
|
+
find_xrefs_to_name: resultOf(z.discriminatedUnion("status", [
|
|
252
|
+
z.object({
|
|
253
|
+
status: z.literal("resolved"),
|
|
254
|
+
name: z.string(),
|
|
255
|
+
address: z.string(),
|
|
256
|
+
xrefs: addressList,
|
|
257
|
+
}),
|
|
258
|
+
z.object({
|
|
259
|
+
status: z.literal("unresolved"),
|
|
260
|
+
name: z.string(),
|
|
261
|
+
reason: z.literal("name_not_found"),
|
|
262
|
+
}),
|
|
223
263
|
])),
|
|
224
264
|
binary_overview: resultOf(z.object({
|
|
225
265
|
document: z.string(),
|
package/dist/domain/errors.js
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/** Stable tags exposed by safe analysis-error projections. */
|
|
2
|
+
export const ANALYSIS_ERROR_TAGS = [
|
|
3
|
+
"AnalysisProtocolError",
|
|
4
|
+
"AnalysisInputError",
|
|
5
|
+
"AnalysisOutputError",
|
|
6
|
+
"AnalysisCapabilityUnavailableError",
|
|
7
|
+
"AnalysisCancelledError",
|
|
8
|
+
"AnalysisTimeoutError",
|
|
9
|
+
"ProviderSelectionError",
|
|
10
|
+
"ProviderAdapterError",
|
|
11
|
+
"ArtifactOperationError",
|
|
12
|
+
"ProcessCaptureError",
|
|
13
|
+
"EvidenceIntegrityError",
|
|
14
|
+
"EvidenceLimitError",
|
|
15
|
+
"EvidenceFileError",
|
|
16
|
+
"UnknownRegistryError",
|
|
17
|
+
"HopperTimeoutError",
|
|
18
|
+
"HopperCancelledError",
|
|
19
|
+
"HopperProtocolError",
|
|
20
|
+
"HopperRemoteError",
|
|
21
|
+
"HopperProcessError",
|
|
22
|
+
"HopperStartError",
|
|
23
|
+
"ConfigurationError",
|
|
24
|
+
"NoBinaryOpenError",
|
|
25
|
+
"BinaryTargetError",
|
|
26
|
+
];
|
|
1
27
|
/** Base class for expected analysis, provider, and session failures. */
|
|
2
28
|
export class AnalysisError extends Error {
|
|
3
29
|
}
|
|
@@ -151,11 +177,13 @@ export class HopperProtocolError extends HopperError {
|
|
|
151
177
|
export class HopperRemoteError extends HopperError {
|
|
152
178
|
code;
|
|
153
179
|
safeMessage;
|
|
180
|
+
diagnosticType;
|
|
154
181
|
_tag = "HopperRemoteError";
|
|
155
|
-
constructor(code, safeMessage) {
|
|
182
|
+
constructor(code, safeMessage, diagnosticType = "remote") {
|
|
156
183
|
super(`Hopper request failed (${String(code)}): ${safeMessage}`);
|
|
157
184
|
this.code = code;
|
|
158
185
|
this.safeMessage = safeMessage;
|
|
186
|
+
this.diagnosticType = diagnosticType;
|
|
159
187
|
}
|
|
160
188
|
}
|
|
161
189
|
/** The owned Hopper bridge stopped before its client was closed. */
|
|
@@ -233,7 +261,11 @@ const safeDetails = (error) => {
|
|
|
233
261
|
if (error instanceof HopperTimeoutError)
|
|
234
262
|
return { timeoutMs: error.timeoutMs };
|
|
235
263
|
if (error instanceof HopperRemoteError)
|
|
236
|
-
return {
|
|
264
|
+
return {
|
|
265
|
+
code: error.code,
|
|
266
|
+
safeMessage: error.safeMessage,
|
|
267
|
+
diagnosticType: error.diagnosticType,
|
|
268
|
+
};
|
|
237
269
|
if (error instanceof HopperProcessError)
|
|
238
270
|
return { exitCode: error.exitCode };
|
|
239
271
|
return {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { AnalysisOutputError, HopperProtocolError } from "./errors.js";
|
|
3
3
|
import { err, ok } from "./result.js";
|
|
4
|
+
/** Return the non-negative byte distance between hexadecimal addresses. */
|
|
5
|
+
export const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
|
|
4
6
|
const procedureMapSchema = z.record(z.string(), z.string());
|
|
5
7
|
const addressedNamesSchema = z.array(z.object({ address: z.string(), name: z.string() }));
|
|
6
8
|
const addressedNameMapSchema = z.record(z.string(), z.string());
|
|
@@ -12,10 +14,30 @@ const segmentSchema = z.object({
|
|
|
12
14
|
writable: z.boolean().nullable().default(null),
|
|
13
15
|
executable: z.boolean().nullable().default(null),
|
|
14
16
|
});
|
|
15
|
-
const addressedPageSchema = z
|
|
17
|
+
const addressedPageSchema = z
|
|
18
|
+
.object({
|
|
16
19
|
items: z.array(z.object({ address: z.string(), value: z.string() })),
|
|
20
|
+
offset: z.number().int().min(0),
|
|
21
|
+
limit: z.number().int().min(1),
|
|
22
|
+
total: z.number().int().min(0),
|
|
17
23
|
next_offset: z.number().int().min(0).nullable(),
|
|
18
24
|
has_more: z.boolean(),
|
|
25
|
+
})
|
|
26
|
+
.superRefine((value, context) => {
|
|
27
|
+
if (value.has_more && value.next_offset === null) {
|
|
28
|
+
context.addIssue({
|
|
29
|
+
code: "custom",
|
|
30
|
+
message: "a page with more results must provide next_offset",
|
|
31
|
+
path: ["next_offset"],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (!value.has_more && value.next_offset !== null) {
|
|
35
|
+
context.addIssue({
|
|
36
|
+
code: "custom",
|
|
37
|
+
message: "a complete page must not provide next_offset",
|
|
38
|
+
path: ["next_offset"],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
19
41
|
});
|
|
20
42
|
const unavailableSchema = z
|
|
21
43
|
.object({ available: z.literal(false), reason: z.string() })
|
|
@@ -94,21 +94,13 @@ export class HopperApplicationLauncher {
|
|
|
94
94
|
await writeFileAtomic(join(session.directory, "ownership.json"), `${JSON.stringify(ownership)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
95
95
|
}
|
|
96
96
|
catch (cause) {
|
|
97
|
-
await cleanupOwnedProcessGroup(
|
|
98
|
-
runId: session.runId,
|
|
99
|
-
leaderPid: pid,
|
|
100
|
-
processGroupId: pid,
|
|
101
|
-
});
|
|
97
|
+
await cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, this.options.launcherPath));
|
|
102
98
|
return err(new HopperStartError({ cause }));
|
|
103
99
|
}
|
|
104
100
|
return ok({
|
|
105
101
|
process: child,
|
|
106
102
|
ownsProcessLifetime: true,
|
|
107
|
-
cleanup: () => cleanupOwnedProcessGroup(
|
|
108
|
-
runId: session.runId,
|
|
109
|
-
leaderPid: pid,
|
|
110
|
-
processGroupId: pid,
|
|
111
|
-
}),
|
|
103
|
+
cleanup: () => cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, this.options.launcherPath)),
|
|
112
104
|
});
|
|
113
105
|
}
|
|
114
106
|
catch (cause) {
|
|
@@ -116,6 +108,13 @@ export class HopperApplicationLauncher {
|
|
|
116
108
|
}
|
|
117
109
|
}
|
|
118
110
|
}
|
|
111
|
+
const ownedProcessGroup = (session, pid, launcherPath) => ({
|
|
112
|
+
runId: session.runId,
|
|
113
|
+
leaderPid: pid,
|
|
114
|
+
processGroupId: pid,
|
|
115
|
+
expectedCommand: launcherPath,
|
|
116
|
+
expectedParentPid: process.pid,
|
|
117
|
+
});
|
|
119
118
|
const prepareHopperApplication = async (launcherPath, signal) => {
|
|
120
119
|
const appBundle = hopperApplicationBundle(launcherPath);
|
|
121
120
|
if (appBundle === undefined)
|
package/dist/hopper/protocol.js
CHANGED
|
@@ -9,7 +9,18 @@ const responseSchema = z.union([
|
|
|
9
9
|
}),
|
|
10
10
|
z.object({
|
|
11
11
|
id: z.number().int().nonnegative(),
|
|
12
|
-
error: z.object({
|
|
12
|
+
error: z.object({
|
|
13
|
+
code: z.number().int(),
|
|
14
|
+
message: z.string(),
|
|
15
|
+
type: z
|
|
16
|
+
.enum([
|
|
17
|
+
"remote",
|
|
18
|
+
"authorization",
|
|
19
|
+
"invalid_request",
|
|
20
|
+
"bridge_exception",
|
|
21
|
+
])
|
|
22
|
+
.default("remote"),
|
|
23
|
+
}),
|
|
13
24
|
}),
|
|
14
25
|
]);
|
|
15
26
|
/** Parse one complete Hopper NDJSON response line. */
|
|
@@ -28,5 +39,5 @@ export const parseResponseLine = (line) => {
|
|
|
28
39
|
};
|
|
29
40
|
/** Project a parsed response into its result or expected remote failure. */
|
|
30
41
|
export const responseResult = (response) => "error" in response
|
|
31
|
-
? err(new HopperRemoteError(response.error.code, response.error.message))
|
|
42
|
+
? err(new HopperRemoteError(response.error.code, response.error.message, response.error.type))
|
|
32
43
|
: ok(response.result);
|
|
@@ -81,6 +81,8 @@ class NativeMacOSClient {
|
|
|
81
81
|
this.runner = runner;
|
|
82
82
|
}
|
|
83
83
|
async execute(operation, parameters, options) {
|
|
84
|
+
if (options?.signal?.aborted === true)
|
|
85
|
+
return err(new AnalysisCancelledError(operation));
|
|
84
86
|
if (operation === "health")
|
|
85
87
|
return ok(createAnalysisExecution(null, IDENTITY));
|
|
86
88
|
if (!isNativeOperation(operation))
|