mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
6f96d7a3fa
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>
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
|
import { electronActiveObservationInputSchema } from "../../domain/javascript/electronActiveObservation.js";
|
|
import { err } from "../../domain/result.js";
|
|
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
|
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
|
|
import { captureElectronScenario } from "./ElectronActiveObservationService.js";
|
|
|
|
const SKIP_PERMISSIONS =
|
|
process.platform === "win32" || process.getuid?.() === 0;
|
|
|
|
/** A provider that records admitted input and never launches anything. */
|
|
const recordingProvider = () => {
|
|
const capture = vi.fn<ElectronActiveObservationPort["capture"]>(() =>
|
|
Promise.resolve(
|
|
err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"test",
|
|
"capture_electron_scenario",
|
|
"recording provider",
|
|
),
|
|
),
|
|
),
|
|
);
|
|
const provider: ElectronActiveObservationPort = {
|
|
identity: () => ({ id: "test", name: "test", version: "1" }),
|
|
capture,
|
|
};
|
|
return { provider, capture };
|
|
};
|
|
|
|
const fixture = async () => {
|
|
const root = await createTestTempDirectory("rea-electron-admission-");
|
|
const denied = join(root, "denied");
|
|
const readable = join(root, "readable");
|
|
await mkdir(join(denied, "root"), { recursive: true });
|
|
await mkdir(readable);
|
|
const executable = join(denied, "electron");
|
|
await writeFile(executable, "#!/bin/sh\n", { mode: 0o755 });
|
|
await writeFile(join(denied, "main.js"), "module.exports = {};\n");
|
|
await writeFile(join(readable, "main.js"), "module.exports = {};\n");
|
|
return { root, denied, readable, executable };
|
|
};
|
|
|
|
/** Remove search permission from `directory` for the duration of `run`. */
|
|
const withoutSearchPermission = async <T>(
|
|
directory: string,
|
|
run: () => Promise<T>,
|
|
): Promise<T> => {
|
|
await chmod(directory, 0o000);
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
await chmod(directory, 0o700);
|
|
}
|
|
};
|
|
|
|
it
|
|
.skipIf(SKIP_PERMISSIONS)
|
|
.each(["application_path", "application_root", "executable_path"] as const)(
|
|
"reports a search-denied %s as host access denial before any provider work",
|
|
async (field) => {
|
|
const { denied, readable, executable } = await fixture();
|
|
const selected = {
|
|
application_path: join(denied, "main.js"),
|
|
application_root: join(denied, "root"),
|
|
executable_path: executable,
|
|
}[field];
|
|
const { provider, capture } = recordingProvider();
|
|
const result = await withoutSearchPermission(denied, () =>
|
|
captureElectronScenario(
|
|
provider,
|
|
electronActiveObservationInputSchema.parse({
|
|
executable_path: process.execPath,
|
|
application_path: join(readable, "main.js"),
|
|
actions: [],
|
|
[field]: selected,
|
|
}),
|
|
),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "access_denied",
|
|
category: "unavailable",
|
|
details: {
|
|
operation: "capture_electron_scenario",
|
|
path: selected,
|
|
system_code: "EACCES",
|
|
boundary: "filesystem-read",
|
|
},
|
|
});
|
|
expect(capture).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.skipIf(SKIP_PERMISSIONS)(
|
|
"reports invalid selections before a host denial on another path",
|
|
async () => {
|
|
const { root, denied } = await fixture();
|
|
const missing = join(root, "missing.js");
|
|
const { provider, capture } = recordingProvider();
|
|
const result = await withoutSearchPermission(denied, () =>
|
|
captureElectronScenario(
|
|
provider,
|
|
electronActiveObservationInputSchema.parse({
|
|
executable_path: process.execPath,
|
|
application_path: missing,
|
|
application_root: join(denied, "root"),
|
|
actions: [],
|
|
}),
|
|
),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: ["application_path"],
|
|
reason: "invalid_value",
|
|
message: expect.stringContaining("ENOENT"),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(capture).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.skipIf(SKIP_PERMISSIONS).each([
|
|
{ label: "0644", mode: 0o644 },
|
|
{ label: "0000", mode: 0o000 },
|
|
])(
|
|
"keeps a reachable executable without execute permission (mode $label) as invalid input",
|
|
async ({ mode }) => {
|
|
const { root, readable } = await fixture();
|
|
const executable = join(root, "not-executable");
|
|
await writeFile(executable, "not executable");
|
|
await chmod(executable, mode);
|
|
const { provider, capture } = recordingProvider();
|
|
const result = await captureElectronScenario(
|
|
provider,
|
|
electronActiveObservationInputSchema.parse({
|
|
executable_path: executable,
|
|
application_path: join(readable, "main.js"),
|
|
actions: [],
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: ["executable_path"],
|
|
reason: "invalid_value",
|
|
message: expect.stringContaining("is not an executable file"),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(capture).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("admits readable selections and hands canonical paths to the provider", async () => {
|
|
const { readable } = await fixture();
|
|
const { provider, capture } = recordingProvider();
|
|
const application = join(readable, "main.js");
|
|
await captureElectronScenario(
|
|
provider,
|
|
electronActiveObservationInputSchema.parse({
|
|
executable_path: process.execPath,
|
|
application_path: application,
|
|
actions: [],
|
|
}),
|
|
);
|
|
expect(capture).toHaveBeenCalledOnce();
|
|
expect(capture.mock.calls[0]?.[0]).toMatchObject({
|
|
application_path: application,
|
|
application_root: readable,
|
|
});
|
|
});
|