fix(javascript): keep selected-input filesystem failures typed

A selected directory that is readable but not searchable passed the R_OK
selection check, then every entry lstat failed and the result became a
generic artifact I/O failure. The selection owner now checks search
permission for directories and reads the selection metadata inside its
classifier, so EACCES/EPERM from realpath, lstat or access is a host
access denial with the selected path and system code.

Host filesystem failures that no narrower owner classifies no longer
drop their facts: EMFILE/ENFILE become a retryable capacity constraint,
and other errno failures keep artifact I/O with the host message and
system_code, syscall and path diagnostics.

Fixes #6116
Fixes #5991

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-22 04:09:35 +00:00
1 parent 4a4bbab3c3
commit abd99a4ce7
3 files changed
+203 -20

No files matched your search

@@ -7,7 +7,10 @@ import {
type JavaScriptApplicationAnalysisResult,
parseOwnedJavaScriptApplicationAnalysisSteps,
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import {
AnalysisOutputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
@@ -149,13 +152,8 @@ export const analyzeJavaScriptApplicationValidated = async (
cause,
}),
);
if (
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string" &&
FILESYSTEM_ERROR_CODES.has(cause.code)
)
return err(new ArtifactOperationError(OPERATION, "io"));
const filesystemFailure = hostFilesystemFailure(cause);
if (filesystemFailure !== undefined) return err(filesystemFailure);
return err(
new ProviderAdapterError(JAVASCRIPT_APPLICATION_PROVIDER.id, OPERATION, {
cause,
@@ -208,6 +206,43 @@ const describeResultSchemaFailure = (cause: z.ZodError): string => {
const escapePointerPart = (part: string): string =>
part.replaceAll("~", "~0").replaceAll("/", "~1");
/**
* Project a host filesystem failure that no narrower owner classified. Selected
* input denial is classified at selection; this failure may concern any path,
* including private workspace files, so it keeps the host's own facts rather
* than claiming a selected-read denial.
*/
const hostFilesystemFailure = (cause: unknown): AnalysisError | undefined => {
if (!(cause instanceof Error)) return undefined;
const code: unknown = Reflect.get(cause, "code");
if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code))
return undefined;
const syscall: unknown = Reflect.get(cause, "syscall");
const path: unknown = Reflect.get(cause, "path");
const facts = {
system_code: code,
...(typeof syscall === "string" ? { syscall } : {}),
...(typeof path === "string" ? { path } : {}),
};
if (code === "EMFILE" || code === "ENFILE")
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
`Host file-descriptor limit reached (${code})${typeof path === "string" ? ` while accessing ${path}` : ""}`,
facts,
{
cause,
retryable: true,
remediationAction:
"Close unused files or raise the open-file limit for the process or host running REA, then retry.",
},
);
return new ArtifactOperationError(OPERATION, "io", undefined, cause.message, {
cause,
diagnostics: facts,
});
};
const FILESYSTEM_ERROR_CODES = new Set([
"ENOENT",
"EACCES",
@@ -1,4 +1,4 @@
import { constants } from "node:fs";
import { constants, type Stats } from "node:fs";
import { access, lstat, realpath } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
@@ -91,8 +91,8 @@ export const reconstructJavaScriptArtifact = async (
};
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
const path = await resolveSelectedInput(input.input_path);
const format = await resolveFormat(path, input);
const { path, metadata } = await resolveSelectedInput(input.input_path);
const format = selectedFormat(path, metadata, input);
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
resourceScope: scope,
signal,
@@ -346,13 +346,21 @@ const OPERATION = "analyze_javascript_application";
/**
* Resolve the caller-selected input. A missing, looping, overlong, or
* unreadable selection is a caller or host-resolution failure, not a damaged
* artifact.
* artifact. A selected directory must also be searchable: without search
* permission its entries can be listed but not inspected, so no entry is
* readable. Other host failures propagate with their errno facts intact.
*/
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
const resolveSelectedInput = async (
inputPath: string,
): Promise<{ readonly path: string; readonly metadata: Stats }> => {
try {
const path = await realpath(inputPath);
await access(path, constants.R_OK);
return path;
const metadata = await lstat(path);
await access(
path,
metadata.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK,
);
return { path, metadata };
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
@@ -389,11 +397,11 @@ const selectedInputResolutionMessage = (
: `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`;
/** The selected input's kind, checked against the caller's requested format. */
const resolveFormat = async (
const selectedFormat = (
path: string,
metadata: Stats,
input: JavaScriptArtifactReconstructionInput,
): Promise<"asar" | "directory"> => {
const metadata = await lstat(path);
): "asar" | "directory" => {
const observed = metadata.isDirectory()
? "directory"
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
@@ -1,7 +1,9 @@
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { promises as filesystem } from "node:fs";
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
import { syncBuiltinESMExports } from "node:module";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -168,3 +170,141 @@ describe("JavaScript application input selection", () => {
},
);
});
describe("JavaScript application selected directory search permission", () => {
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a readable but unsearchable selected directory as a host access denial",
async () => {
const { directory } = await application();
await chmod(directory, 0o400);
try {
expect(await analyzeFailure({ input_path: directory })).toMatchObject({
code: "access_denied",
category: "unavailable",
details: {
path: directory,
system_code: "EACCES",
boundary: "filesystem-read",
},
});
} finally {
await chmod(directory, 0o700);
}
expect(
(await analyzeJavaScriptApplication({ input_path: directory })).ok,
).toBe(true);
},
);
});
describe("JavaScript application host filesystem failures", () => {
const inject = async (
method: "access" | "lstat" | "realpath",
selected: string,
failure: Error,
) => {
const original = filesystem[method] as (
...args: unknown[]
) => Promise<unknown>;
let injected = 0;
const spy = vi.spyOn(filesystem, method).mockImplementation((async (
...args: unknown[]
) => {
if (String(args[0]) === selected) {
injected += 1;
throw failure;
}
return original(...args);
}) as never);
syncBuiltinESMExports();
try {
const error = await analyzeFailure({ input_path: selected });
expect(injected).toBe(1);
return error;
} finally {
spy.mockRestore();
syncBuiltinESMExports();
}
};
const hostFailure = (code: string, syscall: string, path: string) =>
Object.assign(new Error(`${code}: host failure, ${syscall} '${path}'`), {
code,
syscall,
path,
});
it("keeps the errno, syscall and path of a host I/O failure", async () => {
const { directory } = await application();
const selected = await realpath(directory);
const failure = hostFailure("EIO", "realpath", selected);
expect(await inject("realpath", selected, failure)).toMatchObject({
code: "artifact_operation_failed",
category: "execution_failure",
details: {
operation: "analyze_javascript_application",
reason: "io",
detail: failure.message,
diagnostics: {
system_code: "EIO",
syscall: "realpath",
path: selected,
},
},
});
});
it("reports descriptor exhaustion as a retryable resource constraint", async () => {
const { directory } = await application();
const selected = await realpath(directory);
expect(
await inject(
"access",
selected,
hostFailure("EMFILE", "access", selected),
),
).toMatchObject({
code: "resource_constraint",
category: "resource_constraint",
retryable: true,
details: {
operation: "analyze_javascript_application",
resource: "capacity",
reported_limits: {
system_code: "EMFILE",
syscall: "access",
path: selected,
},
},
});
});
it("classifies a denied selected metadata read as a host access denial", async () => {
const { directory } = await application();
const selected = await realpath(directory);
expect(
await inject("lstat", selected, hostFailure("EACCES", "lstat", selected)),
).toMatchObject({
code: "access_denied",
details: { path: selected, system_code: "EACCES" },
});
});
it("keeps a non-errno failure an execution failure", async () => {
const { directory } = await application();
const selected = await realpath(directory);
expect(
await inject("lstat", selected, new Error("unexpected internal failure")),
).toMatchObject({
code: "execution_failure",
category: "execution_failure",
details: {
diagnostics: { error_message: "unexpected internal failure" },
},
});
});
});