Files
rea/src/application/javascript/ElectronActiveObservationService.test.ts
T
Kaoru0822 6f96d7a3fa 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>
2026-10-21 22:22:15 -07:00

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,
});
});