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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { copyFile, lstat, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
4
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
5
|
+
import writeFileAtomic from "write-file-atomic";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
8
|
+
import { supportedClients } from "./Setup.js";
|
|
9
|
+
/** Remove only REA-owned registrations and managed files, optionally including local state. */
|
|
10
|
+
export const runUninstall = async (purgeData, host = systemUninstallHost()) => {
|
|
11
|
+
const items = [];
|
|
12
|
+
for (const client of await host.clients())
|
|
13
|
+
items.push(await host.removeClient(client));
|
|
14
|
+
items.push(await host.removeSkill());
|
|
15
|
+
if (purgeData)
|
|
16
|
+
items.push(...(await host.purgeData()));
|
|
17
|
+
items.push({
|
|
18
|
+
name: "analysis_engine",
|
|
19
|
+
status: "retained",
|
|
20
|
+
detail: "Hopper is not owned by REA uninstall.",
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
status: items.some(({ status }) => status === "failed")
|
|
24
|
+
? "failed"
|
|
25
|
+
: "complete",
|
|
26
|
+
items,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
|
|
30
|
+
export const systemUninstallHost = (home = homedir()) => ({
|
|
31
|
+
clients: () => Promise.resolve(supportedClients(home)),
|
|
32
|
+
removeClient,
|
|
33
|
+
removeSkill: () => removeManagedPath(join(home, ".agents/skills", PRODUCT_IDENTITY.skillName), "skill"),
|
|
34
|
+
purgeData: async () => [
|
|
35
|
+
await removeManagedPath(join(home, ".rea/cache"), "cache"),
|
|
36
|
+
await removeManagedPath(join(home, ".rea/state"), "state"),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
const removeClient = async (client) => {
|
|
40
|
+
if (client.format === "unsupported")
|
|
41
|
+
return item(client.name, "skipped", "This client has no documented local MCP configuration boundary.");
|
|
42
|
+
let original;
|
|
43
|
+
try {
|
|
44
|
+
original = await readFile(client.configPath, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch (cause) {
|
|
47
|
+
return isMissing(cause)
|
|
48
|
+
? item(client.name, "skipped", "Configuration does not exist.")
|
|
49
|
+
: item(client.name, "failed", "Configuration could not be read.");
|
|
50
|
+
}
|
|
51
|
+
let document;
|
|
52
|
+
let key;
|
|
53
|
+
let servers;
|
|
54
|
+
try {
|
|
55
|
+
document = objectSchema.parse(client.format === "toml" ? parseToml(original) : JSON.parse(original));
|
|
56
|
+
key = client.format === "toml" ? "mcp_servers" : "mcpServers";
|
|
57
|
+
servers = optionalObject(document[key]);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return item(client.name, "failed", "Malformed configuration was preserved without mutation.");
|
|
61
|
+
}
|
|
62
|
+
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
|
|
63
|
+
if (registration === undefined)
|
|
64
|
+
return item(client.name, "skipped", "REA registration is absent.");
|
|
65
|
+
if (!isOwnedRegistration(registration))
|
|
66
|
+
return item(client.name, "retained", "The rea-named registration is not REA-owned.");
|
|
67
|
+
const remaining = { ...servers };
|
|
68
|
+
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
|
|
69
|
+
document[key] = remaining;
|
|
70
|
+
const backupPath = `${client.configPath}.rea.backup`;
|
|
71
|
+
try {
|
|
72
|
+
await copyFile(client.configPath, backupPath);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return item(client.name, "failed", "Configuration backup failed; no mutation was attempted.");
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const encoded = client.format === "toml"
|
|
79
|
+
? stringifyToml(document)
|
|
80
|
+
: `${JSON.stringify(document, null, 2)}\n`;
|
|
81
|
+
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
|
|
82
|
+
const readback = objectSchema.parse(client.format === "toml"
|
|
83
|
+
? parseToml(await readFile(client.configPath, "utf8"))
|
|
84
|
+
: JSON.parse(await readFile(client.configPath, "utf8")));
|
|
85
|
+
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
|
|
86
|
+
throw new Error("registration readback mismatch");
|
|
87
|
+
return item(client.name, "removed", `Removed registration from ${client.configPath}.`);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
await writeFileAtomic(client.configPath, original, {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
}).catch(() => undefined);
|
|
93
|
+
return item(client.name, "failed", `Configuration update failed; the original was restored and ${backupPath} was retained.`);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** Ownership rule for persistent MCP registrations created by REA setup. */
|
|
97
|
+
const isOwnedRegistration = (value) => {
|
|
98
|
+
const parsed = registrationSchema.safeParse(value);
|
|
99
|
+
if (!parsed.success)
|
|
100
|
+
return false;
|
|
101
|
+
const { command, args } = parsed.data;
|
|
102
|
+
return (((command === "rea" ||
|
|
103
|
+
(isAbsolute(command) && basename(command) === "rea")) &&
|
|
104
|
+
args.length === 1 &&
|
|
105
|
+
args[0] === "mcp") ||
|
|
106
|
+
(command === "npx" &&
|
|
107
|
+
JSON.stringify(args) ===
|
|
108
|
+
JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"])));
|
|
109
|
+
};
|
|
110
|
+
const removeManagedPath = async (path, name) => {
|
|
111
|
+
try {
|
|
112
|
+
const stats = await lstat(path);
|
|
113
|
+
if (stats.isSymbolicLink())
|
|
114
|
+
return item(name, "retained", `Refused to follow symlink ${path}.`);
|
|
115
|
+
await rm(path, { recursive: true });
|
|
116
|
+
return item(name, "removed", `Removed ${path}.`);
|
|
117
|
+
}
|
|
118
|
+
catch (cause) {
|
|
119
|
+
return isMissing(cause)
|
|
120
|
+
? item(name, "skipped", `${path} does not exist.`)
|
|
121
|
+
: item(name, "failed", `${path} could not be removed.`);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const item = (name, status, detail) => ({ name, status, detail });
|
|
125
|
+
const objectSchema = z.record(z.string(), z.unknown());
|
|
126
|
+
const optionalObject = (value) => value === undefined ? {} : objectSchema.parse(value);
|
|
127
|
+
const registrationSchema = z
|
|
128
|
+
.object({ command: z.string(), args: z.array(z.string()) })
|
|
129
|
+
.passthrough();
|
|
130
|
+
const isMissing = (cause) => cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { BinarySession } from "./BinarySession.js";
|
|
2
2
|
import { HopperProvider } from "../hopper/HopperProvider.js";
|
|
3
|
+
import { NativeMacOSProvider } from "../native/NativeMacOSProvider.js";
|
|
4
|
+
import { ArtifactProvider } from "../artifacts/ArtifactProvider.js";
|
|
5
|
+
import { CompositeProvider } from "./CompositeProvider.js";
|
|
3
6
|
import { silentLogger } from "../logger.js";
|
|
4
7
|
/**
|
|
5
8
|
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
|
|
@@ -7,5 +10,9 @@ import { silentLogger } from "../logger.js";
|
|
|
7
10
|
* out to the other and both retain identical loader and bridge semantics.
|
|
8
11
|
*/
|
|
9
12
|
export const createBinarySession = (config, logger = silentLogger) => {
|
|
10
|
-
return new BinarySession(new
|
|
13
|
+
return new BinarySession(new CompositeProvider([
|
|
14
|
+
new ArtifactProvider(),
|
|
15
|
+
new NativeMacOSProvider(),
|
|
16
|
+
new HopperProvider(config, logger),
|
|
17
|
+
]));
|
|
11
18
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
4
|
+
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
|
|
5
|
+
/** Normalize an untrusted logical archive path without touching filesystem. */
|
|
6
|
+
export const normalizeArtifactPath = (input, limits) => {
|
|
7
|
+
if (input.includes("\0") ||
|
|
8
|
+
input.includes("\\") ||
|
|
9
|
+
input.startsWith("/") ||
|
|
10
|
+
DRIVE_OR_UNC.test(input))
|
|
11
|
+
throw new ArtifactReaderFailure("path", "Artifact path is absolute or unsafe");
|
|
12
|
+
const normalized = input.normalize("NFC").replace(/\/+$/u, "");
|
|
13
|
+
const parts = normalized.split("/");
|
|
14
|
+
if (normalized.length === 0 ||
|
|
15
|
+
parts.some((part) => part === "" || part === "." || part === "..") ||
|
|
16
|
+
parts.length > limits.maxDepth ||
|
|
17
|
+
Buffer.byteLength(normalized, "utf8") > limits.maxPathBytes ||
|
|
18
|
+
posix.normalize(normalized) !== normalized)
|
|
19
|
+
throw new ArtifactReaderFailure("path", "Artifact path is not normalized");
|
|
20
|
+
return normalized;
|
|
21
|
+
};
|
|
22
|
+
/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */
|
|
23
|
+
export class ArtifactPathRegistry {
|
|
24
|
+
#root = { kind: undefined, children: new Map() };
|
|
25
|
+
#folded = new Set();
|
|
26
|
+
add(path, kind) {
|
|
27
|
+
const folded = path.toLocaleLowerCase("en-US");
|
|
28
|
+
if (this.#folded.has(folded))
|
|
29
|
+
throw new ArtifactReaderFailure("path", `Artifact path collision: ${path}`);
|
|
30
|
+
const parts = path.split("/");
|
|
31
|
+
let node = this.#root;
|
|
32
|
+
for (const [index, part] of parts.entries()) {
|
|
33
|
+
if (node.kind !== undefined && node.kind !== "directory")
|
|
34
|
+
throw new ArtifactReaderFailure("path", `Artifact prefix conflict: ${path}`);
|
|
35
|
+
let child = node.children.get(part);
|
|
36
|
+
if (child === undefined) {
|
|
37
|
+
child = { kind: undefined, children: new Map() };
|
|
38
|
+
node.children.set(part, child);
|
|
39
|
+
}
|
|
40
|
+
node = child;
|
|
41
|
+
if (index === parts.length - 1) {
|
|
42
|
+
if (node.kind !== undefined)
|
|
43
|
+
throw new ArtifactReaderFailure("path", `Artifact path collision: ${path}`);
|
|
44
|
+
if (kind !== "directory" && node.children.size > 0)
|
|
45
|
+
throw new ArtifactReaderFailure("path", `Artifact prefix conflict: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
node.kind = kind;
|
|
49
|
+
this.#folded.add(folded);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createAnalysisExecution, } from "../application/AnalysisProvider.js";
|
|
2
|
+
import { inventoryArtifact } from "../application/ArtifactInventory.js";
|
|
3
|
+
import { extractArtifact } from "../application/ArtifactExtraction.js";
|
|
4
|
+
import { ARTIFACT_TOOL_CONTRACTS, artifactInventoryInputSchema, artifactExtractionInputSchema, } from "../contracts/artifactToolContracts.js";
|
|
5
|
+
import { AnalysisCapabilityUnavailableError, ArtifactOperationError, } from "../domain/errors.js";
|
|
6
|
+
import { err, ok } from "../domain/result.js";
|
|
7
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
8
|
+
const IDENTITY = Object.freeze({
|
|
9
|
+
id: "rea-artifact-graph",
|
|
10
|
+
name: "REA safe artifact graph provider",
|
|
11
|
+
version: "1",
|
|
12
|
+
});
|
|
13
|
+
/** Read-only inventory and exclusively owned extraction provider. */
|
|
14
|
+
export class ArtifactProvider {
|
|
15
|
+
#capabilities = Object.freeze(ARTIFACT_TOOL_CONTRACTS.map((contract) => Object.freeze({
|
|
16
|
+
provider: IDENTITY,
|
|
17
|
+
operation: contract.name,
|
|
18
|
+
inputContractVersion: 1,
|
|
19
|
+
outputContractVersion: 1,
|
|
20
|
+
available: true,
|
|
21
|
+
reason: null,
|
|
22
|
+
pagination: "offset",
|
|
23
|
+
exhaustive: false,
|
|
24
|
+
effects: Object.freeze({
|
|
25
|
+
mutatesArtifact: false,
|
|
26
|
+
launchesProcess: true,
|
|
27
|
+
mayShowUi: false,
|
|
28
|
+
mayAccessNetwork: false,
|
|
29
|
+
mayWriteFilesystem: contract.name === "extract_artifact",
|
|
30
|
+
changesPermissions: false,
|
|
31
|
+
requiresRoot: false,
|
|
32
|
+
}),
|
|
33
|
+
limits: Object.freeze({
|
|
34
|
+
maxResults: 500,
|
|
35
|
+
maxPayloadBytes: 4 * 1024 * 1024,
|
|
36
|
+
timeoutMs: 120_000,
|
|
37
|
+
}),
|
|
38
|
+
limitations: Object.freeze([
|
|
39
|
+
"DMG and PKG child inventory requires a future native read-only adapter.",
|
|
40
|
+
"Nested containers are recorded but not recursively expanded implicitly.",
|
|
41
|
+
]),
|
|
42
|
+
})));
|
|
43
|
+
identity() {
|
|
44
|
+
return IDENTITY;
|
|
45
|
+
}
|
|
46
|
+
capabilities() {
|
|
47
|
+
return this.#capabilities;
|
|
48
|
+
}
|
|
49
|
+
createClient(target) {
|
|
50
|
+
return new ArtifactClient(target);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
class ArtifactClient {
|
|
54
|
+
target;
|
|
55
|
+
constructor(target) {
|
|
56
|
+
this.target = target;
|
|
57
|
+
}
|
|
58
|
+
async execute(operation, parameters, options) {
|
|
59
|
+
if (operation === "health")
|
|
60
|
+
return ok(createAnalysisExecution(null, IDENTITY));
|
|
61
|
+
if (!isArtifactOperation(operation))
|
|
62
|
+
return err(new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, "Operation is not implemented by artifact graph provider."));
|
|
63
|
+
try {
|
|
64
|
+
if (operation === "extract_artifact") {
|
|
65
|
+
const parsed = artifactExtractionInputSchema.parse(parameters);
|
|
66
|
+
const result = await extractArtifact({
|
|
67
|
+
inputPath: this.target.sourcePath ?? this.target.path,
|
|
68
|
+
inputFormat: this.target.format,
|
|
69
|
+
outputRoot: parsed.output_root,
|
|
70
|
+
occurrenceIds: parsed.occurrence_ids,
|
|
71
|
+
offset: parsed.offset,
|
|
72
|
+
limit: parsed.limit,
|
|
73
|
+
limits: limitsFrom(parsed),
|
|
74
|
+
}, options?.signal);
|
|
75
|
+
return ok(createAnalysisExecution(result, IDENTITY, {
|
|
76
|
+
rawResult: null,
|
|
77
|
+
limitations: result.limitations,
|
|
78
|
+
subject: subjectFor(this.target.sourcePath ?? this.target.path, result.manifest),
|
|
79
|
+
locations: result.artifacts.items.map(({ relative_path: path }) => ({
|
|
80
|
+
kind: "artifact-path",
|
|
81
|
+
path,
|
|
82
|
+
})),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
const parsed = artifactInventoryInputSchema.parse(parameters);
|
|
86
|
+
const result = await inventoryArtifact(this.target.sourcePath ?? this.target.path, limitsFrom(parsed), {
|
|
87
|
+
nodeOffset: parsed.node_offset,
|
|
88
|
+
nodeLimit: parsed.node_limit,
|
|
89
|
+
occurrenceOffset: parsed.occurrence_offset,
|
|
90
|
+
occurrenceLimit: parsed.occurrence_limit,
|
|
91
|
+
edgeOffset: parsed.edge_offset,
|
|
92
|
+
edgeLimit: parsed.edge_limit,
|
|
93
|
+
}, options?.signal);
|
|
94
|
+
return ok(createAnalysisExecution(result, IDENTITY, {
|
|
95
|
+
rawResult: null,
|
|
96
|
+
limitations: result.limitations,
|
|
97
|
+
subject: subjectFor(this.target.sourcePath ?? this.target.path, result.manifest),
|
|
98
|
+
locations: result.occurrences.items.map(({ logical_path: path }) => ({
|
|
99
|
+
kind: "artifact-path",
|
|
100
|
+
path,
|
|
101
|
+
})),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
catch (cause) {
|
|
105
|
+
return err(translateFailure(operation, cause));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const limitsFrom = (input) => ({
|
|
113
|
+
maxEntries: input.max_entries,
|
|
114
|
+
maxTotalBytes: input.max_total_bytes,
|
|
115
|
+
maxEntryBytes: input.max_entry_bytes,
|
|
116
|
+
maxCompressionRatio: input.max_compression_ratio,
|
|
117
|
+
maxDepth: input.max_depth,
|
|
118
|
+
maxPathBytes: input.max_path_bytes,
|
|
119
|
+
});
|
|
120
|
+
const isArtifactOperation = (operation) => ARTIFACT_TOOL_CONTRACTS.some(({ name }) => name === operation);
|
|
121
|
+
const translateFailure = (operation, cause) => {
|
|
122
|
+
if (cause instanceof ArtifactReaderFailure)
|
|
123
|
+
return new ArtifactOperationError(operation, cause.reason);
|
|
124
|
+
return new ArtifactOperationError(operation, "io");
|
|
125
|
+
};
|
|
126
|
+
const subjectFor = (path, manifest) => ({
|
|
127
|
+
path,
|
|
128
|
+
sha256: manifest.root_sha256,
|
|
129
|
+
format: manifest.root_format,
|
|
130
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Typed adapter failure translated at provider boundary. */
|
|
2
|
+
export class ArtifactReaderFailure extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(reason, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.reason = reason;
|
|
7
|
+
this.name = "ArtifactReaderFailure";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { extractFile, listPackage, statFile } from "@electron/asar";
|
|
3
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
4
|
+
/** Official Electron ASAR adapter. Individual files remain caller-bounded. */
|
|
5
|
+
export class AsarArtifactReader {
|
|
6
|
+
path;
|
|
7
|
+
format = "asar";
|
|
8
|
+
constructor(path) {
|
|
9
|
+
this.path = path;
|
|
10
|
+
}
|
|
11
|
+
async *entries(signal) {
|
|
12
|
+
const paths = listPackage(this.path, { isPack: false }).sort((left, right) => left.localeCompare(right, "en"));
|
|
13
|
+
for (const listed of paths) {
|
|
14
|
+
abortIfNeeded(signal);
|
|
15
|
+
const path = listed.replace(/^\/+|\/+$/gu, "");
|
|
16
|
+
if (path.length === 0)
|
|
17
|
+
continue;
|
|
18
|
+
const metadata = statFile(this.path, path, false);
|
|
19
|
+
const kind = "files" in metadata
|
|
20
|
+
? "directory"
|
|
21
|
+
: "link" in metadata
|
|
22
|
+
? "symlink"
|
|
23
|
+
: "file";
|
|
24
|
+
yield {
|
|
25
|
+
path,
|
|
26
|
+
kind,
|
|
27
|
+
declaredSize: "size" in metadata ? metadata.size : null,
|
|
28
|
+
compressedSize: null,
|
|
29
|
+
executable: "executable" in metadata && metadata.executable,
|
|
30
|
+
encrypted: false,
|
|
31
|
+
byteOffset: null,
|
|
32
|
+
declaredSha256: "integrity" in metadata &&
|
|
33
|
+
metadata.integrity.algorithm === "SHA256" &&
|
|
34
|
+
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
|
35
|
+
? metadata.integrity.hash
|
|
36
|
+
: null,
|
|
37
|
+
limitations: kind === "symlink"
|
|
38
|
+
? ["ASAR symlink target was not followed or disclosed."]
|
|
39
|
+
: [
|
|
40
|
+
"Official ASAR extraction buffers each individually bounded file.",
|
|
41
|
+
],
|
|
42
|
+
adapterKey: path,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
open(entry, signal) {
|
|
47
|
+
abortIfNeeded(signal);
|
|
48
|
+
if (entry.kind !== "file")
|
|
49
|
+
return Promise.reject(new ArtifactReaderFailure("format", "ASAR entry is not a regular file"));
|
|
50
|
+
return Promise.resolve(Readable.from(extractFile(this.path, entry.adapterKey, false)));
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
provenance() {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const abortIfNeeded = (signal) => {
|
|
60
|
+
if (signal?.aborted === true)
|
|
61
|
+
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
|
|
62
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
5
|
+
/** Deterministic, non-symlink-following directory reader. */
|
|
6
|
+
export class DirectoryArtifactReader {
|
|
7
|
+
format = "directory";
|
|
8
|
+
#rootPromise;
|
|
9
|
+
constructor(root) {
|
|
10
|
+
this.#rootPromise = realpath(root);
|
|
11
|
+
}
|
|
12
|
+
async *entries(signal) {
|
|
13
|
+
const root = await this.#rootPromise;
|
|
14
|
+
const pending = [root];
|
|
15
|
+
while (pending.length > 0) {
|
|
16
|
+
abortIfNeeded(signal);
|
|
17
|
+
const directory = pending.pop();
|
|
18
|
+
if (directory === undefined)
|
|
19
|
+
break;
|
|
20
|
+
await assertContainedDirectory(root, directory);
|
|
21
|
+
const children = [];
|
|
22
|
+
const handle = await opendir(directory);
|
|
23
|
+
try {
|
|
24
|
+
for await (const child of handle)
|
|
25
|
+
children.push(child.name);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await handle.close().catch(() => undefined);
|
|
29
|
+
}
|
|
30
|
+
await assertContainedDirectory(root, directory);
|
|
31
|
+
children.sort((left, right) => left.localeCompare(right, "en"));
|
|
32
|
+
const directories = [];
|
|
33
|
+
for (const name of children) {
|
|
34
|
+
abortIfNeeded(signal);
|
|
35
|
+
const absolute = join(directory, name);
|
|
36
|
+
const metadata = await lstat(absolute);
|
|
37
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
38
|
+
const kind = metadata.isSymbolicLink()
|
|
39
|
+
? "symlink"
|
|
40
|
+
: metadata.isDirectory()
|
|
41
|
+
? "directory"
|
|
42
|
+
: metadata.isFile()
|
|
43
|
+
? "file"
|
|
44
|
+
: undefined;
|
|
45
|
+
if (kind === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
if (kind === "directory")
|
|
48
|
+
directories.push(absolute);
|
|
49
|
+
yield {
|
|
50
|
+
path,
|
|
51
|
+
kind,
|
|
52
|
+
declaredSize: kind === "file" ? metadata.size : null,
|
|
53
|
+
compressedSize: null,
|
|
54
|
+
executable: (metadata.mode & 0o111) !== 0,
|
|
55
|
+
encrypted: false,
|
|
56
|
+
byteOffset: null,
|
|
57
|
+
declaredSha256: null,
|
|
58
|
+
limitations: kind === "symlink"
|
|
59
|
+
? ["Symlink target was not followed or disclosed."]
|
|
60
|
+
: [],
|
|
61
|
+
adapterKey: absolute,
|
|
62
|
+
...(kind === "file"
|
|
63
|
+
? { sourceIdentity: { device: metadata.dev, inode: metadata.ino } }
|
|
64
|
+
: {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
directories.reverse();
|
|
68
|
+
pending.push(...directories);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async open(entry, signal) {
|
|
72
|
+
abortIfNeeded(signal);
|
|
73
|
+
if (entry.kind !== "file")
|
|
74
|
+
throw new ArtifactReaderFailure("format", "Only regular files can be opened");
|
|
75
|
+
const handle = await open(entry.adapterKey, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
76
|
+
const observed = await handle.stat();
|
|
77
|
+
if (!observed.isFile() ||
|
|
78
|
+
entry.sourceIdentity === undefined ||
|
|
79
|
+
observed.dev !== entry.sourceIdentity.device ||
|
|
80
|
+
observed.ino !== entry.sourceIdentity.inode) {
|
|
81
|
+
await handle.close();
|
|
82
|
+
throw new ArtifactReaderFailure("integrity", `Directory entry changed before open: ${entry.path}`);
|
|
83
|
+
}
|
|
84
|
+
return handle.createReadStream({ autoClose: true });
|
|
85
|
+
}
|
|
86
|
+
close() {
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
provenance() {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const abortIfNeeded = (signal) => {
|
|
94
|
+
if (signal?.aborted === true)
|
|
95
|
+
throw new ArtifactReaderFailure("cancelled", "Artifact traversal cancelled");
|
|
96
|
+
};
|
|
97
|
+
const assertContainedDirectory = async (root, directory) => {
|
|
98
|
+
const canonical = await realpath(directory);
|
|
99
|
+
const fromRoot = relative(root, canonical);
|
|
100
|
+
const metadata = await lstat(directory);
|
|
101
|
+
if (metadata.isSymbolicLink() ||
|
|
102
|
+
!metadata.isDirectory() ||
|
|
103
|
+
fromRoot === ".." ||
|
|
104
|
+
fromRoot.startsWith(`..${sep}`) ||
|
|
105
|
+
isAbsolute(fromRoot))
|
|
106
|
+
throw new ArtifactReaderFailure("path", "Directory traversal escaped or changed its artifact root");
|
|
107
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { XcrunCommandRunner, } from "../native/CommandRunner.js";
|
|
3
|
+
import { parseLipoArchitectures } from "../native/parsers/lipo.js";
|
|
4
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
5
|
+
/** Read-only universal Mach-O slice reader backed by native lipo metadata. */
|
|
6
|
+
export class MachOSliceArtifactReader {
|
|
7
|
+
path;
|
|
8
|
+
runner;
|
|
9
|
+
format = "file";
|
|
10
|
+
#command;
|
|
11
|
+
constructor(path, runner = new XcrunCommandRunner()) {
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.runner = runner;
|
|
14
|
+
}
|
|
15
|
+
async *entries(signal) {
|
|
16
|
+
const captured = await this.runner.run("lipo", ["-detailed_info", this.path], {
|
|
17
|
+
...(signal === undefined ? {} : { signal }),
|
|
18
|
+
timeoutMs: 30_000,
|
|
19
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
20
|
+
});
|
|
21
|
+
if (!captured.ok)
|
|
22
|
+
throw new ArtifactReaderFailure(captured.error.reason === "cancelled" ? "cancelled" : "unavailable", "lipo could not enumerate universal Mach-O slices", { cause: captured.error });
|
|
23
|
+
this.#command = {
|
|
24
|
+
tool: captured.value.tool,
|
|
25
|
+
arguments: ["-detailed_info", "$ARTIFACT"],
|
|
26
|
+
tool_version: captured.value.toolVersion,
|
|
27
|
+
executable_sha256: captured.value.executableSha256,
|
|
28
|
+
exit_code: captured.value.exitCode,
|
|
29
|
+
effects: ["read"],
|
|
30
|
+
};
|
|
31
|
+
for (const architecture of parseLipoArchitectures(captured.value.stdout)) {
|
|
32
|
+
if (architecture.file_offset === null || architecture.size === null)
|
|
33
|
+
throw new ArtifactReaderFailure("integrity", "lipo omitted a universal slice byte range");
|
|
34
|
+
yield {
|
|
35
|
+
path: `slices/${architecture.name}`,
|
|
36
|
+
kind: "slice",
|
|
37
|
+
declaredSize: architecture.size,
|
|
38
|
+
compressedSize: null,
|
|
39
|
+
executable: true,
|
|
40
|
+
encrypted: false,
|
|
41
|
+
byteOffset: architecture.file_offset,
|
|
42
|
+
declaredSha256: null,
|
|
43
|
+
limitations: [],
|
|
44
|
+
adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
open(entry, signal) {
|
|
49
|
+
if (signal?.aborted === true)
|
|
50
|
+
return Promise.reject(new ArtifactReaderFailure("cancelled", "Mach-O slice read cancelled"));
|
|
51
|
+
const [offsetText, sizeText] = entry.adapterKey.split(":");
|
|
52
|
+
const offset = Number(offsetText);
|
|
53
|
+
const size = Number(sizeText);
|
|
54
|
+
if (!Number.isSafeInteger(offset) ||
|
|
55
|
+
!Number.isSafeInteger(size) ||
|
|
56
|
+
size <= 0)
|
|
57
|
+
return Promise.reject(new ArtifactReaderFailure("integrity", "Invalid Mach-O slice byte range"));
|
|
58
|
+
return Promise.resolve(createReadStream(this.path, { start: offset, end: offset + size - 1 }));
|
|
59
|
+
}
|
|
60
|
+
provenance() {
|
|
61
|
+
return this.#command === undefined ? [] : [this.#command];
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
}
|