Merge pull request #1197 from morluto/fix/target-admission-access-denied

fix(domain): report target read-permission denials as access_denied
This commit is contained in:
Kaoru0822 2026-10-08 21:13:32 -07:00 committed by GitHub
commit f36c10f9e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 131 additions and 5 deletions

View File

@ -74,7 +74,10 @@ export const analysisErrorRemediationAction = (
error.constraint === "directory_requires_file"
)
return "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run `rea analyze <directory>`. For binary analysis, select its executable file.";
if (error instanceof AnalysisAccessDeniedError)
if (
error instanceof AnalysisAccessDeniedError ||
(error instanceof BinaryTargetError && error.systemCode !== undefined)
)
return "Check the current process's read access to the selected path. Retry with a readable local file.";
if (error instanceof AnalysisArtifactChangedError)
return "Wait until the selected file is stable. For an active binary session, reopen the target with open_binary before retrying so REA acquires its current identity; for a CLI command or target-free tool, rerun the operation.";

View File

@ -439,6 +439,42 @@ describe("analysis error projection: caller contract", () => {
expect(JSON.stringify(projected)).not.toContain("secret-token");
});
it.each(["EACCES", "EPERM"] as const)(
"reports a target the host refused to read (%s) as access_denied",
(systemCode) => {
const denied = Object.assign(new Error("denied"), { code: systemCode });
const reason = `permission denied while reading target: ${systemCode}`;
expect(
projectAnalysisError(
new BinaryTargetError("/local/targets/app", reason, {
cause: denied,
}),
),
).toMatchObject({
code: "access_denied",
category: "unavailable",
retryable: false,
remediation: {
action:
"Check the current process's read access to the selected path. Retry with a readable local file.",
},
details: {
path: "/local/targets/app",
reason,
system_code: systemCode,
boundary: "filesystem-read",
},
});
expect(
projectAnalysisError(
new BinaryTargetError("/local/targets/app", "missing", {
cause: Object.assign(new Error("missing"), { code: "ENOENT" }),
}),
),
).toMatchObject({ code: "target_unavailable" });
},
);
it("uses explicit capability recovery while retaining the constraint", () => {
const projected = projectAnalysisError(
new AnalysisCapabilityUnavailableError(

View File

@ -101,6 +101,10 @@ const underlyingErrorCode = (
: browserErrorCode(error.reason);
if (error instanceof ArtifactOperationError)
return artifactOperationCode(error);
if (error instanceof BinaryTargetError)
return error.systemCode === undefined
? "target_unavailable"
: "access_denied";
if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason);
if (error instanceof UnknownRegistryError)
return unknownRegistryCode(error.reason);
@ -162,6 +166,7 @@ const processCaptureCode = (
type SpecializedErrorTag =
| "ArtifactOperationError"
| "BinaryTargetError"
| "BrowserObservationError"
| "EvidenceFileError"
| "ProcessCaptureError"
@ -186,7 +191,6 @@ const STATIC_ERROR_CODES = {
HopperStartError: "provider_unavailable",
ConfigurationError: "configuration_invalid",
NoBinaryOpenError: "target_unavailable",
BinaryTargetError: "target_unavailable",
EvidenceIntegrityError: "evidence_integrity_mismatch",
ProviderAdapterError: "execution_failure",
HopperRemoteError: "execution_failure",
@ -202,6 +206,7 @@ const staticErrorCode = (
): AnalysisErrorProjection["code"] => {
switch (tag) {
case "ArtifactOperationError":
case "BinaryTargetError":
case "BrowserObservationError":
case "EvidenceFileError":
case "ProcessCaptureError":
@ -489,6 +494,9 @@ const lifecycleErrorDetails = (
...(error.constraint === undefined
? {}
: { constraint: error.constraint }),
...(error.systemCode === undefined
? {}
: { system_code: error.systemCode, boundary: "filesystem-read" }),
};
return undefined;
};

View File

@ -27,6 +27,8 @@ export interface BinaryTargetErrorOptions extends AnalysisErrorOptions {
export class BinaryTargetError extends AnalysisError {
readonly _tag = "BinaryTargetError";
readonly constraint: BinaryTargetErrorOptions["constraint"];
/** The OS read-permission code of the cause, when the host denied access. */
readonly systemCode: "EACCES" | "EPERM" | undefined;
constructor(
readonly path: string,
readonly reason: string,
@ -34,5 +36,12 @@ export class BinaryTargetError extends AnalysisError {
) {
super(`Cannot open artifact: ${reason}`, options);
this.constraint = options?.constraint;
this.systemCode = readPermissionCode(options?.cause);
}
}
const readPermissionCode = (cause: unknown): "EACCES" | "EPERM" | undefined => {
const code: unknown =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
return code === "EACCES" || code === "EPERM" ? code : undefined;
};

View File

@ -0,0 +1,37 @@
import { chmod, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { resolveHopperMachOImage } from "./HopperMachOImage.js";
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a FAT64 source that became unreadable as a host read denial",
async () => {
const directory = await createTestTempDirectory("rea-hopper-fat64-");
const path = join(directory, "universal");
await writeFile(path, Buffer.alloc(64));
await chmod(path, 0);
try {
const result = await resolveHopperMachOImage({
path,
sha256: "0".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({ systemCode: "EACCES" });
expect(projectAnalysisError(result.error)).toMatchObject({
code: "access_denied",
details: { path, system_code: "EACCES" },
});
} finally {
await chmod(path, 0o600);
}
},
);

View File

@ -35,15 +35,23 @@ export const resolveHopperMachOImage = async (
): Promise<
Result<HopperMachOImage, BinaryTargetError | AnalysisCancelledError>
> => {
const invalid = (reason: string) =>
err(new BinaryTargetError(target.path, reason));
const invalid = (reason: string, cause?: unknown) =>
err(
new BinaryTargetError(
target.path,
reason,
cause === undefined ? undefined : { cause },
),
);
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
let file: FileHandle;
try {
file = await open(target.path, "r");
} catch (cause: unknown) {
// Keep the OS error so a read-permission denial reports access_denied.
return invalid(
`Cannot read FAT64 source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`,
cause,
);
}
try {

View File

@ -90,10 +90,11 @@ describe.skipIf(process.platform !== "darwin")(
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "target_unavailable",
code: "access_denied",
details: {
path: locked,
reason: "permission denied while reading plist (EACCES)",
system_code: "EACCES",
},
});
},

View File

@ -22,6 +22,7 @@ import {
pe,
thinMach,
} from "../../../src/domain/binaryTarget.fixture.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("binary target I/O: app plist permissions and decoding", () => {
@ -48,6 +49,7 @@ describe("binary target I/O: app plist permissions and decoding", () => {
path: join(app, "Contents", "Info.plist"),
reason: expect.stringContaining(message),
cause: denied,
systemCode: code,
},
});
});
@ -232,6 +234,28 @@ describe("binary target I/O: filesystem and app bundle target resolution", () =>
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an unreadable target as a host read denial",
async () => {
const directory = await createTestTempDirectory("rea-target-denied-");
const path = join(directory, "unreadable");
await writeFile(path, thinMach(0xcffaedfe, 0x0100000c));
await chmod(path, 0);
try {
const result = await parseBinaryTarget(path, directory, "arm64");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({ path, systemCode: "EACCES" });
expect(projectAnalysisError(result.error)).toMatchObject({
code: "access_denied",
details: { path, system_code: "EACCES" },
});
} finally {
await chmod(path, 0o600);
}
},
);
it("rejects non-regular targets before reading them", async () => {
const directory = await createTestTempDirectory("rea-target-");
expect((await parseBinaryTarget(directory)).ok).toBe(false);