fix(artifacts): propagate conditional loads and bind digests to parsed images

- Edges below a conditional fallback now carry loader_conditional, reuse of
  a conditional image by install name is conditional, and their findings say
  they apply only if that image loads.
- Hash each traced image only if it is still the file whose headers were
  parsed (device, inode, size and change times), before and after hashing.
- Resolve explicit roots segment by segment, so a root below a symlinked
  directory that points outside the analyzed root is rejected.
- Require a complete 16-byte LC_CODE_SIGNATURE command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 12:58:20 +00:00
1 parent 9049620dfe
commit 5e334a0e12
12 files changed
+208 -30

No files matched your search

+5
View File
@@ -163,6 +163,11 @@ graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `r
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
A `conditional` image is still traced, because it is the image the analyzed
root supplies. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. Findings on those
edges say that they apply only if the conditional image loads.
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
`outside-target`. On macOS 11 and later most of them live in the dyld shared
cache rather than on disk, so REA does not check them against the host.
+2 -2
View File
@@ -672,9 +672,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "23afd2ad272de67c0f3c350e36eb68f3eafa229cd135797a6b4bbdb1acea18d7",
"tools_sha256": "7c89ac4dba6aa708f3e5fc7a8b702136833d8ab8c99e117e3256aa690660e7e5",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "9d48bf6a2a523c2b472f7273797aeab30bf103bbd1ba74e21c23c3306b3d2e60",
"combined_sha256": "ad4b34f04957f5f4eb15a664684dadd690d820fccc7073a14968c35e10848095",
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_0f113449440f920714b896b90f99dfd94ac35b0ba56f56f759c4a77ee9ddd2ba",
"manifest_id": "ecm_e06a3c238cfd76f6deee52d24fdaefddc4be80bf633bb5dc43cab72e472e0881",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "2ed9cb3039053459635b32739cf4fbf0ef7670c85ed246aa3b7433d1d7193457"
"sha256": "0af79d55c9243e40b5e96b8e1f390284f4c28c98892d1b9fc4a65d1f4064015a"
}
],
"claims": [
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "28"
tool_count: 132
catalog_digest: "9d48bf6a2a523c2b472f7273797aeab30bf103bbd1ba74e21c23c3306b3d2e60"
catalog_digest: "ad4b34f04957f5f4eb15a664684dadd690d820fccc7073a14968c35e10848095"
---
# REA
+95 -15
View File
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
import { lstat, open, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { resolveTreePath } from "../domain/dyldPaths.js";
import {
dylibResolutionInputSchema,
dylibResolutionResultSchema,
@@ -18,10 +19,14 @@ import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
const HASH_CHUNK_BYTES = 1024 * 1024;
/** Device, inode, size and change times of the file whose bytes were parsed. */
type FileIdentity = string;
/** Lazily probed, symlink-preserving view of one analyzed directory. */
class FilesystemTreeView implements DylibTreeView {
readonly #entries = new Map<string, Promise<DylibTreeEntry | undefined>>();
readonly #images = new Map<string, Promise<MachoImageFacts>>();
readonly #identities = new Map<string, FileIdentity>();
constructor(
private readonly root: string,
@@ -39,11 +44,21 @@ class FilesystemTreeView implements DylibTreeView {
image(path: string): Promise<MachoImageFacts> {
const cached = this.#images.get(path);
if (cached !== undefined) return cached;
const pending = readImage(join(this.root, path), this.signal);
const pending = readImage(join(this.root, path), this.signal).then(
({ facts, identity }) => {
this.#identities.set(path, identity);
return facts;
},
);
this.#images.set(path, pending);
return pending;
}
/** Identity of the file whose header bytes `image()` parsed. */
identity(path: string): FileIdentity | undefined {
return this.#identities.get(path);
}
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
cancelled(this.signal);
const absolute = join(this.root, path);
@@ -81,13 +96,13 @@ export const traceDylibResolution = async (options: {
.join("/");
const view = new FilesystemTreeView(root, options.signal);
try {
const { roots, unclassified } =
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
: options.enumerateRoots
? await executableRoots(root, view, options.signal)
: { roots: [target], unclassified: [] };
await requireMachoRoots(view, roots);
const roots = await requireMachoRoots(view, requested);
const trace = await traceDylibLoading(view, {
roots,
unclassified,
@@ -96,12 +111,24 @@ export const traceDylibResolution = async (options: {
: { architecture: parsed.data.architecture }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
// Each digest must describe the same file whose headers were parsed.
const digests = new Map<string, string>();
for (const { path } of trace.images)
digests.set(path, await fileSha256(join(root, path), options.signal));
digests.set(
path,
await fileSha256(join(root, path), {
path,
expected: view.identity(path),
...(options.signal === undefined ? {} : { signal: options.signal }),
}),
);
const targetDigest =
digests.get(target) ??
(await fileSha256(join(root, target), options.signal));
(await fileSha256(join(root, target), {
path: target,
expected: undefined,
...(options.signal === undefined ? {} : { signal: options.signal }),
}));
if (targetDigest !== options.targetSha256)
throw new ArtifactReaderFailure(
"integrity",
@@ -170,23 +197,35 @@ const executableRoots = async (
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
/**
* Resolve each root segment by segment, as dependency candidates are, so a
* symlink in an intermediate directory cannot lead outside the analyzed root.
*/
const requireMachoRoots = async (
view: FilesystemTreeView,
roots: readonly string[],
): Promise<void> => {
): Promise<string[]> => {
const resolved: string[] = [];
for (const path of roots) {
const entry = await view.entry(path);
if (entry?.kind !== "file")
const lookup = await resolveTreePath(view, path);
if (lookup.kind === "escapes")
throw new ArtifactReaderFailure(
"path",
`Root ${path} resolves outside the analyzed root`,
);
if (lookup.kind !== "file")
throw new ArtifactReaderFailure(
"path",
`Root ${path} is not a regular file in the analyzed root`,
);
if ((await view.image(path)).status === "not-mach-o")
if ((await view.image(lookup.path)).status === "not-mach-o")
throw new ArtifactReaderFailure(
"format",
`Root ${path} is not a Mach-O image`,
);
if (!resolved.includes(lookup.path)) resolved.push(lookup.path);
}
return resolved;
};
const startsWithMachoMagic = async (path: string): Promise<boolean> => {
@@ -200,15 +239,34 @@ const startsWithMachoMagic = async (path: string): Promise<boolean> => {
}
};
const identityOf = (metadata: {
readonly dev: bigint;
readonly ino: bigint;
readonly size: bigint;
readonly mtimeNs: bigint;
readonly ctimeNs: bigint;
}): FileIdentity =>
[
metadata.dev,
metadata.ino,
metadata.size,
metadata.mtimeNs,
metadata.ctimeNs,
].join(":");
const readImage = async (
path: string,
signal?: AbortSignal,
): Promise<MachoImageFacts> => {
): Promise<{
readonly facts: MachoImageFacts;
readonly identity: FileIdentity;
}> => {
cancelled(signal);
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const { size } = await handle.stat();
return await readMachoImage(async (offset, length) => {
const metadata = await handle.stat({ bigint: true });
const size = Number(metadata.size);
const facts = await readMachoImage(async (offset, length) => {
cancelled(signal);
const buffer = new Uint8Array(
Math.max(0, Math.min(length, size - offset)),
@@ -216,17 +274,38 @@ const readImage = async (
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
return buffer.subarray(0, bytesRead);
}, size);
return { facts, identity: identityOf(metadata) };
} finally {
await handle.close();
}
};
/** Hash a file, failing if it is not the file (or the version) that was parsed. */
const fileSha256 = async (
path: string,
signal?: AbortSignal,
absolute: string,
options: {
readonly path: string;
readonly expected: FileIdentity | undefined;
readonly signal?: AbortSignal;
},
): Promise<string> => {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const { signal } = options;
const handle = await open(
absolute,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
const unchanged = async (): Promise<void> => {
if (
options.expected !== undefined &&
identityOf(await handle.stat({ bigint: true })) !== options.expected
)
throw new ArtifactReaderFailure(
"integrity",
`${options.path} changed while its dyld load commands were traced`,
);
};
try {
await unchanged();
const hash = createHash("sha256");
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
for (;;) {
@@ -235,6 +314,7 @@ const fileSha256 = async (
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
await unchanged();
return hash.digest("hex");
} finally {
await handle.close();
+7 -1
View File
@@ -211,9 +211,10 @@ describe("Mach-O load command reader failures", () => {
});
it("requires 8-byte command alignment in 64-bit images", async () => {
// A command the reader does not decode; only its size is checked.
const command = new Uint8Array(12);
const view = new DataView(command.buffer);
view.setUint32(0, LC.CODE_SIGNATURE, true);
view.setUint32(0, 0x7f000001, true);
view.setUint32(4, 12, true);
expect(await read(machoImage({ commands: [command] }))).toMatchObject({
status: "malformed",
@@ -257,6 +258,11 @@ describe("Mach-O load command reader failures", () => {
status: "malformed",
reason: "load command 0 is too short to hold a string offset",
});
view.setUint32(0, LC.CODE_SIGNATURE, true);
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
status: "malformed",
reason: "load command 0 is too short for LC_CODE_SIGNATURE",
});
});
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
+6
View File
@@ -279,6 +279,12 @@ const decodeCommand = (
return;
}
if (command === LC_CODE_SIGNATURE) {
// linkedit_data_command: cmd, cmdsize, dataoff, datasize.
if (body.byteLength < 16)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for LC_CODE_SIGNATURE`,
);
slice.code_signature_present = true;
return;
}
+45
View File
@@ -547,3 +547,48 @@ describe("lazily loaded dependencies", () => {
]);
});
});
describe("conditional loads", () => {
it("carries a conditional fallback's uncertainty to its dependents and reuses", async () => {
const VENDOR = "Contents/Frameworks/libvendor.dylib";
const OTHER = "Contents/Frameworks/libother.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["/opt/vendor/lib", "@executable_path/../Frameworks"],
dependencies: [
dependency("@rpath/libvendor.dylib"),
dependency("@executable_path/../Frameworks/libother.dylib"),
],
}),
[VENDOR]: parsed(
slice({
install_name: "@rpath/libvendor.dylib",
dependencies: [dependency("@loader_path/libgone.dylib")],
}),
),
[OTHER]: parsed(
slice({ dependencies: [dependency("@rpath/libvendor.dylib")] }),
),
}),
{ roots: [MAIN] },
);
expect(edgeFor(trace, MAIN, "@rpath/libvendor.dylib")).toMatchObject({
resolution: { status: "conditional", image: VENDOR },
loader_conditional: false,
});
expect(edgeFor(trace, VENDOR, "@loader_path/libgone.dylib")).toMatchObject({
resolution: { status: "unresolved" },
loader_conditional: true,
});
expect(edgeFor(trace, OTHER, "@rpath/libvendor.dylib")).toMatchObject({
candidates: [{ source: "already-loaded" }],
resolution: { status: "conditional", image: VENDOR },
loader_conditional: false,
});
expect(
trace.findings.find(({ kind }) => kind === "required-load-unresolved")
?.explanation,
).toContain(`${VENDOR} loads only conditionally`);
});
});
+30 -4
View File
@@ -116,6 +116,11 @@ const edgeSchema = z.strictObject({
image: z.string().nullable(),
}),
install_name_matches: z.boolean().nullable(),
/**
* The loader was reached through a conditional edge, so it might not load;
* this edge describes what happens only if it does.
*/
loader_conditional: z.boolean(),
});
const findingSchema = z.strictObject({
@@ -179,6 +184,8 @@ interface LoadedImage {
readonly slice: MachoSlice;
/** Images from the process root to this image; the rpath stack, outermost first. */
readonly chain: readonly string[];
/** Some edge on the chain was conditional: an unknown earlier candidate may load instead. */
readonly conditional: boolean;
}
interface ProcessContext {
@@ -307,7 +314,8 @@ const resolveDependency = async (
loader: string,
context: ProcessContext,
): Promise<Edge> => {
const chain = context.loaded.get(loader)?.chain ?? [loader];
const current = context.loaded.get(loader);
const chain = current?.chain ?? [loader];
const loaded = context.byInstallName.get(dependency.install_name);
const candidates: Candidate[] = [];
if (loaded !== undefined)
@@ -326,7 +334,14 @@ const resolveDependency = async (
// dyld stops at the first loadable candidate.
if (candidate.outcome === "resolved") break;
}
const resolved = resolution(candidates);
const searched = resolution(candidates);
// Reusing an image that itself loads only conditionally is conditional too.
const resolved: Edge["resolution"] =
loaded !== undefined &&
context.loaded.get(loaded)?.conditional === true &&
searched.status === "resolved"
? { ...searched, status: "conditional" }
: searched;
const image =
resolved.image === null ? undefined : context.images.get(resolved.image);
const slice =
@@ -345,6 +360,7 @@ const resolveDependency = async (
slice === undefined || slice.install_name === null
? null
: slice.install_name === dependency.install_name,
loader_conditional: current?.conditional ?? false,
};
};
@@ -358,7 +374,11 @@ const traceProcess = async (
signal?: AbortSignal,
): Promise<Edge[]> => {
const edges: Edge[] = [];
context.loaded.set(context.root, { slice: rootSlice, chain: [context.root] });
context.loaded.set(context.root, {
slice: rootSlice,
chain: [context.root],
conditional: false,
});
if (rootSlice.install_name !== null)
context.byInstallName.set(rootSlice.install_name, context.root);
const pending = [context.root];
@@ -381,7 +401,13 @@ const traceProcess = async (
? compatibleSlice(facts.slices, context.architecture)
: undefined;
if (slice === undefined) continue;
context.loaded.set(image, { slice, chain: [...current.chain, image] });
context.loaded.set(image, {
slice,
chain: [...current.chain, image],
// dyld may load an unknown earlier candidate instead of this fallback.
conditional:
current.conditional || edge.resolution.status === "conditional",
});
if (
slice.install_name !== null &&
!context.byInstallName.has(slice.install_name)
+4 -2
View File
@@ -72,7 +72,9 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
: {
...base,
kind: "required-load-unresolved",
explanation: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
explanation: edge.loader_conditional
? `No candidate for ${edge.install_name} exists in the analyzed root. ${edge.loader} loads only conditionally; if it loads, dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`
: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
};
};
@@ -81,7 +83,7 @@ export const DYLIB_RESOLUTION_LIMITATIONS = [
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later request whose install name matches an already loaded image reuses it.",
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
"Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.",
+1 -1
View File
@@ -30,7 +30,7 @@ interface GeneratedPayload {
}
const GENERATED_PAYLOAD_JSON =
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
+10 -2
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
@@ -127,7 +127,15 @@ describe("trace-dylib-resolution CLI", () => {
(narrowed.json as { normalized_result: { roots: unknown } })
.normalized_result.roots,
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
for (const root of ["../outside", "Contents/Info.plist"]) {
// A root below a symlinked directory that points outside the bundle.
const outside = await createTestTempDirectory("rea-dylib-outside-");
await writeFiles(outside, { Tool: machoImage({}) });
await symlink(outside, join(app, "Contents/Linked"));
for (const root of [
"../outside",
"Contents/Info.plist",
"Contents/Linked/Tool",
]) {
const rejected = await cli.run({
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
environment: ENVIRONMENT,