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.
- package/LICENSE +21 -0
- package/README.md +312 -0
- package/bridge/hopper_bridge.py +557 -0
- package/dist/application/AnalysisProvider.js +1 -0
- package/dist/application/BinarySession.js +173 -0
- package/dist/application/DirectAnalysis.js +31 -0
- package/dist/application/Doctor.js +110 -0
- package/dist/application/EnhancedTools.js +357 -0
- package/dist/application/EvidenceLedger.js +65 -0
- package/dist/application/HopperToolPort.js +1 -0
- package/dist/application/LoopbackReplay.js +84 -0
- package/dist/application/ProcessHarness.js +347 -0
- package/dist/application/Setup.js +240 -0
- package/dist/application/homebrew.js +19 -0
- package/dist/application/runtime.js +11 -0
- package/dist/cli.js +88 -0
- package/dist/config.js +86 -0
- package/dist/contracts/enhancedInputs.js +73 -0
- package/dist/contracts/toolContracts.js +144 -0
- package/dist/contracts/toolOutputSchemas.js +267 -0
- package/dist/domain/binaryTarget.js +321 -0
- package/dist/domain/errors.js +81 -0
- package/dist/domain/evidence.js +146 -0
- package/dist/domain/evidenceBundle.js +11 -0
- package/dist/domain/hopperValues.js +253 -0
- package/dist/domain/jsonValue.js +3 -0
- package/dist/domain/processCapture.js +252 -0
- package/dist/domain/result.js +4 -0
- package/dist/domain/symbolAnalysis.js +68 -0
- package/dist/hopper/BridgeLauncher.js +130 -0
- package/dist/hopper/HopperClient.js +314 -0
- package/dist/hopper/HopperProvider.js +66 -0
- package/dist/hopper/protocol.js +32 -0
- package/dist/identity.js +14 -0
- package/dist/logger.js +27 -0
- package/dist/main.js +81 -0
- package/dist/server/createServer.js +30 -0
- package/dist/server/registerEnhancedTools.js +53 -0
- package/dist/server/registerOfficialTools.js +59 -0
- package/dist/server/registerSessionTools.js +159 -0
- package/dist/server/toolLogging.js +11 -0
- package/dist/server/toolResult.js +44 -0
- package/package.json +109 -0
- package/scripts/rea.mjs +15 -0
- package/scripts/rebuild-native.mjs +22 -0
- 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));
|