mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 11:25:39 +00:00
fix(capture): report scenario path search denial as access denied (#6244)
Electron and process scenario path admission caught every realpath or stat failure as an invalid_value input issue, so a correct path beneath a directory without search permission returned invalid_request with schema-correction recovery instead of a host access denial. Admission now classifies EACCES/EPERM from path resolution and metadata reads as the existing AnalysisAccessDeniedError (selected path, system_code, filesystem-read boundary). Missing, looping or otherwise unresolvable paths and reachable files without execute permission keep their input-error semantics. Electron reports invalid selections before a denial on another field. Process PATH search still continues past a denied directory; only an explicitly selected executable reports denial. Fixes #6117 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7590b50801
commit
6f96d7a3fa
5 files changed
+506
-51
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { dirname } from "node:path";
|
||||
import { constants } from "node:fs";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ElectronActiveObservationPort,
|
||||
} from "./ElectronActiveObservationPort.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
type AnalysisInputIssue,
|
||||
@@ -21,53 +22,107 @@ import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-electron-active";
|
||||
|
||||
const resolveInputPath = async (
|
||||
field: "executable_path" | "application_path" | "application_root",
|
||||
type ElectronPathField =
|
||||
| "executable_path"
|
||||
| "application_path"
|
||||
| "application_root";
|
||||
|
||||
/**
|
||||
* Why a selected path was not admitted. Host permission denial is not
|
||||
* malformed caller input: the path may be correct, but REA cannot read it.
|
||||
*/
|
||||
type PathAdmissionFailure =
|
||||
| {
|
||||
readonly kind: "invalid";
|
||||
readonly issue: AnalysisInputIssue;
|
||||
readonly cause: unknown;
|
||||
}
|
||||
| { readonly kind: "denied"; readonly error: AnalysisAccessDeniedError };
|
||||
|
||||
const hostAccessDenial = (
|
||||
selected: string,
|
||||
): Promise<
|
||||
Result<
|
||||
string,
|
||||
{ readonly issue: AnalysisInputIssue; readonly cause: unknown }
|
||||
>
|
||||
> => {
|
||||
cause: unknown,
|
||||
): PathAdmissionFailure | undefined => {
|
||||
const code: unknown =
|
||||
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
|
||||
return code === "EACCES" || code === "EPERM"
|
||||
? {
|
||||
kind: "denied",
|
||||
error: new AnalysisAccessDeniedError(OPERATION, selected, code, {
|
||||
cause,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const resolveInputPath = async (
|
||||
field: ElectronPathField,
|
||||
selected: string,
|
||||
): Promise<Result<string, PathAdmissionFailure>> => {
|
||||
try {
|
||||
return ok(await realpath(selected));
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: `Cannot resolve ${field} ${selected}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
return err(
|
||||
hostAccessDenial(selected, cause) ?? {
|
||||
kind: "invalid",
|
||||
issue: {
|
||||
path: [field],
|
||||
reason: "invalid_value",
|
||||
message: `Cannot resolve ${field} ${selected}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve the executable and require a launchable regular file before any spawn. */
|
||||
/**
|
||||
* Resolve the executable and require a launchable regular file before any
|
||||
* spawn. Denied resolution or metadata reads are host access failures; a
|
||||
* readable file without execute permission is an invalid selection.
|
||||
*/
|
||||
const resolveExecutablePath = async (
|
||||
selected: string,
|
||||
): ReturnType<typeof resolveInputPath> => {
|
||||
const resolved = await resolveInputPath("executable_path", selected);
|
||||
if (!resolved.ok) return resolved;
|
||||
let metadata: Stats;
|
||||
try {
|
||||
if (!(await stat(resolved.value)).isFile())
|
||||
throw new Error("not a regular file");
|
||||
if (process.platform !== "win32")
|
||||
await access(resolved.value, constants.X_OK);
|
||||
return resolved;
|
||||
metadata = await stat(resolved.value);
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
issue: {
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
return err(
|
||||
hostAccessDenial(selected, cause) ?? notExecutable(selected, cause),
|
||||
);
|
||||
}
|
||||
if (!metadata.isFile())
|
||||
return err(notExecutable(selected, new Error("not a regular file")));
|
||||
if (process.platform !== "win32")
|
||||
try {
|
||||
await access(resolved.value, constants.X_OK);
|
||||
} catch (cause: unknown) {
|
||||
return err(notExecutable(selected, cause));
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const notExecutable = (
|
||||
selected: string,
|
||||
cause: unknown,
|
||||
): PathAdmissionFailure => ({
|
||||
kind: "invalid",
|
||||
issue: {
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_value",
|
||||
message: `executable_path ${selected} is not an executable file: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
cause,
|
||||
});
|
||||
|
||||
/**
|
||||
* Admit every selected path before any provider work. Invalid selections are
|
||||
* reported together as input issues; a host access denial is reported only
|
||||
* when no selection is invalid, because correcting the request comes first.
|
||||
*/
|
||||
const canonicalizeInput = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
|
||||
@@ -82,16 +137,23 @@ const canonicalizeInput = async (
|
||||
const failures = [executable, application, root].flatMap((result) =>
|
||||
result.ok ? [] : [result.error],
|
||||
);
|
||||
const issues = failures.flatMap((failure) =>
|
||||
failure.kind === "invalid" ? [failure.issue] : [],
|
||||
);
|
||||
const denial = failures.find((failure) => failure.kind === "denied");
|
||||
if (issues.length === 0 && denial !== undefined) return err(denial.error);
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
{
|
||||
cause: new AggregateError(
|
||||
failures.map(({ cause }) => cause),
|
||||
failures.map((failure) =>
|
||||
failure.kind === "invalid" ? failure.cause : failure.error,
|
||||
),
|
||||
"Electron paths could not be resolved",
|
||||
),
|
||||
},
|
||||
failures.map(({ issue }) => issue),
|
||||
issues,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user