fix(artifacts): defer lazy loads and tighten Mach-O validation

Address review findings in trace_dylib_resolution:
- Resolve LC_LAZY_LOAD_DYLIB dependencies without traversing them, since
  dyld loads them on first use. An unresolved one becomes
  `lazy-load-unresolved`, not a launch failure.
- Accept FAT tables up to 128 records, as target opening does. A table
  that names no Mach-O CPU type, such as a Java class file, is not
  Mach-O.
- Require 8-byte load-command alignment in 64-bit images. 1,508 real
  slices still match otool.
- Report EACCES/EPERM while probing, reading, or hashing as an
  `unavailable` failure naming the code and path, rather than generic
  io.

Findings and limitations move to dylibResolutionFindings.ts to keep the
resolver under the module size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 11:50:00 +00:00
1 parent a0ec3e2016
commit 91280393cd
12 files changed
+256 -78

No files matched your search

+3 -1
View File
@@ -167,7 +167,9 @@ cache rather than on disk, so REA does not check them against the host.
`findings` are derived, not observed:
- `required-load-unresolved` and `weak-load-unresolved`.
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
loads them on first use; an unresolved one is not a launch failure.
- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
would load first. Whether code-signing library validation would reject it is
not evaluated; use `inspect_signature`.
+2 -2
View File
@@ -672,9 +672,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "7ad6e57f4a82e441c28969242a6e2f098871b48388049cb6b1ebac161ba3d699",
"tools_sha256": "a99b4fdcb450237aa910b549b0cd8aaf13a85f25b5c631379ac4cb4de75767c7",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200",
"combined_sha256": "39493ff570450eac6dd889bf94685b1180d48311bee1a67e0801e6ed531abd64",
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_93f08d561a3f3decdeeb2350630274ea7404a6b072908b213c853b97c8adca1d",
"manifest_id": "ecm_7ac92161eaf2ccc3146a8e49390a73e5d642f1e6918213be67118383410522e5",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "d65a570cd63b6fb73dc44fc5a08122fdfa7d9ce43f3dfd4b4d2b18950ea2387b"
"sha256": "6ea0efd0566072b6f504913a367bbc38035f57f42f5353410ad9081aaf5fe6c9"
}
],
"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: "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200"
catalog_digest: "39493ff570450eac6dd889bf94685b1180d48311bee1a67e0801e6ed531abd64"
---
# REA
+22
View File
@@ -123,6 +123,13 @@ export const traceDylibResolution = async (options: {
"Dylib resolution was cancelled",
{ cause },
);
const denied = permissionDenied(cause);
if (denied !== undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Permission denied (${denied.code}) reading ${denied.path === undefined ? "a file in the analyzed root" : relative(root, denied.path) || denied.path}`,
{ cause },
);
throw cause;
}
};
@@ -242,6 +249,21 @@ const cancelled = (signal?: AbortSignal): void => {
);
};
/** Host permission denials, kept distinct from malformed or missing files. */
const permissionDenied = (
cause: unknown,
): { readonly code: string; readonly path: string | undefined } | undefined => {
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined;
return {
code: cause.code,
path:
"path" in cause && typeof cause.path === "string"
? cause.path
: undefined,
};
};
const missing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
@@ -210,6 +210,35 @@ describe("Mach-O load command reader failures", () => {
});
});
it("requires 8-byte command alignment in 64-bit images", async () => {
const command = new Uint8Array(12);
const view = new DataView(command.buffer);
view.setUint32(0, LC.CODE_SIGNATURE, true);
view.setUint32(4, 12, true);
expect(await read(machoImage({ commands: [command] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("invalid cmdsize 12"),
});
expect(
await read(
machoImage({
cpu: { type: 7, subtype: 3 },
wide: false,
commands: [command],
}),
),
).toMatchObject({ status: "parsed" });
});
it("accepts FAT tables up to 128 records that name a Mach-O CPU", async () => {
const slices = Array.from({ length: 24 }, () => ({
cpu: CPU.arm64,
bytes: machoImage({}),
}));
const facts = await read(fatImage(slices));
expect(facts.status === "parsed" && facts.slices).toHaveLength(24);
});
it("rejects string commands too short to hold their string offset", async () => {
const short = new Uint8Array(8);
const view = new DataView(short.buffer);
+27 -5
View File
@@ -9,8 +9,12 @@ export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
/** Load commands larger than this are refused instead of buffered. */
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
/** Real universal binaries carry a handful of slices; Java class files share the FAT magic. */
const MAX_FAT_ARCHITECTURES = 20;
/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */
const MAX_FAT_ARCHITECTURES = 128;
/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */
const KNOWN_CPU_TYPES: readonly number[] = [
7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012,
];
const MH_MAGIC = 0xfeedface;
const MH_MAGIC_64 = 0xfeedfacf;
@@ -91,12 +95,26 @@ export const readMachoImage = async (
// FAT headers are big-endian; the swapped magics store them little-endian.
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
const count = view.getUint32(4, littleEndian);
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
if (
count === 0 ||
count > MAX_FAT_ARCHITECTURES ||
8 + count * (wide ? 32 : 20) > size
)
return { status: "not-mach-o" };
const table = viewOf(
await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"),
);
// A Java class file's version fields read as a FAT count; its "table" names no CPU.
const cpuTypes = Array.from({ length: count }, (_, index) =>
table.getUint32(index * (wide ? 32 : 20), littleEndian),
);
if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type)))
return { status: "not-mach-o" };
return {
status: "parsed",
slices: await readFatSlices(readAt, size, {
wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64,
wide,
littleEndian,
count,
}),
@@ -179,6 +197,7 @@ const readSlice = async (
"load commands",
);
return decodeCommands(commands, {
wide,
cpuType: header.getUint32(4, true),
cpuSubtype: header.getUint32(8, true),
fileType: header.getUint32(12, true),
@@ -198,12 +217,15 @@ const readSlice = async (
const decodeCommands = (
bytes: Uint8Array,
header: {
readonly wide: boolean;
readonly cpuType: number;
readonly cpuSubtype: number;
readonly fileType: number;
readonly commandCount: number;
},
): MachoSlice => {
// loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit.
const alignment = header.wide ? 8 : 4;
const view = viewOf(bytes);
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
@@ -223,7 +245,7 @@ const decodeCommands = (
);
const command = view.getUint32(offset, true);
const size = view.getUint32(offset + 4, true);
if (size < 8 || size % 4 !== 0 || offset + size > bytes.byteLength)
if (size < 8 || size % alignment !== 0 || offset + size > bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
+29
View File
@@ -518,3 +518,32 @@ describe("dyld slice compatibility and coverage", () => {
);
});
});
describe("lazily loaded dependencies", () => {
it("resolves lazy loads without traversing them or reporting launch failure", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["@executable_path/../Frameworks"],
dependencies: [
dependency("@rpath/liblazy.dylib", {
command: "LC_LAZY_LOAD_DYLIB",
}),
dependency("@rpath/libmissing.dylib", {
command: "LC_LAZY_LOAD_DYLIB",
}),
],
}),
"Contents/Frameworks/liblazy.dylib": parsed(
slice({ dependencies: [dependency("@rpath/libdeep.dylib")] }),
),
}),
{ roots: [MAIN] },
);
expect(trace.edges.map(({ loader }) => loader)).toEqual([MAIN, MAIN]);
expect(trace.edges[0]?.resolution.status).toBe("resolved");
expect(trace.findings.map(({ kind }) => kind)).toEqual([
"lazy-load-unresolved",
]);
});
});
+10 -65
View File
@@ -9,6 +9,10 @@ import {
type Expansion,
} from "./dyldPaths.js";
import { digestSchema } from "./digests.js";
import {
DYLIB_RESOLUTION_LIMITATIONS,
deriveFindings,
} from "./dylibResolutionFindings.js";
/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
const ROOT_RELATIVE_PATH =
@@ -118,6 +122,7 @@ const findingSchema = z.strictObject({
kind: z.enum([
"required-load-unresolved",
"weak-load-unresolved",
"lazy-load-unresolved",
"earlier-rpath-candidate-absent",
"dyld-environment-present",
]),
@@ -155,8 +160,9 @@ export const dylibResolutionResultSchema = z.strictObject({
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
type Candidate = z.infer<typeof candidateSchema>;
type Edge = z.infer<typeof edgeSchema>;
type Finding = z.infer<typeof findingSchema>;
export type DylibEdge = z.infer<typeof edgeSchema>;
export type DylibFinding = z.infer<typeof findingSchema>;
type Edge = DylibEdge;
/** Result before the adapter adds file digests and the analyzed root. */
export type DylibTrace = Omit<
@@ -365,6 +371,8 @@ const traceProcess = async (
for (const dependency of current.slice.dependencies) {
const edge = await resolveDependency(dependency, loader, context);
edges.push(edge);
// dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
const image = edge.resolution.image;
if (image === null || context.loaded.has(image)) continue;
const facts = context.images.get(image);
@@ -476,68 +484,5 @@ const withoutDependencies = (
code_signature_present: slice.code_signature_present,
});
const deriveFindings = (
edges: readonly Edge[],
roots: DylibTrace["roots"],
images: ReadonlyMap<string, MachoImageFacts>,
): Finding[] => {
const findings: Finding[] = [];
edges.forEach((edge, index) => {
if (edge.resolution.status === "unresolved")
findings.push({
kind: edge.weak ? "weak-load-unresolved" : "required-load-unresolved",
edge_index: index,
image: edge.loader,
basis: "derived",
explanation: edge.weak
? `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`
: `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.`,
});
const resolvedAt = edge.candidates.findIndex(
({ outcome }) => outcome === "resolved",
);
const earlier = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.filter(
({ source, outcome }) => source === "rpath" && outcome === "absent",
)
.map(({ path }) => path);
if (resolvedAt > 0 && earlier.length > 0)
findings.push({
kind: "earlier-rpath-candidate-absent",
edge_index: index,
image: edge.loader,
basis: "derived",
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
});
});
for (const { image, architecture } of roots) {
const facts = images.get(image);
const environment =
facts?.status === "parsed"
? (facts.slices.find((slice) => slice.architecture === architecture)
?.dyld_environment ?? [])
: [];
if (environment.length > 0)
findings.push({
kind: "dyld-environment-present",
edge_index: null,
image,
basis: "derived",
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
});
}
return findings;
};
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.",
"@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.",
];
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
+87
View File
@@ -0,0 +1,87 @@
import type {
DylibEdge as Edge,
DylibFinding as Finding,
DylibTrace,
MachoImageFacts,
} from "./dylibResolution.js";
export const deriveFindings = (
edges: readonly Edge[],
roots: DylibTrace["roots"],
images: ReadonlyMap<string, MachoImageFacts>,
): Finding[] => {
const findings: Finding[] = [];
edges.forEach((edge, index) => {
if (edge.resolution.status === "unresolved")
findings.push(unresolvedFinding(edge, index));
const resolvedAt = edge.candidates.findIndex(
({ outcome }) => outcome === "resolved",
);
const earlier = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.filter(
({ source, outcome }) => source === "rpath" && outcome === "absent",
)
.map(({ path }) => path);
if (resolvedAt > 0 && earlier.length > 0)
findings.push({
kind: "earlier-rpath-candidate-absent",
edge_index: index,
image: edge.loader,
basis: "derived",
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
});
});
for (const { image, architecture } of roots) {
const facts = images.get(image);
const environment =
facts?.status === "parsed"
? (facts.slices.find((slice) => slice.architecture === architecture)
?.dyld_environment ?? [])
: [];
if (environment.length > 0)
findings.push({
kind: "dyld-environment-present",
edge_index: null,
image,
basis: "derived",
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
});
}
return findings;
};
const unresolvedFinding = (edge: Edge, index: number): Finding => {
const base = {
edge_index: index,
image: edge.loader,
basis: "derived",
} as const;
if (edge.command === "LC_LAZY_LOAD_DYLIB")
return {
...base,
kind: "lazy-load-unresolved",
explanation: `No candidate for lazily loaded ${edge.install_name} exists in the analyzed root; dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
};
return edge.weak
? {
...base,
kind: "weak-load-unresolved",
explanation: `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`,
}
: {
...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.`,
};
};
export const DYLIB_RESOLUTION_LIMITATIONS = [
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
"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.",
"@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.",
];
+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");
+43 -1
View File
@@ -1,4 +1,4 @@
import { mkdir, writeFile } from "node:fs/promises";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
@@ -192,3 +192,45 @@ describe("trace-dylib-resolution CLI", () => {
},
);
});
describe.skipIf(process.getuid?.() === 0)(
"trace-dylib-resolution permissions",
() => {
cliTest(
"reports an unreadable dependency as a permission failure",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-denied-");
await writeFiles(directory, {
tool: machoImage({
commands: [
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
],
}),
"libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
});
await chmod(join(directory, "libsecret.dylib"), 0o000);
try {
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
join(directory, "tool"),
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
details: {
reason: "unavailable",
detail: expect.stringContaining(
"Permission denied (EACCES) reading libsecret.dylib",
),
},
});
} finally {
await chmod(join(directory, "libsecret.dylib"), 0o644);
}
},
);
},
);