rea-agents 0.2.1

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bridge/hopper_bridge.py +557 -0
  4. package/dist/application/AnalysisProvider.js +1 -0
  5. package/dist/application/BinarySession.js +173 -0
  6. package/dist/application/DirectAnalysis.js +31 -0
  7. package/dist/application/Doctor.js +110 -0
  8. package/dist/application/EnhancedTools.js +357 -0
  9. package/dist/application/EvidenceLedger.js +65 -0
  10. package/dist/application/HopperToolPort.js +1 -0
  11. package/dist/application/LoopbackReplay.js +84 -0
  12. package/dist/application/ProcessHarness.js +347 -0
  13. package/dist/application/Setup.js +240 -0
  14. package/dist/application/homebrew.js +19 -0
  15. package/dist/application/runtime.js +11 -0
  16. package/dist/cli.js +88 -0
  17. package/dist/config.js +86 -0
  18. package/dist/contracts/enhancedInputs.js +73 -0
  19. package/dist/contracts/toolContracts.js +144 -0
  20. package/dist/contracts/toolOutputSchemas.js +267 -0
  21. package/dist/domain/binaryTarget.js +321 -0
  22. package/dist/domain/errors.js +81 -0
  23. package/dist/domain/evidence.js +146 -0
  24. package/dist/domain/evidenceBundle.js +11 -0
  25. package/dist/domain/hopperValues.js +253 -0
  26. package/dist/domain/jsonValue.js +3 -0
  27. package/dist/domain/processCapture.js +252 -0
  28. package/dist/domain/result.js +4 -0
  29. package/dist/domain/symbolAnalysis.js +68 -0
  30. package/dist/hopper/BridgeLauncher.js +130 -0
  31. package/dist/hopper/HopperClient.js +314 -0
  32. package/dist/hopper/HopperProvider.js +66 -0
  33. package/dist/hopper/protocol.js +32 -0
  34. package/dist/identity.js +14 -0
  35. package/dist/logger.js +27 -0
  36. package/dist/main.js +81 -0
  37. package/dist/server/createServer.js +30 -0
  38. package/dist/server/registerEnhancedTools.js +53 -0
  39. package/dist/server/registerOfficialTools.js +59 -0
  40. package/dist/server/registerSessionTools.js +159 -0
  41. package/dist/server/toolLogging.js +11 -0
  42. package/dist/server/toolResult.js +44 -0
  43. package/package.json +109 -0
  44. package/scripts/rea.mjs +15 -0
  45. package/scripts/rebuild-native.mjs +22 -0
  46. package/skills/rea-analysis/SKILL.md +44 -0
@@ -0,0 +1,173 @@
1
+ import { parseBinaryTarget } from "../domain/binaryTarget.js";
2
+ import { HopperCancelledError, NoBinaryOpenError, } from "../domain/errors.js";
3
+ import { err, ok } from "../domain/result.js";
4
+ import { EvidenceLedger } from "./EvidenceLedger.js";
5
+ /**
6
+ * Owns the single active target shared by CLI and MCP adapters.
7
+ *
8
+ * Target transitions are serialized because each client dispatches Hopper API
9
+ * work on its dedicated Python thread, and switching targets tears that bridge
10
+ * down. A failed switch recreates the previous target instead of retaining a
11
+ * client whose bridge was already shut down.
12
+ */
13
+ export class BinarySession {
14
+ provider;
15
+ #active;
16
+ #transition = Promise.resolve();
17
+ #calls = new Set();
18
+ #createClient;
19
+ #providerIdentity;
20
+ #evidence = new EvidenceLedger({ maxRecords: 10_000 });
21
+ constructor(provider) {
22
+ this.provider = provider;
23
+ this.#createClient =
24
+ typeof provider === "function"
25
+ ? provider
26
+ : (target) => provider.createClient(target);
27
+ this.#providerIdentity =
28
+ typeof provider === "function"
29
+ ? { id: "unidentified", name: "Unidentified provider", version: null }
30
+ : provider.identity();
31
+ }
32
+ /** Identify the provider producing evidence for this session. */
33
+ providerIdentity() {
34
+ return this.#providerIdentity;
35
+ }
36
+ /** Add one successful public observation to the session ledger. */
37
+ recordEvidence(evidence) {
38
+ this.#evidence.record(evidence);
39
+ }
40
+ /** Return a deterministic snapshot without clearing session evidence. */
41
+ exportEvidenceBundle() {
42
+ return this.#evidence.export();
43
+ }
44
+ /** Atomically merge a validated evidence bundle into this session. */
45
+ importEvidenceBundle(bundle) {
46
+ return this.#evidence.import(bundle);
47
+ }
48
+ /**
49
+ * Open or switch targets after draining calls against the current target.
50
+ * Returns the switch failure even if best-effort restoration also fails.
51
+ */
52
+ open(path, options = {}) {
53
+ return this.#serialize(async () => {
54
+ if (isAborted(options.signal))
55
+ return err(new HopperCancelledError());
56
+ const parsed = await parseBinaryTarget(path, process.cwd(), process.arch, options.targetKind);
57
+ if (!parsed.ok)
58
+ return parsed;
59
+ if (isAborted(options.signal))
60
+ return err(new HopperCancelledError());
61
+ if (this.#active?.target.path === parsed.value.path)
62
+ return ok(parsed.value);
63
+ await this.#drainCalls();
64
+ const previous = this.#active;
65
+ this.#active = undefined;
66
+ await previous?.client.close();
67
+ const client = this.#createClient(parsed.value);
68
+ const started = await client.execute("health", {}, options);
69
+ if (!started.ok) {
70
+ await client.close();
71
+ await this.#restore(previous);
72
+ return started;
73
+ }
74
+ if (isAborted(options.signal)) {
75
+ await client.close();
76
+ await this.#restore(previous);
77
+ return err(new HopperCancelledError());
78
+ }
79
+ this.#active = { target: parsed.value, client };
80
+ return ok(parsed.value);
81
+ });
82
+ }
83
+ /** Close the active target, if any. */
84
+ close() {
85
+ return this.#serialize(async () => {
86
+ const previous = this.#active;
87
+ this.#active = undefined;
88
+ await this.#drainCalls();
89
+ await previous?.client.close();
90
+ this.#evidence.clear();
91
+ return ok(null);
92
+ });
93
+ }
94
+ /** Describe the active binary session. */
95
+ status() {
96
+ const target = this.#active?.target;
97
+ return target === undefined
98
+ ? { open: false }
99
+ : {
100
+ open: true,
101
+ path: target.path,
102
+ sha256: target.sha256,
103
+ format: target.format,
104
+ kind: target.kind,
105
+ architecture: target.architecture ?? null,
106
+ };
107
+ }
108
+ /** Return the immutable artifact identity captured before Hopper launched. */
109
+ activeTarget() {
110
+ return this.#active?.target;
111
+ }
112
+ /**
113
+ * Invoke a Hopper tool against the active target.
114
+ * Calls may overlap, but a pending target transition prevents new calls from
115
+ * entering until the transition has settled.
116
+ */
117
+ async execute(name, arguments_, options) {
118
+ const transitioned = await this.#waitForTransition(options?.signal);
119
+ if (!transitioned.ok)
120
+ return transitioned;
121
+ const active = this.#active;
122
+ if (active === undefined)
123
+ return err(new NoBinaryOpenError());
124
+ const call = active.client.execute(name, arguments_, options);
125
+ this.#calls.add(call);
126
+ try {
127
+ return await call;
128
+ }
129
+ finally {
130
+ this.#calls.delete(call);
131
+ }
132
+ }
133
+ #serialize(operation) {
134
+ const result = this.#transition.then(operation, operation);
135
+ this.#transition = result.then(() => undefined, () => undefined);
136
+ return result;
137
+ }
138
+ async #drainCalls() {
139
+ await Promise.allSettled(this.#calls);
140
+ }
141
+ async #restore(previous) {
142
+ if (previous === undefined)
143
+ return;
144
+ const client = this.#createClient(previous.target);
145
+ const started = await client.execute("health", {});
146
+ if (started.ok)
147
+ this.#active = { target: previous.target, client };
148
+ else
149
+ await client.close();
150
+ }
151
+ async #waitForTransition(signal) {
152
+ if (signal?.aborted === true)
153
+ return err(new HopperCancelledError());
154
+ if (signal === undefined) {
155
+ await this.#transition;
156
+ return ok(undefined);
157
+ }
158
+ return new Promise((resolve) => {
159
+ const onAbort = () => {
160
+ resolve(err(new HopperCancelledError()));
161
+ };
162
+ signal.addEventListener("abort", onAbort, { once: true });
163
+ this.#transition.then(() => {
164
+ signal.removeEventListener("abort", onAbort);
165
+ resolve(signal.aborted ? err(new HopperCancelledError()) : ok(undefined));
166
+ }, () => {
167
+ signal.removeEventListener("abort", onAbort);
168
+ resolve(signal.aborted ? err(new HopperCancelledError()) : ok(undefined));
169
+ });
170
+ });
171
+ }
172
+ }
173
+ const isAborted = (signal) => signal?.aborted === true;
@@ -0,0 +1,31 @@
1
+ import { parseConfig } from "../config.js";
2
+ import { createBinarySession } from "./runtime.js";
3
+ import { silentLogger } from "../logger.js";
4
+ import { createEvidence } from "../domain/evidence.js";
5
+ /**
6
+ * Open one binary, execute one tool, and always release the bridge session.
7
+ * Unlike MCP mode, every CLI invocation is intentionally isolated and does not
8
+ * retain a target or bridge resources for a subsequent command.
9
+ */
10
+ export const runDirectAnalysis = async (path, tool, arguments_, logger = silentLogger) => {
11
+ const config = parseConfig(process.env);
12
+ if (!config.ok)
13
+ return { error: config.error._tag, message: config.error.message };
14
+ const session = createBinarySession(config.value, logger);
15
+ try {
16
+ const opened = await session.open(path);
17
+ if (!opened.ok)
18
+ return { error: opened.error._tag, message: opened.error.message };
19
+ const result = await session.execute(tool, arguments_);
20
+ return result.ok
21
+ ? createEvidence(opened.value, session.providerIdentity(), {
22
+ operation: tool,
23
+ parameters: arguments_,
24
+ result: result.value,
25
+ })
26
+ : { error: result.error._tag, message: result.error.message };
27
+ }
28
+ finally {
29
+ await session.close();
30
+ }
31
+ };
@@ -0,0 +1,110 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { readdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { parseBinaryTarget } from "../domain/binaryTarget.js";
9
+ import { probeHomebrew } from "./homebrew.js";
10
+ const execFileAsync = promisify(execFile);
11
+ const DEFAULT_HOPPER = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
12
+ /**
13
+ * Check requirements and an optional target without mutating the host.
14
+ * Every failed check includes remediation suitable for either the one-shot CLI
15
+ * or an agent consuming the structured result.
16
+ */
17
+ export const runDoctor = async (target, host = systemDoctorHost()) => {
18
+ const checks = [];
19
+ const nodeMajor = parseMajor(host.nodeVersion);
20
+ checks.push(check("node", nodeMajor >= 22, host.nodeVersion, "Install Node.js 22 or newer."));
21
+ const macosVersion = host.platform === "darwin" ? await host.macosVersion() : undefined;
22
+ const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
23
+ checks.push(check("macos", host.platform === "darwin" && macosMajor >= 12, macosVersion ?? host.platform, "Hopper requires macOS 12 or newer."));
24
+ const candidates = [
25
+ host.configuredHopperPath,
26
+ DEFAULT_HOPPER,
27
+ ...(await host.manualHopperPaths()),
28
+ await host.brewHopperPath(),
29
+ ].filter((value) => value !== undefined);
30
+ let hopperPath;
31
+ for (const candidate of new Set(candidates))
32
+ if (await host.executable(candidate)) {
33
+ hopperPath = candidate;
34
+ break;
35
+ }
36
+ checks.push(check("hopper", hopperPath !== undefined, hopperPath, "Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH."));
37
+ if (target !== undefined)
38
+ checks.push(check("target", await host.validTarget(target), target, "Supply a readable local app or program path."));
39
+ return {
40
+ healthy: checks.every(({ ok }) => ok),
41
+ ...(hopperPath === undefined ? {} : { hopperPath }),
42
+ checks,
43
+ };
44
+ };
45
+ /** Create diagnostics backed by the current process and host commands. */
46
+ export const systemDoctorHost = () => ({
47
+ platform: process.platform,
48
+ nodeVersion: process.versions.node,
49
+ ...(process.env.HOPPER_LAUNCHER_PATH === undefined
50
+ ? {}
51
+ : { configuredHopperPath: process.env.HOPPER_LAUNCHER_PATH }),
52
+ async macosVersion() {
53
+ try {
54
+ return (await execFileAsync("sw_vers", ["-productVersion"])).stdout.trim();
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ },
60
+ async validTarget(path) {
61
+ return (await parseBinaryTarget(path)).ok;
62
+ },
63
+ async executable(path) {
64
+ try {
65
+ await access(path, constants.X_OK);
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ },
72
+ async brewHopperPath() {
73
+ return probeHomebrew(async (command) => {
74
+ try {
75
+ const prefix = (await execFileAsync(command, [
76
+ "--prefix",
77
+ "--cask",
78
+ "hopper-disassembler",
79
+ ])).stdout.trim();
80
+ return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ });
86
+ },
87
+ async manualHopperPaths() {
88
+ const roots = ["/Applications", join(homedir(), "Applications")];
89
+ const paths = [];
90
+ for (const root of roots) {
91
+ try {
92
+ for (const entry of await readdir(root, { withFileTypes: true })) {
93
+ if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
94
+ paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
95
+ }
96
+ }
97
+ catch {
98
+ // A missing or unreadable optional application directory is not fatal.
99
+ }
100
+ }
101
+ return paths;
102
+ },
103
+ });
104
+ const parseMajor = (version) => Number.parseInt(version.split(".")[0] ?? "0", 10);
105
+ const check = (name, ok, detail, remediation) => ({
106
+ name,
107
+ ok,
108
+ ...(detail === undefined ? {} : { detail }),
109
+ ...(ok ? {} : { remediation }),
110
+ });
@@ -0,0 +1,357 @@
1
+ import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
2
+ import { AnalysisProtocolError } from "../domain/errors.js";
3
+ import { parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
4
+ import { err, ok } from "../domain/result.js";
5
+ import { categorizeSwiftTypes, discoverObjcClasses, discoverObjcProtocols, discoverSwiftClasses, } from "../domain/symbolAnalysis.js";
6
+ /**
7
+ * Composes official Hopper operations into bounded reverse-engineering tools.
8
+ * Inputs are parsed again at this application boundary even though MCP normally
9
+ * validates them, allowing direct callers to use the same service safely.
10
+ */
11
+ export class EnhancedTools {
12
+ analysis;
13
+ constructor(analysis) {
14
+ this.analysis = analysis;
15
+ }
16
+ /** Parse inputs again at the direct-call boundary, then dispatch exhaustively. */
17
+ execute(name, input, signal) {
18
+ switch (name) {
19
+ case "swift_classes": {
20
+ const parsed = enhancedInputSchemas.swift_classes.safeParse(input);
21
+ return parsed.success
22
+ ? this.#swiftClasses(parsed.data.pattern, signal)
23
+ : invalidInput(name, parsed.error);
24
+ }
25
+ case "get_objc_classes": {
26
+ const parsed = enhancedInputSchemas.get_objc_classes.safeParse(input);
27
+ return parsed.success
28
+ ? this.#objcClasses(parsed.data.pattern, signal)
29
+ : invalidInput(name, parsed.error);
30
+ }
31
+ case "get_objc_protocols":
32
+ return this.#objcProtocols(signal);
33
+ case "batch_decompile": {
34
+ const parsed = enhancedInputSchemas.batch_decompile.safeParse(input);
35
+ return parsed.success
36
+ ? this.#batchDecompile(parsed.data.addresses, signal)
37
+ : invalidInput(name, parsed.error);
38
+ }
39
+ case "get_call_graph": {
40
+ const parsed = enhancedInputSchemas.get_call_graph.safeParse(input);
41
+ return parsed.success
42
+ ? this.#callGraph(parsed.data, signal)
43
+ : invalidInput(name, parsed.error);
44
+ }
45
+ case "analyze_swift_types":
46
+ return this.#analyzeSwiftTypes(signal);
47
+ case "find_xrefs_to_name": {
48
+ const parsed = enhancedInputSchemas.find_xrefs_to_name.safeParse(input);
49
+ return parsed.success
50
+ ? this.#findXrefs(parsed.data.name, signal)
51
+ : invalidInput(name, parsed.error);
52
+ }
53
+ case "binary_overview": {
54
+ const parsed = enhancedInputSchemas.binary_overview.safeParse(input);
55
+ return parsed.success
56
+ ? this.#binaryOverview(parsed.data, signal)
57
+ : invalidInput(name, parsed.error);
58
+ }
59
+ case "analyze_function": {
60
+ const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
61
+ return parsed.success
62
+ ? this.#analyzeFunction(parsed.data, signal)
63
+ : invalidInput(name, parsed.error);
64
+ }
65
+ case "trace_feature": {
66
+ const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
67
+ return parsed.success
68
+ ? this.#traceFeature(parsed.data, signal)
69
+ : invalidInput(name, parsed.error);
70
+ }
71
+ }
72
+ }
73
+ async #analyzeFunction(input, signal) {
74
+ const result = await this.#call("analyze_function", input, signal);
75
+ return result.ok ? parseFunctionDossier(result.value) : result;
76
+ }
77
+ async #swiftClasses(pattern, signal) {
78
+ const procedures = await this.#allProcedures(signal);
79
+ return procedures.ok
80
+ ? ok(discoverSwiftClasses(procedures.value, pattern))
81
+ : procedures;
82
+ }
83
+ async #objcClasses(pattern, signal) {
84
+ const names = await this.#allAddressed("list_names", signal);
85
+ return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
86
+ }
87
+ async #objcProtocols(signal) {
88
+ const names = await this.#allAddressed("list_names", signal);
89
+ return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
90
+ }
91
+ async #batchDecompile(addresses, signal) {
92
+ if (addresses.length === 0)
93
+ return ok({ error: "No addresses provided" });
94
+ const entries = await Promise.all(addresses.map(async (address) => {
95
+ const result = await this.#call("procedure_pseudo_code", { procedure: address }, signal);
96
+ return [
97
+ address,
98
+ result.ok
99
+ ? result.value === null || result.value === ""
100
+ ? "No output"
101
+ : result.value
102
+ : `Error: ${result.error.message}`,
103
+ ];
104
+ }));
105
+ return ok(Object.fromEntries(entries));
106
+ }
107
+ async #callGraph(input, signal) {
108
+ const relation = input.direction === "forward" ? "callees" : "callers";
109
+ const tool = input.direction === "forward" ? "procedure_callees" : "procedure_callers";
110
+ const discovered = new Set([input.address]);
111
+ const queue = [
112
+ { address: input.address, depth: 0 },
113
+ ];
114
+ let queueIndex = 0;
115
+ const graph = {};
116
+ while (queueIndex < queue.length) {
117
+ const current = queue[queueIndex++];
118
+ if (current === undefined || current.depth >= input.depth) {
119
+ continue;
120
+ }
121
+ const level = String(current.depth);
122
+ graph[level] ??= [];
123
+ const result = await this.#call(tool, { procedure: current.address }, signal);
124
+ if (!result.ok) {
125
+ graph[level].push({
126
+ address: current.address,
127
+ error: result.error.message,
128
+ });
129
+ continue;
130
+ }
131
+ const related = parseRelatedAddresses(result.value, relation);
132
+ if (!related.ok) {
133
+ graph[level].push({
134
+ address: current.address,
135
+ error: related.error.message,
136
+ });
137
+ continue;
138
+ }
139
+ graph[level].push({
140
+ address: current.address,
141
+ calls: [...related.value],
142
+ });
143
+ if (current.depth + 1 < input.depth) {
144
+ for (const address of related.value) {
145
+ if (!discovered.has(address)) {
146
+ discovered.add(address);
147
+ queue.push({ address, depth: current.depth + 1 });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return ok(graph);
153
+ }
154
+ async #analyzeSwiftTypes(signal) {
155
+ const procedures = await this.#allProcedures(signal);
156
+ return procedures.ok
157
+ ? ok(categorizeSwiftTypes(procedures.value))
158
+ : procedures;
159
+ }
160
+ async #findXrefs(name, signal) {
161
+ const resolved = await this.#call("address_name", { address: name }, signal);
162
+ if (!resolved.ok)
163
+ return resolved;
164
+ const address = resolveAddress(resolved.value);
165
+ if (address === undefined)
166
+ return ok({ error: `Could not resolve name: ${name}` });
167
+ const xrefs = await this.#call("xrefs", { address }, signal);
168
+ if (!xrefs.ok)
169
+ return xrefs;
170
+ return ok(Array.isArray(xrefs.value) ? { xrefs: xrefs.value } : xrefs.value);
171
+ }
172
+ async #binaryOverview(input, signal) {
173
+ const [segmentsResult, documentsResult, procedures, stringsResult] = await Promise.all([
174
+ this.#call("list_segments", {}, signal),
175
+ this.#call("list_documents", {}, signal),
176
+ this.#allProcedures(signal),
177
+ this.#call("list_strings", {}, signal),
178
+ ]);
179
+ if (!segmentsResult.ok)
180
+ return segmentsResult;
181
+ if (!documentsResult.ok)
182
+ return documentsResult;
183
+ if (!procedures.ok)
184
+ return procedures;
185
+ if (!stringsResult.ok)
186
+ return stringsResult;
187
+ const segments = parseSegments(segmentsResult.value);
188
+ if (!segments.ok)
189
+ return segments;
190
+ const documents = parseDocuments(documentsResult.value);
191
+ if (!documents.ok)
192
+ return documents;
193
+ const stringCount = parseListCount(stringsResult.value, "strings");
194
+ if (!stringCount.ok)
195
+ return stringCount;
196
+ return ok({
197
+ document: documents.value[0] ?? "unknown",
198
+ detail: input.detail,
199
+ segments: segments.value
200
+ .slice(0, input.limit)
201
+ .map(({ name, start, end }) => input.detail === "detailed"
202
+ ? { name, start, end, length: addressDistance(start, end) }
203
+ : { name, start, end }),
204
+ segment_count: segments.value.length,
205
+ procedure_count: procedures.value.length,
206
+ string_count: stringCount.value,
207
+ });
208
+ }
209
+ async #traceFeature(input, signal) {
210
+ const searched = await this.#literalMatches(input, signal);
211
+ if (!searched.ok)
212
+ return searched;
213
+ const residual = new Set();
214
+ const traced = await this.#traceReferences(searched.value.matches, input.limit, input.max_operations - searched.value.operations, signal);
215
+ if (!traced.ok)
216
+ return traced;
217
+ for (const reason of traced.value.residual)
218
+ residual.add(reason);
219
+ const operations = searched.value.operations + traced.value.operations;
220
+ if (operations >= input.max_operations)
221
+ residual.add("Investigation reached the operation budget.");
222
+ if (searched.value.matches.length >= input.limit)
223
+ residual.add("Literal matches reached the configured limit.");
224
+ return ok({
225
+ query: input.query,
226
+ search_mode: "literal",
227
+ operations_used: operations,
228
+ operation_budget: input.max_operations,
229
+ matches: searched.value.matches,
230
+ references: traced.value.references,
231
+ truncated: residual.size > 0,
232
+ residual_unknowns: [...residual],
233
+ });
234
+ }
235
+ async #literalMatches(input, signal) {
236
+ let operations = 0;
237
+ const matches = [];
238
+ const needle = input.case_sensitive
239
+ ? input.query
240
+ : input.query.toLowerCase();
241
+ for (const [tool, type] of [
242
+ ["list_strings", "string"],
243
+ ["list_procedures", "procedure"],
244
+ ]) {
245
+ let offset = 0;
246
+ while (operations < input.max_operations &&
247
+ matches.length < input.limit) {
248
+ operations += 1;
249
+ const result = await this.#call(tool, { offset, limit: 500 }, signal);
250
+ if (!result.ok)
251
+ return result;
252
+ const page = parseAddressedPage(result.value);
253
+ if (!page.ok)
254
+ return page;
255
+ for (const item of page.value.items) {
256
+ const haystack = input.case_sensitive
257
+ ? item.name
258
+ : item.name.toLowerCase();
259
+ if (haystack.includes(needle))
260
+ matches.push({ type, address: item.address, value: item.name });
261
+ if (matches.length >= input.limit)
262
+ break;
263
+ }
264
+ if (!page.value.hasMore || page.value.nextOffset === null)
265
+ break;
266
+ offset = page.value.nextOffset;
267
+ }
268
+ }
269
+ return ok({ matches, operations });
270
+ }
271
+ async #traceReferences(matches, limit, operationBudget, signal) {
272
+ let operations = 0;
273
+ const references = [];
274
+ const residual = new Set();
275
+ for (const match of matches) {
276
+ if (operations >= operationBudget) {
277
+ residual.add("Reference traversal stopped at the operation budget.");
278
+ break;
279
+ }
280
+ operations += 1;
281
+ const xrefs = await this.#call("xrefs", { address: match.address }, signal);
282
+ if (!xrefs.ok)
283
+ return xrefs;
284
+ if (!Array.isArray(xrefs.value))
285
+ return err(new AnalysisProtocolError("xrefs returned a non-array result"));
286
+ for (const source of xrefs.value) {
287
+ if (typeof source !== "string")
288
+ return err(new AnalysisProtocolError("xrefs returned a non-address value"));
289
+ if (operations >= operationBudget) {
290
+ residual.add("Containing-procedure resolution stopped at the operation budget.");
291
+ break;
292
+ }
293
+ operations += 1;
294
+ const resolved = await this.#call("resolve_containing_procedure", { address: source }, signal);
295
+ if (!resolved.ok)
296
+ return resolved;
297
+ references.push({
298
+ target_address: match.address,
299
+ source_address: source,
300
+ containing_procedure: resolved.value,
301
+ });
302
+ if (references.length >= limit) {
303
+ residual.add("Reference results reached the configured limit.");
304
+ break;
305
+ }
306
+ }
307
+ if (references.length >= limit)
308
+ break;
309
+ }
310
+ return ok({
311
+ references,
312
+ operations,
313
+ residual: [...residual],
314
+ });
315
+ }
316
+ async #allProcedures(signal) {
317
+ return this.#allAddressed("list_procedures", signal);
318
+ }
319
+ async #allAddressed(tool, signal) {
320
+ const entries = [];
321
+ let offset = 0;
322
+ while (true) {
323
+ const result = await this.#call(tool, { offset, limit: 500 }, signal);
324
+ if (!result.ok)
325
+ return result;
326
+ const page = parseAddressedPage(result.value);
327
+ if (!page.ok)
328
+ return page;
329
+ entries.push(...page.value.items);
330
+ if (!page.value.hasMore || page.value.nextOffset === null) {
331
+ return ok(entries);
332
+ }
333
+ if (page.value.nextOffset <= offset) {
334
+ return err(new AnalysisProtocolError(`${tool} returned a non-advancing pagination offset`));
335
+ }
336
+ offset = page.value.nextOffset;
337
+ }
338
+ }
339
+ #call(name, arguments_, signal) {
340
+ return this.analysis.execute(name, arguments_, signal === undefined ? {} : { signal });
341
+ }
342
+ }
343
+ const invalidInput = (name, cause) => Promise.resolve(err(new AnalysisProtocolError(`Invalid ${name} input after MCP validation`, {
344
+ cause,
345
+ })));
346
+ const resolveAddress = (value) => {
347
+ if (typeof value === "string" && value.length > 0)
348
+ return value;
349
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
350
+ return undefined;
351
+ }
352
+ const candidate = value.address ?? value.name;
353
+ return typeof candidate === "string" && candidate.length > 0
354
+ ? candidate
355
+ : undefined;
356
+ };
357
+ const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));