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:
parent
e6abaefbbc
commit
d0ff57a45c
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue