fix(domain): report target read-permission denials as access_denied

Opening a target the current user cannot read returned
`target_unavailable`, the same code as a missing target, although the
reason named EACCES. Clients that branch on the error code could not choose
read-access recovery, contrary to the boundary contract that separates host
permission denial from unavailable targets.

A BinaryTargetError whose cause is an OS EACCES or EPERM now records that
system code and projects as `access_denied` with `system_code`, the
filesystem-read boundary, and read-access remediation, while keeping its
specific reason and path. This covers target files, app bundle paths and
Info.plist reads, and selected plist reads, since every site passes the OS
error as the cause.

Fixes #1188

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-09 03:56:38 +00:00
parent e6abaefbbc
commit d0ff57a45c
6 changed files with 84 additions and 3 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

@ -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);