rea-agents 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -20
- package/bridge/hopper_bridge.py +141 -13
- package/dist/application/AnalysisProvider.js +12 -1
- package/dist/application/ArtifactExtraction.js +166 -0
- package/dist/application/ArtifactGraphConstruction.js +257 -0
- package/dist/application/ArtifactInventory.js +253 -0
- package/dist/application/BinarySession.js +164 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +27 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +8 -7
- package/dist/application/EvidenceBundleCommands.js +29 -0
- package/dist/application/EvidenceBundleFiles.js +127 -0
- package/dist/application/EvidenceLedger.js +225 -18
- package/dist/application/FilesystemSnapshot.js +124 -0
- package/dist/application/LinuxHopper.js +186 -0
- package/dist/application/LoopbackReplay.js +195 -37
- package/dist/application/ProcessHarness.js +200 -191
- package/dist/application/ProcessNormalization.js +44 -0
- package/dist/application/ProcessOwnership.js +84 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +105 -0
- package/dist/application/ReferenceSourceImport.js +182 -0
- package/dist/application/ReferenceSourceImportEntries.js +122 -0
- package/dist/application/ReferenceSourceImportPolicy.js +73 -0
- package/dist/application/ReferenceSourceImportTypes.js +18 -0
- package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
- package/dist/application/Setup.js +192 -41
- package/dist/application/Uninstall.js +130 -0
- package/dist/application/runtime.js +8 -1
- package/dist/artifacts/ArtifactPaths.js +51 -0
- package/dist/artifacts/ArtifactProvider.js +130 -0
- package/dist/artifacts/ArtifactReader.js +9 -0
- package/dist/artifacts/AsarArtifactReader.js +62 -0
- package/dist/artifacts/DirectoryArtifactReader.js +107 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
- package/dist/artifacts/SafeOutputTree.js +199 -0
- package/dist/artifacts/StreamBytes.js +10 -0
- package/dist/artifacts/ZipArtifactReader.js +109 -0
- package/dist/cli.js +153 -6
- package/dist/config.js +35 -1
- package/dist/contracts/artifactComparisonExample.js +95 -0
- package/dist/contracts/artifactToolContracts.js +84 -0
- package/dist/contracts/enhancedInputs.js +4 -0
- package/dist/contracts/functionComparisonExample.js +57 -0
- package/dist/contracts/investigationExamples.js +118 -0
- package/dist/contracts/nativeToolContracts.js +52 -0
- package/dist/contracts/processCaptureExample.js +25 -0
- package/dist/contracts/toolContractExamples.js +69 -0
- package/dist/contracts/toolContracts.js +99 -36
- package/dist/contracts/toolOutputSchemas.js +154 -82
- package/dist/contracts/unknownContractExamples.js +33 -0
- package/dist/domain/artifactComparison.js +273 -0
- package/dist/domain/artifactGraph.js +194 -0
- package/dist/domain/artifactInventoryEvidence.js +150 -0
- package/dist/domain/binaryTarget.js +55 -0
- package/dist/domain/bundleComparison.js +266 -0
- package/dist/domain/callPath.js +346 -0
- package/dist/domain/changedBehavior.js +294 -0
- package/dist/domain/errors.js +192 -4
- package/dist/domain/evidence.js +41 -10
- package/dist/domain/evidenceBundle.js +187 -6
- package/dist/domain/functionComparison.js +201 -0
- package/dist/domain/functionComparisonNormalization.js +112 -0
- package/dist/domain/functionComparisonResults.js +54 -0
- package/dist/domain/functionComparisonSchemas.js +82 -0
- package/dist/domain/functionDossierEvidence.js +171 -0
- package/dist/domain/hopperValues.js +14 -7
- package/dist/domain/nativeInspection.js +142 -0
- package/dist/domain/processCapture.js +152 -54
- package/dist/domain/processComparison.js +106 -0
- package/dist/domain/reconstructionUnknowns.js +90 -0
- package/dist/domain/reconstructionVerification.js +285 -0
- package/dist/domain/reconstructionVerificationSchemas.js +126 -0
- package/dist/domain/referenceSourceClassification.js +496 -0
- package/dist/domain/referenceSourceGraph.js +376 -0
- package/dist/domain/referenceSourceImportParsing.js +235 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +40 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/identity.js +1 -0
- package/dist/main.js +5 -1
- package/dist/native/CommandRunner.js +156 -0
- package/dist/native/NativeMacOSProvider.js +306 -0
- package/dist/native/NativeMachoInspection.js +135 -0
- package/dist/native/parsers/codesign.js +55 -0
- package/dist/native/parsers/demangle.js +26 -0
- package/dist/native/parsers/dyldInfo.js +25 -0
- package/dist/native/parsers/lipo.js +67 -0
- package/dist/native/parsers/otool.js +193 -0
- package/dist/native/parsers/plist.js +23 -0
- package/dist/reference/ReferenceSourceReader.js +73 -0
- package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
- package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
- package/dist/reference/ReferenceSourceReaderFile.js +119 -0
- package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
- package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
- package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
- package/dist/server/createServer.js +31 -5
- package/dist/server/recordDerivedEvidence.js +10 -0
- package/dist/server/registerArtifactComparisonTool.js +62 -0
- package/dist/server/registerArtifactTools.js +6 -0
- package/dist/server/registerBundleComparisonTool.js +47 -0
- package/dist/server/registerEnhancedTools.js +64 -12
- package/dist/server/registerEvidenceTools.js +36 -0
- package/dist/server/registerFunctionComparisonTool.js +68 -0
- package/dist/server/registerInvestigationTools.js +224 -0
- package/dist/server/registerNativeTools.js +6 -0
- package/dist/server/registerOfficialTools.js +47 -14
- package/dist/server/registerProcessComparisonTool.js +106 -0
- package/dist/server/registerSessionTools.js +179 -70
- package/dist/server/sessionEvidence.js +28 -0
- package/dist/server/sessionToolPolicies.js +64 -0
- package/dist/server/toolRegistrationOptions.js +7 -0
- package/dist/server/toolResult.js +8 -5
- package/install.sh +198 -0
- package/package.json +19 -2
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
- /package/dist/{application/HopperToolPort.js → domain/referenceSourcePolicy.js} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { access, mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -57,10 +57,12 @@ export class HopperClient {
|
|
|
57
57
|
}
|
|
58
58
|
const socketPath = join(this.#directory, "bridge.sock");
|
|
59
59
|
this.#token = randomBytes(32).toString("hex");
|
|
60
|
+
const runId = randomUUID();
|
|
60
61
|
const launched = await this.#options.launcher.launch({
|
|
61
62
|
directory: this.#directory,
|
|
62
63
|
socketPath,
|
|
63
64
|
token: this.#token,
|
|
65
|
+
runId,
|
|
64
66
|
}, signal === undefined ? {} : { signal });
|
|
65
67
|
if (!launched.ok) {
|
|
66
68
|
await this.close();
|
|
@@ -81,7 +83,7 @@ export class HopperClient {
|
|
|
81
83
|
await this.close();
|
|
82
84
|
return health;
|
|
83
85
|
}
|
|
84
|
-
const parsed = parseServerInfo(health.value);
|
|
86
|
+
const parsed = parseServerInfo(health.value, runId);
|
|
85
87
|
if (!parsed.ok)
|
|
86
88
|
await this.close();
|
|
87
89
|
return parsed;
|
|
@@ -109,7 +111,13 @@ export class HopperClient {
|
|
|
109
111
|
socket?.destroy();
|
|
110
112
|
this.#socket = undefined;
|
|
111
113
|
if (this.#launch?.ownsProcessLifetime === true) {
|
|
112
|
-
this.#launch.
|
|
114
|
+
if (this.#launch.cleanup !== undefined) {
|
|
115
|
+
const cleanup = await this.#launch.cleanup();
|
|
116
|
+
if (!cleanup.cleaned)
|
|
117
|
+
this.#logger.warn({ reason: cleanup.reason }, "Owned launcher cleanup failed closed");
|
|
118
|
+
}
|
|
119
|
+
else
|
|
120
|
+
this.#launch.process.kill("SIGTERM");
|
|
113
121
|
}
|
|
114
122
|
this.#launch = undefined;
|
|
115
123
|
if (this.#directory !== undefined) {
|
|
@@ -302,12 +310,13 @@ const connectOnce = async (socketPath) => new Promise((resolve) => {
|
|
|
302
310
|
resolve(err(new HopperStartError({ cause })));
|
|
303
311
|
});
|
|
304
312
|
});
|
|
305
|
-
const parseServerInfo = (value) => {
|
|
313
|
+
const parseServerInfo = (value, expectedRunId) => {
|
|
306
314
|
if (typeof value === "object" &&
|
|
307
315
|
value !== null &&
|
|
308
316
|
!Array.isArray(value) &&
|
|
309
317
|
value.name === "REA Hopper bridge" &&
|
|
310
|
-
typeof value.version === "string"
|
|
318
|
+
typeof value.version === "string" &&
|
|
319
|
+
value.run_id === expectedRunId) {
|
|
311
320
|
return ok({ name: value.name, version: value.version });
|
|
312
321
|
}
|
|
313
322
|
return err(new HopperProtocolError("Hopper bridge health result is invalid"));
|
|
@@ -1,36 +1,54 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { createAnalysisExecution } from "../application/AnalysisProvider.js";
|
|
3
|
+
import { AnalysisCapabilityUnavailableError } from "../domain/errors.js";
|
|
4
|
+
import { err } from "../domain/result.js";
|
|
5
|
+
import { ENHANCED_TOOL_CONTRACTS, OFFICIAL_TOOL_CONTRACTS, } from "../contracts/toolContracts.js";
|
|
2
6
|
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
|
|
3
7
|
import { HopperClient } from "./HopperClient.js";
|
|
4
|
-
const IDENTITY = {
|
|
8
|
+
const IDENTITY = Object.freeze({
|
|
5
9
|
id: "hopper",
|
|
6
10
|
name: "Hopper Disassembler",
|
|
7
11
|
version: null,
|
|
8
|
-
};
|
|
9
|
-
const
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
12
|
+
});
|
|
13
|
+
const MUTATING_OPERATIONS = new Set([
|
|
14
|
+
"set_address_name",
|
|
15
|
+
"set_addresses_names",
|
|
16
|
+
"set_bookmark",
|
|
17
|
+
"set_comment",
|
|
18
|
+
"set_inline_comment",
|
|
19
|
+
"unset_bookmark",
|
|
20
|
+
]);
|
|
21
|
+
const PROVIDER_TOOL_CONTRACTS = [
|
|
22
|
+
...OFFICIAL_TOOL_CONTRACTS,
|
|
23
|
+
...ENHANCED_TOOL_CONTRACTS.filter((contract) => contract.name === "analyze_function"),
|
|
17
24
|
];
|
|
18
|
-
const CAPABILITIES =
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
const CAPABILITIES = Object.freeze(PROVIDER_TOOL_CONTRACTS.map((contract) => Object.freeze({
|
|
26
|
+
provider: IDENTITY,
|
|
27
|
+
operation: contract.name,
|
|
28
|
+
inputContractVersion: 1,
|
|
29
|
+
outputContractVersion: 1,
|
|
21
30
|
available: true,
|
|
22
|
-
|
|
31
|
+
reason: null,
|
|
32
|
+
pagination: "offset" in contract.inputSchema.shape ? "offset" : "none",
|
|
23
33
|
exhaustive: false,
|
|
24
|
-
effects: {
|
|
25
|
-
mutatesArtifact:
|
|
34
|
+
effects: Object.freeze({
|
|
35
|
+
mutatesArtifact: MUTATING_OPERATIONS.has(contract.name),
|
|
26
36
|
launchesProcess: true,
|
|
27
37
|
mayShowUi: true,
|
|
28
38
|
mayAccessNetwork: false,
|
|
29
|
-
mayWriteFilesystem:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
mayWriteFilesystem: MUTATING_OPERATIONS.has(contract.name),
|
|
40
|
+
changesPermissions: false,
|
|
41
|
+
requiresRoot: false,
|
|
42
|
+
}),
|
|
43
|
+
limits: Object.freeze({
|
|
44
|
+
maxResults: null,
|
|
45
|
+
maxPayloadBytes: null,
|
|
46
|
+
timeoutMs: null,
|
|
47
|
+
}),
|
|
48
|
+
limitations: Object.freeze([
|
|
49
|
+
"Results depend on Hopper's completed static analysis.",
|
|
50
|
+
]),
|
|
51
|
+
})));
|
|
34
52
|
/** Concrete analysis provider backed by REA's private Hopper bridge. */
|
|
35
53
|
export class HopperProvider {
|
|
36
54
|
config;
|
|
@@ -46,6 +64,11 @@ export class HopperProvider {
|
|
|
46
64
|
return CAPABILITIES;
|
|
47
65
|
}
|
|
48
66
|
createClient(target) {
|
|
67
|
+
if (target.kind !== "executable" && target.kind !== "database")
|
|
68
|
+
return {
|
|
69
|
+
execute: (operation) => Promise.resolve(err(new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, `Hopper cannot open ${target.kind} targets directly. Inventory or extract the artifact first.`))),
|
|
70
|
+
close: () => Promise.resolve(),
|
|
71
|
+
};
|
|
49
72
|
const client = new HopperClient({
|
|
50
73
|
launcher: new HopperApplicationLauncher({
|
|
51
74
|
launcherPath: this.config.hopperLauncherPath,
|
|
@@ -59,7 +82,19 @@ export class HopperProvider {
|
|
|
59
82
|
logger: this.logger.child({ layer: "bridge" }),
|
|
60
83
|
});
|
|
61
84
|
return {
|
|
62
|
-
execute: (operation, parameters, options) =>
|
|
85
|
+
execute: async (operation, parameters, options) => {
|
|
86
|
+
const result = await client.callTool(operation, parameters, options);
|
|
87
|
+
return result.ok
|
|
88
|
+
? {
|
|
89
|
+
ok: true,
|
|
90
|
+
value: createAnalysisExecution(result.value, IDENTITY, {
|
|
91
|
+
limitations: [
|
|
92
|
+
"Results depend on Hopper's completed static analysis.",
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
: result;
|
|
97
|
+
},
|
|
63
98
|
close: () => client.close(),
|
|
64
99
|
};
|
|
65
100
|
}
|
package/dist/identity.js
CHANGED
package/dist/main.js
CHANGED
|
@@ -33,7 +33,11 @@ export const run = async () => {
|
|
|
33
33
|
}
|
|
34
34
|
let handle;
|
|
35
35
|
try {
|
|
36
|
-
handle = serveStdio(() => createServer(session, session,
|
|
36
|
+
handle = serveStdio(() => createServer(session, session, {
|
|
37
|
+
logger,
|
|
38
|
+
processPolicy: config.value.processExecutionPolicy,
|
|
39
|
+
evidenceFilePolicy: config.value.evidenceFilePolicy,
|
|
40
|
+
}), {
|
|
37
41
|
onerror: () => {
|
|
38
42
|
serverLogger.error("MCP stdio transport error");
|
|
39
43
|
process.stderr.write("MCP stdio transport error\n");
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { err, ok } from "../domain/result.js";
|
|
6
|
+
export class NativeCommandFailure extends Error {
|
|
7
|
+
tool;
|
|
8
|
+
reason;
|
|
9
|
+
exitCode;
|
|
10
|
+
constructor(tool, reason, exitCode = null, options) {
|
|
11
|
+
super(`Native command ${tool} failed: ${reason}`, options);
|
|
12
|
+
this.tool = tool;
|
|
13
|
+
this.reason = reason;
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Run allowlisted Xcode tools directly with bounded output and no shell. */
|
|
18
|
+
export class XcrunCommandRunner {
|
|
19
|
+
#resolved = new Map();
|
|
20
|
+
run(tool, arguments_, options) {
|
|
21
|
+
if (!ALLOWED_TOOLS.has(tool))
|
|
22
|
+
return Promise.resolve(err(new NativeCommandFailure(tool, "unavailable")));
|
|
23
|
+
return this.#resolve(tool).then(async (resolved) => {
|
|
24
|
+
if (!resolved.ok)
|
|
25
|
+
return resolved;
|
|
26
|
+
const captured = await captureProcess(resolved.value.path, arguments_, tool, options);
|
|
27
|
+
return captured.ok
|
|
28
|
+
? ok({
|
|
29
|
+
...captured.value,
|
|
30
|
+
tool,
|
|
31
|
+
executable: resolved.value.path,
|
|
32
|
+
executableSha256: resolved.value.sha256,
|
|
33
|
+
toolVersion: null,
|
|
34
|
+
versionReason: "Tool exposes no uniform stable version flag; executable digest identifies it.",
|
|
35
|
+
arguments: [...arguments_],
|
|
36
|
+
})
|
|
37
|
+
: captured;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
#resolve(tool) {
|
|
41
|
+
const existing = this.#resolved.get(tool);
|
|
42
|
+
if (existing !== undefined)
|
|
43
|
+
return existing;
|
|
44
|
+
const pending = resolveTool(tool);
|
|
45
|
+
this.#resolved.set(tool, pending);
|
|
46
|
+
return pending;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const ALLOWED_TOOLS = new Set([
|
|
50
|
+
"codesign",
|
|
51
|
+
"dyld_info",
|
|
52
|
+
"dwarfdump",
|
|
53
|
+
"file",
|
|
54
|
+
"lipo",
|
|
55
|
+
"nm",
|
|
56
|
+
"otool",
|
|
57
|
+
"plutil",
|
|
58
|
+
"strings",
|
|
59
|
+
"swift-demangle",
|
|
60
|
+
"vtool",
|
|
61
|
+
]);
|
|
62
|
+
const resolveTool = async (tool) => {
|
|
63
|
+
const found = await captureProcess("/usr/bin/xcrun", ["--find", tool], "xcrun", { timeoutMs: 5_000, maxOutputBytes: 64 * 1024 });
|
|
64
|
+
if (!found.ok || found.value.exitCode !== 0)
|
|
65
|
+
return err(new NativeCommandFailure(tool, "unavailable"));
|
|
66
|
+
const candidate = found.value.stdout.trim();
|
|
67
|
+
if (!candidate.startsWith("/"))
|
|
68
|
+
return err(new NativeCommandFailure(tool, "unavailable"));
|
|
69
|
+
try {
|
|
70
|
+
const path = await realpath(candidate);
|
|
71
|
+
return ok({ path, sha256: await hashFile(path) });
|
|
72
|
+
}
|
|
73
|
+
catch (cause) {
|
|
74
|
+
return err(new NativeCommandFailure(tool, "io", null, { cause }));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const captureProcess = (executable, arguments_, tool, options) => new Promise((resolve) => {
|
|
78
|
+
if (options.signal?.aborted === true) {
|
|
79
|
+
resolve(err(new NativeCommandFailure(tool, "cancelled")));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const runToken = randomUUID();
|
|
83
|
+
const child = spawn(executable, [...arguments_], {
|
|
84
|
+
shell: false,
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
env: {
|
|
87
|
+
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
88
|
+
LC_ALL: "C",
|
|
89
|
+
LANG: "C",
|
|
90
|
+
REA_NATIVE_RUN_TOKEN: runToken,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const stdout = [];
|
|
94
|
+
const stderr = [];
|
|
95
|
+
let stdoutBytes = 0;
|
|
96
|
+
let stderrBytes = 0;
|
|
97
|
+
let settled = false;
|
|
98
|
+
let reason;
|
|
99
|
+
const finish = (result) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
105
|
+
resolve(result);
|
|
106
|
+
};
|
|
107
|
+
const stop = (failure) => {
|
|
108
|
+
reason ??= failure;
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
};
|
|
111
|
+
const onAbort = () => stop("cancelled");
|
|
112
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
113
|
+
const timeout = setTimeout(() => stop("timeout"), options.timeoutMs);
|
|
114
|
+
child.stdout.on("data", (chunk) => {
|
|
115
|
+
stdoutBytes += chunk.length;
|
|
116
|
+
if (stdoutBytes + stderrBytes > options.maxOutputBytes)
|
|
117
|
+
stop("output-limit");
|
|
118
|
+
else
|
|
119
|
+
stdout.push(chunk);
|
|
120
|
+
});
|
|
121
|
+
child.stderr.on("data", (chunk) => {
|
|
122
|
+
stderrBytes += chunk.length;
|
|
123
|
+
if (stdoutBytes + stderrBytes > options.maxOutputBytes)
|
|
124
|
+
stop("output-limit");
|
|
125
|
+
else
|
|
126
|
+
stderr.push(chunk);
|
|
127
|
+
});
|
|
128
|
+
child.once("error", (cause) => finish(err(new NativeCommandFailure(tool, "io", null, { cause }))));
|
|
129
|
+
child.once("close", (code, signal) => {
|
|
130
|
+
if (reason !== undefined) {
|
|
131
|
+
finish(err(new NativeCommandFailure(tool, reason, code)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (code !== 0 && options.acceptNonZero !== true) {
|
|
135
|
+
finish(err(new NativeCommandFailure(tool, "nonzero-exit", code)));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
finish(ok({
|
|
139
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
140
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
141
|
+
stdoutBytes,
|
|
142
|
+
stderrBytes,
|
|
143
|
+
stdoutTruncated: false,
|
|
144
|
+
stderrTruncated: false,
|
|
145
|
+
exitCode: code,
|
|
146
|
+
signal,
|
|
147
|
+
}));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
const hashFile = (path) => new Promise((resolve, reject) => {
|
|
151
|
+
const hash = createHash("sha256");
|
|
152
|
+
const stream = createReadStream(path);
|
|
153
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
154
|
+
stream.once("error", reject);
|
|
155
|
+
stream.once("end", () => resolve(hash.digest("hex")));
|
|
156
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { parse as parseXmlPlist } from "plist";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createAnalysisExecution, } from "../application/AnalysisProvider.js";
|
|
6
|
+
import { NATIVE_TOOL_CONTRACTS, } from "../contracts/nativeToolContracts.js";
|
|
7
|
+
import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisOutputError, AnalysisTimeoutError, ProviderAdapterError, } from "../domain/errors.js";
|
|
8
|
+
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
9
|
+
import { demangleSwiftSchema, inspectPlistSchema, inspectSignatureSchema, listArchitecturesSchema, } from "../domain/nativeInspection.js";
|
|
10
|
+
import { err, ok } from "../domain/result.js";
|
|
11
|
+
import { NativeCommandFailure, XcrunCommandRunner, } from "./CommandRunner.js";
|
|
12
|
+
import { parseCodeSignature } from "./parsers/codesign.js";
|
|
13
|
+
import { parseDemangledSymbols } from "./parsers/demangle.js";
|
|
14
|
+
import { parseLipoArchitectures } from "./parsers/lipo.js";
|
|
15
|
+
import { parsePlistJson } from "./parsers/plist.js";
|
|
16
|
+
import { architectureLocations, inspectNativeMacho, } from "./NativeMachoInspection.js";
|
|
17
|
+
const IDENTITY = Object.freeze({
|
|
18
|
+
id: "native-macos",
|
|
19
|
+
name: "macOS native inspection utilities",
|
|
20
|
+
version: null,
|
|
21
|
+
});
|
|
22
|
+
const TIMEOUT_MS = 30_000;
|
|
23
|
+
const OUTPUT_LIMIT = 4 * 1024 * 1024;
|
|
24
|
+
/** Read-only semantic provider composed from Xcode command-line utilities. */
|
|
25
|
+
export class NativeMacOSProvider {
|
|
26
|
+
runner;
|
|
27
|
+
#capabilities;
|
|
28
|
+
constructor(runner = new XcrunCommandRunner(), platform = process.platform) {
|
|
29
|
+
this.runner = runner;
|
|
30
|
+
const available = platform === "darwin";
|
|
31
|
+
this.#capabilities = Object.freeze(NATIVE_TOOL_CONTRACTS.map((contract) => {
|
|
32
|
+
const availability = available
|
|
33
|
+
? { available: true, reason: null }
|
|
34
|
+
: {
|
|
35
|
+
available: false,
|
|
36
|
+
reason: "Native macOS utilities require macOS.",
|
|
37
|
+
};
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
provider: IDENTITY,
|
|
40
|
+
operation: contract.name,
|
|
41
|
+
inputContractVersion: 1,
|
|
42
|
+
outputContractVersion: 1,
|
|
43
|
+
...availability,
|
|
44
|
+
pagination: "none",
|
|
45
|
+
exhaustive: contract.name !== "inspect_macho",
|
|
46
|
+
effects: Object.freeze({
|
|
47
|
+
mutatesArtifact: false,
|
|
48
|
+
launchesProcess: true,
|
|
49
|
+
mayShowUi: false,
|
|
50
|
+
mayAccessNetwork: false,
|
|
51
|
+
mayWriteFilesystem: false,
|
|
52
|
+
changesPermissions: false,
|
|
53
|
+
requiresRoot: false,
|
|
54
|
+
}),
|
|
55
|
+
limits: Object.freeze({
|
|
56
|
+
maxResults: contract.name === "demangle_swift" ? 500 : null,
|
|
57
|
+
maxPayloadBytes: OUTPUT_LIMIT,
|
|
58
|
+
timeoutMs: TIMEOUT_MS,
|
|
59
|
+
}),
|
|
60
|
+
limitations: Object.freeze([
|
|
61
|
+
"Availability and textual formats depend on the installed macOS/Xcode toolchain.",
|
|
62
|
+
]),
|
|
63
|
+
});
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
identity() {
|
|
67
|
+
return IDENTITY;
|
|
68
|
+
}
|
|
69
|
+
capabilities() {
|
|
70
|
+
return this.#capabilities;
|
|
71
|
+
}
|
|
72
|
+
createClient(target) {
|
|
73
|
+
return new NativeMacOSClient(target, this.runner);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
class NativeMacOSClient {
|
|
77
|
+
target;
|
|
78
|
+
runner;
|
|
79
|
+
constructor(target, runner) {
|
|
80
|
+
this.target = target;
|
|
81
|
+
this.runner = runner;
|
|
82
|
+
}
|
|
83
|
+
async execute(operation, parameters, options) {
|
|
84
|
+
if (operation === "health")
|
|
85
|
+
return ok(createAnalysisExecution(null, IDENTITY));
|
|
86
|
+
if (!isNativeOperation(operation))
|
|
87
|
+
return err(new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, "Operation is not implemented by native macOS tools."));
|
|
88
|
+
try {
|
|
89
|
+
const observation = await this.#dispatch(operation, parameters, options?.signal);
|
|
90
|
+
return observation.ok
|
|
91
|
+
? ok(createAnalysisExecution(observation.value.result, IDENTITY, {
|
|
92
|
+
rawResult: { provenance: observation.value.provenance },
|
|
93
|
+
limitations: observation.value.limitations,
|
|
94
|
+
locations: observation.value.locations,
|
|
95
|
+
}))
|
|
96
|
+
: observation;
|
|
97
|
+
}
|
|
98
|
+
catch (cause) {
|
|
99
|
+
return err(new AnalysisOutputError(operation, "Native output parsing failed", {
|
|
100
|
+
cause,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
close() {
|
|
105
|
+
return Promise.resolve();
|
|
106
|
+
}
|
|
107
|
+
#dispatch(operation, parameters, signal) {
|
|
108
|
+
switch (operation) {
|
|
109
|
+
case "inspect_macho":
|
|
110
|
+
return this.#inspectMacho(signal);
|
|
111
|
+
case "inspect_signature":
|
|
112
|
+
return this.#inspectSignature(signal);
|
|
113
|
+
case "inspect_plist":
|
|
114
|
+
return this.#inspectPlist(parameters, signal);
|
|
115
|
+
case "list_architectures":
|
|
116
|
+
return this.#listArchitectures(signal);
|
|
117
|
+
case "demangle_swift":
|
|
118
|
+
return this.#demangle(parameters, signal);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async #listArchitectures(signal) {
|
|
122
|
+
const capture = await this.#run("list_architectures", "lipo", ["-detailed_info", this.target.path], { signal });
|
|
123
|
+
if (!capture.ok)
|
|
124
|
+
return capture;
|
|
125
|
+
const provenance = [invocation(capture.value, this.target.path)];
|
|
126
|
+
const result = listArchitecturesSchema.parse({
|
|
127
|
+
architectures: {
|
|
128
|
+
items: parseLipoArchitectures(capture.value.stdout),
|
|
129
|
+
total: parseLipoArchitectures(capture.value.stdout).length,
|
|
130
|
+
exhaustive: true,
|
|
131
|
+
limitations: [],
|
|
132
|
+
},
|
|
133
|
+
provenance,
|
|
134
|
+
limitations: [],
|
|
135
|
+
});
|
|
136
|
+
return ok({
|
|
137
|
+
result: jsonValueSchema.parse(result),
|
|
138
|
+
provenance,
|
|
139
|
+
limitations: [],
|
|
140
|
+
locations: architectureLocations(result.architectures.items),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
async #demangle(parameters, signal) {
|
|
144
|
+
const symbols = z.array(z.string()).safeParse(parameters.symbols);
|
|
145
|
+
if (!symbols.success)
|
|
146
|
+
return err(new AnalysisOutputError("demangle_swift", "symbols were not parsed strings"));
|
|
147
|
+
const capture = await this.#run("demangle_swift", "swift-demangle", ["--compact", ...symbols.data], { signal });
|
|
148
|
+
if (!capture.ok)
|
|
149
|
+
return capture;
|
|
150
|
+
const provenance = [invocation(capture.value, this.target.path)];
|
|
151
|
+
const result = demangleSwiftSchema.parse({
|
|
152
|
+
symbols: parseDemangledSymbols(symbols.data, capture.value.stdout),
|
|
153
|
+
provenance,
|
|
154
|
+
limitations: [],
|
|
155
|
+
});
|
|
156
|
+
return ok({
|
|
157
|
+
result: jsonValueSchema.parse(result),
|
|
158
|
+
provenance,
|
|
159
|
+
limitations: [],
|
|
160
|
+
locations: [],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async #inspectSignature(signal) {
|
|
164
|
+
const display = await this.#run("inspect_signature", "codesign", ["-d", "--verbose=4", this.target.path], { signal, acceptNonZero: true });
|
|
165
|
+
if (!display.ok)
|
|
166
|
+
return display;
|
|
167
|
+
const requirements = await this.#run("inspect_signature", "codesign", ["-d", "-r-", this.target.path], { signal, acceptNonZero: true });
|
|
168
|
+
if (!requirements.ok)
|
|
169
|
+
return requirements;
|
|
170
|
+
const entitlements = await this.#run("inspect_signature", "codesign", ["-d", "--entitlements", ":-", this.target.path], { signal, acceptNonZero: true });
|
|
171
|
+
if (!entitlements.ok)
|
|
172
|
+
return entitlements;
|
|
173
|
+
const unsigned = /not signed at all|code object is not signed/iu.test(`${display.value.stdout}\n${display.value.stderr}`);
|
|
174
|
+
const parsed = parseCodeSignature(`${display.value.stdout}\n${display.value.stderr}`, unsigned);
|
|
175
|
+
const requirementText = /designated\s*=>\s*(.+)$/mu.exec(`${requirements.value.stdout}\n${requirements.value.stderr}`)?.[1] ?? null;
|
|
176
|
+
const entitlementValue = parseEntitlements(`${entitlements.value.stdout}\n${entitlements.value.stderr}`);
|
|
177
|
+
const provenance = [
|
|
178
|
+
display.value,
|
|
179
|
+
requirements.value,
|
|
180
|
+
entitlements.value,
|
|
181
|
+
].map((capture) => invocation(capture, this.target.path));
|
|
182
|
+
const result = inspectSignatureSchema.parse({
|
|
183
|
+
...parsed,
|
|
184
|
+
designated_requirement: requirementText,
|
|
185
|
+
entitlements: entitlementValue,
|
|
186
|
+
provenance,
|
|
187
|
+
});
|
|
188
|
+
return ok({
|
|
189
|
+
result: jsonValueSchema.parse(result),
|
|
190
|
+
provenance,
|
|
191
|
+
limitations: result.limitations,
|
|
192
|
+
locations: [],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async #inspectPlist(parameters, signal) {
|
|
196
|
+
const requested = parameters.relative_path;
|
|
197
|
+
if (typeof requested !== "string")
|
|
198
|
+
return err(new AnalysisOutputError("inspect_plist", "relative_path was not a string"));
|
|
199
|
+
const plist = await resolvePlistPath(this.target, requested);
|
|
200
|
+
if (!plist.ok)
|
|
201
|
+
return plist;
|
|
202
|
+
const classified = await this.#run("inspect_plist", "file", ["-b", plist.value], { signal });
|
|
203
|
+
if (!classified.ok)
|
|
204
|
+
return classified;
|
|
205
|
+
const capture = await this.#run("inspect_plist", "plutil", ["-convert", "json", "-o", "-", "--", plist.value], { signal });
|
|
206
|
+
if (!capture.ok)
|
|
207
|
+
return capture;
|
|
208
|
+
const parsed = parsePlistJson(capture.value.stdout);
|
|
209
|
+
const provenance = [classified.value, capture.value].map((item) => invocation(item, plist.value, "$PLIST"));
|
|
210
|
+
const result = inspectPlistSchema.parse({
|
|
211
|
+
format: /binary property list/iu.test(classified.value.stdout)
|
|
212
|
+
? "binary"
|
|
213
|
+
: /XML|text/iu.test(classified.value.stdout)
|
|
214
|
+
? "xml"
|
|
215
|
+
: "unknown",
|
|
216
|
+
...parsed,
|
|
217
|
+
source_path: requested,
|
|
218
|
+
provenance,
|
|
219
|
+
limitations: [],
|
|
220
|
+
});
|
|
221
|
+
return ok({
|
|
222
|
+
result: jsonValueSchema.parse(result),
|
|
223
|
+
provenance,
|
|
224
|
+
limitations: [],
|
|
225
|
+
locations: [{ kind: "artifact-path", path: requested }],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async #inspectMacho(signal) {
|
|
229
|
+
if (this.target.format !== "mach-o")
|
|
230
|
+
return err(new AnalysisCapabilityUnavailableError(IDENTITY.id, "inspect_macho", "Active artifact is not Mach-O."));
|
|
231
|
+
return inspectNativeMacho({
|
|
232
|
+
target: this.target,
|
|
233
|
+
...(signal === undefined ? {} : { signal }),
|
|
234
|
+
run: (tool, arguments_, commandSignal) => this.#run("inspect_macho", tool, arguments_, {
|
|
235
|
+
signal: commandSignal,
|
|
236
|
+
}),
|
|
237
|
+
invocation: (capture) => invocation(capture, this.target.path),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
async #run(operation, tool, arguments_, options = {}) {
|
|
241
|
+
const captured = await this.runner.run(tool, arguments_, {
|
|
242
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
243
|
+
timeoutMs: TIMEOUT_MS,
|
|
244
|
+
maxOutputBytes: OUTPUT_LIMIT,
|
|
245
|
+
acceptNonZero: options.acceptNonZero ?? false,
|
|
246
|
+
});
|
|
247
|
+
if (captured.ok)
|
|
248
|
+
return captured;
|
|
249
|
+
return err(translateCommandFailure(operation, captured.error));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const isNativeOperation = (operation) => NATIVE_TOOL_CONTRACTS.some(({ name }) => name === operation);
|
|
253
|
+
const translateCommandFailure = (operation, failure) => {
|
|
254
|
+
if (failure.reason === "unavailable")
|
|
255
|
+
return new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, `${failure.tool} is unavailable through xcrun.`);
|
|
256
|
+
if (failure.reason === "cancelled")
|
|
257
|
+
return new AnalysisCancelledError(operation);
|
|
258
|
+
if (failure.reason === "timeout")
|
|
259
|
+
return new AnalysisTimeoutError(operation, TIMEOUT_MS);
|
|
260
|
+
return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure });
|
|
261
|
+
};
|
|
262
|
+
const invocation = (capture, artifactPath, alias = "$ARTIFACT") => ({
|
|
263
|
+
tool: capture.tool,
|
|
264
|
+
command: [
|
|
265
|
+
capture.executable,
|
|
266
|
+
...capture.arguments.map((argument) => argument === artifactPath ? alias : argument),
|
|
267
|
+
],
|
|
268
|
+
tool_version: capture.toolVersion,
|
|
269
|
+
version_reason: capture.versionReason,
|
|
270
|
+
executable_sha256: capture.executableSha256,
|
|
271
|
+
exit: { code: capture.exitCode, signal: capture.signal },
|
|
272
|
+
stdout_bytes: capture.stdoutBytes,
|
|
273
|
+
stderr_bytes: capture.stderrBytes,
|
|
274
|
+
stdout_truncated: capture.stdoutTruncated,
|
|
275
|
+
stderr_truncated: capture.stderrTruncated,
|
|
276
|
+
});
|
|
277
|
+
const parseEntitlements = (output) => {
|
|
278
|
+
const start = output.indexOf("<?xml");
|
|
279
|
+
const end = output.lastIndexOf("</plist>");
|
|
280
|
+
if (start < 0 || end < start)
|
|
281
|
+
return null;
|
|
282
|
+
return jsonValueSchema.parse(parseXmlPlist(output.slice(start, end + "</plist>".length)));
|
|
283
|
+
};
|
|
284
|
+
const resolvePlistPath = async (target, requested) => {
|
|
285
|
+
try {
|
|
286
|
+
const source = target.sourcePath ?? target.path;
|
|
287
|
+
const sourceMetadata = await stat(source);
|
|
288
|
+
const root = sourceMetadata.isDirectory() ? source : dirname(target.path);
|
|
289
|
+
const candidate = resolve(root, requested);
|
|
290
|
+
if (!within(root, candidate))
|
|
291
|
+
return err(new ProviderAdapterError(IDENTITY.id, "inspect_plist"));
|
|
292
|
+
const canonicalRoot = await realpath(root);
|
|
293
|
+
const canonical = await realpath(candidate);
|
|
294
|
+
if (!within(canonicalRoot, canonical))
|
|
295
|
+
return err(new ProviderAdapterError(IDENTITY.id, "inspect_plist"));
|
|
296
|
+
return ok(canonical);
|
|
297
|
+
}
|
|
298
|
+
catch (cause) {
|
|
299
|
+
return err(new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }));
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const within = (root, candidate) => {
|
|
303
|
+
const child = relative(root, candidate);
|
|
304
|
+
return (child === "" ||
|
|
305
|
+
(!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child)));
|
|
306
|
+
};
|