rea-agents 0.3.0 → 0.5.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 +139 -21
- package/bridge/hopper_bridge.py +156 -14
- 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 +174 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +44 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +10 -7
- package/dist/application/EvidenceBundleCommands.js +51 -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 +105 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +116 -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 +229 -21
- package/dist/cliEvidenceCommands.js +68 -0
- package/dist/cliLogging.js +21 -0
- 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 +156 -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 +199 -5
- 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 +35 -8
- 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/referenceSourcePolicy.js +1 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +39 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/hopper/protocol.js +13 -2
- 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 +18 -1
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
|
@@ -5,6 +5,8 @@ import { basename, dirname, extname, join } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { HopperCancelledError, HopperStartError } from "../domain/errors.js";
|
|
7
7
|
import { err, ok } from "../domain/result.js";
|
|
8
|
+
import { cleanupOwnedProcessGroup, } from "../application/ProcessOwnership.js";
|
|
9
|
+
import writeFileAtomic from "write-file-atomic";
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
|
|
10
12
|
/**
|
|
@@ -17,8 +19,9 @@ const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
|
|
|
17
19
|
* supported launcher; callers must treat possible foreground UI as an upstream
|
|
18
20
|
* Hopper constraint, not as evidence that analysis failed.
|
|
19
21
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
+
* REA owns only the short-lived launcher helper's run-token-authenticated
|
|
23
|
+
* process group. It does not claim ownership of the Hopper GUI process that
|
|
24
|
+
* macOS LaunchServices may create or reuse.
|
|
22
25
|
*/
|
|
23
26
|
export class HopperApplicationLauncher {
|
|
24
27
|
options;
|
|
@@ -30,6 +33,7 @@ export class HopperApplicationLauncher {
|
|
|
30
33
|
const source = [
|
|
31
34
|
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
|
|
32
35
|
`REA_TOKEN = ${JSON.stringify(session.token)}`,
|
|
36
|
+
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
|
|
33
37
|
`exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
|
|
34
38
|
"",
|
|
35
39
|
].join("\n");
|
|
@@ -57,6 +61,8 @@ export class HopperApplicationLauncher {
|
|
|
57
61
|
return err(new HopperCancelledError());
|
|
58
62
|
const child = spawn(this.options.launcherPath, args, {
|
|
59
63
|
stdio: ["ignore", "ignore", "pipe"],
|
|
64
|
+
detached: process.platform !== "win32",
|
|
65
|
+
env: { ...process.env, REA_PROCESS_RUN_ID: session.runId },
|
|
60
66
|
});
|
|
61
67
|
const started = await new Promise((resolve) => {
|
|
62
68
|
const onSpawn = () => {
|
|
@@ -72,13 +78,43 @@ export class HopperApplicationLauncher {
|
|
|
72
78
|
});
|
|
73
79
|
if (!started.ok)
|
|
74
80
|
return started;
|
|
75
|
-
|
|
81
|
+
const pid = child.pid;
|
|
82
|
+
if (pid === undefined)
|
|
83
|
+
return err(new HopperStartError({ cause: new Error("missing launcher PID") }));
|
|
84
|
+
const ownership = {
|
|
85
|
+
schema_version: 1,
|
|
86
|
+
run_id: session.runId,
|
|
87
|
+
pid,
|
|
88
|
+
process_group_id: pid,
|
|
89
|
+
parent_pid: process.pid,
|
|
90
|
+
launcher: this.options.launcherPath,
|
|
91
|
+
created_at: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
await writeFileAtomic(join(session.directory, "ownership.json"), `${JSON.stringify(ownership)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
95
|
+
}
|
|
96
|
+
catch (cause) {
|
|
97
|
+
await cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, this.options.launcherPath));
|
|
98
|
+
return err(new HopperStartError({ cause }));
|
|
99
|
+
}
|
|
100
|
+
return ok({
|
|
101
|
+
process: child,
|
|
102
|
+
ownsProcessLifetime: true,
|
|
103
|
+
cleanup: () => cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, this.options.launcherPath)),
|
|
104
|
+
});
|
|
76
105
|
}
|
|
77
106
|
catch (cause) {
|
|
78
107
|
return err(new HopperStartError({ cause }));
|
|
79
108
|
}
|
|
80
109
|
}
|
|
81
110
|
}
|
|
111
|
+
const ownedProcessGroup = (session, pid, launcherPath) => ({
|
|
112
|
+
runId: session.runId,
|
|
113
|
+
leaderPid: pid,
|
|
114
|
+
processGroupId: pid,
|
|
115
|
+
expectedCommand: launcherPath,
|
|
116
|
+
expectedParentPid: process.pid,
|
|
117
|
+
});
|
|
82
118
|
const prepareHopperApplication = async (launcherPath, signal) => {
|
|
83
119
|
const appBundle = hopperApplicationBundle(launcherPath);
|
|
84
120
|
if (appBundle === undefined)
|
|
@@ -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/hopper/protocol.js
CHANGED
|
@@ -9,7 +9,18 @@ const responseSchema = z.union([
|
|
|
9
9
|
}),
|
|
10
10
|
z.object({
|
|
11
11
|
id: z.number().int().nonnegative(),
|
|
12
|
-
error: z.object({
|
|
12
|
+
error: z.object({
|
|
13
|
+
code: z.number().int(),
|
|
14
|
+
message: z.string(),
|
|
15
|
+
type: z
|
|
16
|
+
.enum([
|
|
17
|
+
"remote",
|
|
18
|
+
"authorization",
|
|
19
|
+
"invalid_request",
|
|
20
|
+
"bridge_exception",
|
|
21
|
+
])
|
|
22
|
+
.default("remote"),
|
|
23
|
+
}),
|
|
13
24
|
}),
|
|
14
25
|
]);
|
|
15
26
|
/** Parse one complete Hopper NDJSON response line. */
|
|
@@ -28,5 +39,5 @@ export const parseResponseLine = (line) => {
|
|
|
28
39
|
};
|
|
29
40
|
/** Project a parsed response into its result or expected remote failure. */
|
|
30
41
|
export const responseResult = (response) => "error" in response
|
|
31
|
-
? err(new HopperRemoteError(response.error.code, response.error.message))
|
|
42
|
+
? err(new HopperRemoteError(response.error.code, response.error.message, response.error.type))
|
|
32
43
|
: ok(response.result);
|
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
|
+
});
|