From 85be0f70a69a3a0ae7ea3a965f9c3621ebdb4b9e Mon Sep 17 00:00:00 2001 From: Dhruvilkumar Ashokbhai Chodvadiya <50516925+dhruvac29@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:58:06 -0800 Subject: [PATCH] fix(artifacts): guide retries for symlinked files (#8725) * fix(go): suggest resolved path for rejected symlinks * test(go): cover unresolved symlink recovery paths * fix(go): omit schema recovery for resolved symlinks * fix(artifacts): share resolved-path recovery across inspectors * fix(sqlite): retain resolved database path through snapshot reads * fix(captures): retain symlink retry guidance * fix(artifacts): preserve retry hints through redaction and selected reads --------- Co-authored-by: dhruvac29 --- src/android/AndroidMappingFile.ts | 2 + src/apkSignature/apkSignatureFailures.ts | 9 ++- src/artifacts/ArtifactReader.ts | 5 ++ .../ArtifactSourceResolution.test.ts | 32 +++++++++ src/artifacts/StableArtifactFailure.ts | 9 +++ src/artifacts/readStableArtifact.ts | 23 ++++++- src/browser/assets/WebArtifactReads.ts | 14 +++- src/browser/history/CaptureFailures.test.ts | 32 +++++++++ src/browser/history/CaptureFailures.ts | 45 +++++++------ src/domain/explicitSensitiveFailure.test.ts | 17 +++++ src/domain/explicitSensitiveFailure.ts | 11 +++- src/erlang/BeamProvider.ts | 19 ++++-- src/evm/EvmoleFailures.ts | 2 + src/firmware/devicetree/DevicetreeProvider.ts | 19 ++++-- src/go/GoBinaryProvider.ts | 9 ++- src/il2cpp/Il2cppMetadataProvider.ts | 9 ++- src/native/apple/AppleIpsProvider.ts | 9 ++- src/native/elf/ElfNotesProvider.ts | 19 ++++-- src/native/elf/KernelModuleProvider.ts | 19 ++++-- src/native/exports/NativeExportsProvider.ts | 9 ++- src/native/pe/PeCertificatesProvider.ts | 19 ++++-- src/native/pe/PeImportsProvider.ts | 9 ++- src/native/pe/PeLoadConfigProvider.ts | 9 ++- src/native/pe/PeResourcesProvider.ts | 19 ++++-- src/native/pwntools/PwntoolsFailures.ts | 2 + src/process/ArtifactAnalysisResources.test.ts | 38 ++++++++++- src/process/ArtifactAnalysisResources.ts | 14 +++- src/python/PythonPycProvider.ts | 19 ++++-- src/rust/RustBinaryProvider.ts | 9 ++- src/sqlite/SqliteDatabaseFailures.ts | 19 ++++-- src/sqlite/SqliteDatabaseSnapshot.ts | 20 ++++-- src/wasm/WabtFailures.ts | 9 ++- tests/boundary/go/goBinaryProvider.test.ts | 7 +- tests/boundary/mcp/goBinaryMcp.test.ts | 65 ++++++++++++++++++- .../stableArtifactSymlinkRecoveryMcp.test.ts | 56 ++++++++++++++++ .../sqlite/sqliteDatabaseSnapshot.test.ts | 14 ++++ 36 files changed, 525 insertions(+), 116 deletions(-) create mode 100644 tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts diff --git a/src/android/AndroidMappingFile.ts b/src/android/AndroidMappingFile.ts index 46577f1bbc..a0f5b4cc72 100644 --- a/src/android/AndroidMappingFile.ts +++ b/src/android/AndroidMappingFile.ts @@ -4,6 +4,7 @@ import { bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { @@ -99,6 +100,7 @@ const readFailurePrimary = ( if (owned?.reason === "path") return invalidMapping(read, "invalid_value", owned.message, { cause: owned, + ...stableArtifactInputRecovery(owned), }); if (primary instanceof Error && "code" in primary) { if (primary.code === "EACCES" || primary.code === "EPERM") diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts index 659506ecc9..d63a2942b2 100644 --- a/src/apkSignature/apkSignatureFailures.ts +++ b/src/apkSignature/apkSignatureFailures.ts @@ -1,5 +1,6 @@ import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -230,9 +231,11 @@ const artifactReaderFailure = ( }, ); case "path": - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); default: return adapterFailure(cause, path); } diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index e2fc1b6219..8709121b3d 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -26,6 +26,8 @@ import type { export interface ArtifactReaderFailureOptions extends ErrorOptions { /** Recovery guidance established by the producing adapter, separate from diagnostics. */ readonly userMessage?: string | undefined; + /** Regular-file path observed behind a rejected selected symlink; retry still revalidates it. */ + readonly resolvedPath?: string | undefined; readonly cancellation?: { readonly reason: unknown } | undefined; readonly cleanup?: AnalysisCleanupObservation | undefined; /** Exact authority for all reported cleanup; undefined leaves ownership unknown. */ @@ -230,6 +232,7 @@ export interface ArtifactReader { /** Typed adapter failure translated at provider boundary. */ export class ArtifactReaderFailure extends Error { readonly userMessage: string | undefined; + readonly resolvedPath: string | undefined; readonly cancellation: { readonly reason: unknown } | undefined; readonly cleanup: AnalysisCleanupObservation | undefined; readonly cleanupLeases: readonly AnalysisCleanupLease[] | undefined; @@ -255,6 +258,7 @@ export class ArtifactReaderFailure extends Error { super(message, options); this.name = "ArtifactReaderFailure"; this.userMessage = options?.userMessage; + this.resolvedPath = options?.resolvedPath; this.cancellation = reason === "cancelled" ? (options?.cancellation ?? { reason: options?.cause }) @@ -306,6 +310,7 @@ export class ArtifactReaderFailure extends Error { { cause: primary, userMessage: primary.userMessage, + resolvedPath: primary.resolvedPath, cancellation: primary.cancellation, cleanup: combineCleanup(primary.cleanup, cleanup), cleanupLeases: diff --git a/src/artifacts/ArtifactSourceResolution.test.ts b/src/artifacts/ArtifactSourceResolution.test.ts index 7101a31382..3a87e57f0b 100644 --- a/src/artifacts/ArtifactSourceResolution.test.ts +++ b/src/artifacts/ArtifactSourceResolution.test.ts @@ -156,3 +156,35 @@ it.each(["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG", "EACCES", "EIO"])( }); }, ); + +it.skipIf(process.platform === "win32")( + "keeps a rejected symlink without a hint when its resolved target lookup fails", + async () => { + const root = await createTestTempDirectory("rea-stable-symlink-lookup-"); + const target = join(root, "target.bin"); + const selected = join(root, "selected"); + await writeFile(target, "carrier"); + await symlink(target, selected); + const selectedStats = await lstat(selected, { bigint: true }); + const failure = Object.assign(new Error("target lookup failed"), { + code: "EACCES", + }); + + await expect( + readStableArtifact(selected, 1024, undefined, { + lstat: async (path) => { + if (path === selected) return selectedStats; + throw failure; + }, + realpath: async () => target, + open: async () => { + throw new Error("A rejected symlink must not be opened"); + }, + }), + ).rejects.toMatchObject({ + reason: "path", + resolvedPath: undefined, + message: `Expected a regular file without a symlink: ${selected}`, + }); + }, +); diff --git a/src/artifacts/StableArtifactFailure.ts b/src/artifacts/StableArtifactFailure.ts index 0c4fb62751..4d326b2927 100644 --- a/src/artifacts/StableArtifactFailure.ts +++ b/src/artifacts/StableArtifactFailure.ts @@ -5,6 +5,15 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js"; import { completedStableArtifactObservation } from "./StableArtifactObservation.js"; +/** Offer the verified regular-file target after a selected symlink is rejected. */ +export const stableArtifactInputRecovery = (cause: ArtifactReaderFailure) => + cause.reason === "path" && cause.resolvedPath !== undefined + ? { + remediationAction: `Retry with the resolved regular file path ${cause.resolvedPath}. The selected symlink is not admitted; the retry will validate the target again.`, + conformsToInputContract: true, + } + : {}; + const artifactCleanupFailure = ( cause: unknown, providerId: string, diff --git a/src/artifacts/readStableArtifact.ts b/src/artifacts/readStableArtifact.ts index ad3371d3f7..ec20830575 100644 --- a/src/artifacts/readStableArtifact.ts +++ b/src/artifacts/readStableArtifact.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { constants, type BigIntStats } from "node:fs"; -import { lstat, open, type FileHandle } from "node:fs/promises"; +import { lstat, open, realpath, type FileHandle } from "node:fs/promises"; import { sameRegularFileState } from "../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import type { ArtifactResourceScope } from "./ArtifactResourceScope.js"; @@ -38,11 +38,29 @@ export const completedStableArtifact = ( export interface StableArtifactFileSystem { lstat(path: string): Promise; open(path: string, flags: number): Promise; + realpath?(path: string): Promise; } const FILE_SYSTEM: StableArtifactFileSystem = { lstat: (path) => lstat(path, { bigint: true }), open, + realpath, +}; + +/** Suggest a retry only when the rejected link currently resolves to a regular file. */ +const resolvedRegularFile = async ( + path: string, + fileSystem: StableArtifactFileSystem, +): Promise => { + try { + const resolved = await fileSystem.realpath?.(path); + if (resolved === undefined) return undefined; + const target = await fileSystem.lstat(resolved); + return target.isFile() && !target.isSymbolicLink() ? resolved : undefined; + } catch { + // A missing or unreadable target changes only the recovery hint. + return undefined; + } }; const afterValidation = async ( @@ -77,6 +95,9 @@ export const readStableArtifact = async ( throw new ArtifactReaderFailure( "path", `Expected a regular file without a symlink: ${path}`, + before.isSymbolicLink() + ? { resolvedPath: await resolvedRegularFile(path, fileSystem) } + : {}, ); const maximumSize = BigInt(maximumBytes); if (before.size > maximumSize) diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index 009fe9e901..a0b469160e 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -1,6 +1,7 @@ import type { WebArtifactAcquisition as Acquisition } from "../../domain/webArtifactAcquisition.js"; import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../../artifacts/StableArtifactFailure.js"; import { artifactFileCancellationCause, artifactFilePrimaryFacts, @@ -250,9 +251,16 @@ const selectedFileError = ( context: WebArtifactReadContext, ): AnalysisError | undefined => { const selected = (message: string) => - new AnalysisInputError(context.operation, { cause }, [ - { path: context.field, reason: "invalid_value", message }, - ]); + new AnalysisInputError( + context.operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [{ path: context.field, reason: "invalid_value", message }], + ); if ( origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts index 260c630a1e..72eb16f407 100644 --- a/src/browser/history/CaptureFailures.test.ts +++ b/src/browser/history/CaptureFailures.test.ts @@ -12,6 +12,38 @@ import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js"; import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +it.each([ + { phase: "capture-read", field: ["capture_path"] }, + { + phase: "descriptor-read", + field: ["protobuf", "schema", "descriptor_set_path"], + }, +] as const)("offers the resolved-file retry for $phase", ({ phase, field }) => { + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: "/selected/capture.har", + format: "har", + protobuf: { + schema: { + descriptor_set_path: "/selected/schema.pb", + message_type: "capture.Message", + }, + }, + }); + const resolvedPath = "/resolved/regular-file"; + const cause = new ArtifactReaderFailure("path", "Selected a symlink", { + resolvedPath, + }); + const projected = projectAnalysisError( + historicalCaptureFailure(input, cause, phase), + ); + expect(projected).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: field }] }, + remediation: { action: expect.stringContaining(resolvedPath) }, + }); + expect(projected).not.toHaveProperty("recovery"); +}); + it.each([ { label: "POSIX SIGABRT", exitCode: null, signal: "SIGABRT" }, { label: "Windows exit 134", exitCode: 134, signal: null }, diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 0b51e4780d..cd02548a55 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -1,6 +1,7 @@ import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../../artifacts/StableArtifactFailure.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { bindArtifactFileCleanup, @@ -168,26 +169,30 @@ export const historicalCaptureFailure = ( cause.reason === "format" || cause.reason === "path") ) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: inputPath, - reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", - message: - cause.reason === "limit" - ? `${cause.message}. Select a ${sourceLabel} within the ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget. This release has no runtime override. capture_timeout_ms does not raise it.` - : cause.message, - ...(cause.reason === "limit" - ? { - expected: { - [descriptorRead - ? "maximum_descriptor_set_bytes" - : "maximum_capture_bytes"]: - WEB_NETWORK_CAPTURE_LIMITS.inputBytes, - }, - } - : {}), - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: inputPath, + reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: + cause.reason === "limit" + ? `${cause.message}. Select a ${sourceLabel} within the ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget. This release has no runtime override. capture_timeout_ms does not raise it.` + : cause.message, + ...(cause.reason === "limit" + ? { + expected: { + [descriptorRead + ? "maximum_descriptor_set_bytes" + : "maximum_capture_bytes"]: + WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + }, + } + : {}), + }, + ], + ); if ( phase !== "decoder" && cause instanceof Error && diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index c02093672e..3a1d81b1f7 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -278,6 +278,23 @@ it("coarsens ordinary sensitive issue-path segments to their real parent", () => expect(JSON.stringify(projectAnalysisError(result))).not.toContain("secret"); }); +it("retains input retry guidance while redacting marked text", () => { + const error = new AnalysisInputError( + "inspect_web_network_capture", + { + userMessage: "Selected private-marker capture is a symlink", + remediationAction: "Retry /resolved/private-marker/capture.har", + conformsToInputContract: true, + }, + [{ path: ["capture_path"], reason: "invalid_format" }], + ); + const redacted = redactExplicitFailure(error, ["private-marker"]); + const projected = projectAnalysisError(redacted); + expect(projected.remediation.action).toContain("/resolved/"); + expect(projected).not.toHaveProperty("recovery"); + expect(JSON.stringify(projected)).not.toContain("private-marker"); +}); + it("does not reinterpret an unknown argument name as a JSON pointer", () => { const input = new AnalysisInputError( "inspect_web_network_capture", diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index e55be448a7..a7e4e5f226 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -258,7 +258,16 @@ const redactFailure = ( if (error instanceof AnalysisInputError) return new AnalysisInputError( error.operation, - context, + { + ...context, + ...(error.userMessage === undefined + ? {} + : { userMessage: text(error.userMessage) }), + ...(error.remediationAction === undefined + ? {} + : { remediationAction: text(error.remediationAction) }), + conformsToInputContract: error.conformsToInputContract, + }, error.issues.map(issueProjection), ); if (error instanceof AnalysisOutputError) diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts index bbee47ca82..a68d45b004 100644 --- a/src/erlang/BeamProvider.ts +++ b/src/erlang/BeamProvider.ts @@ -1,6 +1,7 @@ import { ERLANG_BEAM_PROVIDER_IDENTITY } from "./ErlangBeamProviderIdentity.js"; import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; @@ -154,13 +155,17 @@ const beamFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index 3ae76c1b21..bfa320f067 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -1,5 +1,6 @@ import type { EvmWorkerLimits } from "./EvmWorkerLimits.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFileCancellationCause, artifactSelectedPathErrno, @@ -218,6 +219,7 @@ export const evmInterfaceFailure = ( { cause, ...cleanupOptions, + ...stableArtifactInputRecovery(cause), }, [ { diff --git a/src/firmware/devicetree/DevicetreeProvider.ts b/src/firmware/devicetree/DevicetreeProvider.ts index 848f9bec51..26ba90e132 100644 --- a/src/firmware/devicetree/DevicetreeProvider.ts +++ b/src/firmware/devicetree/DevicetreeProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -153,13 +154,17 @@ const devicetreeFailurePrimary = ( } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 5f59bf7f75..15c5413a15 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -2,6 +2,7 @@ import { GO_BINARY_PROVIDER_IDENTITY } from "./GoBinaryProviderIdentity.js"; import { projectStableArtifactFailure, projectArtifactCloseFailure, + stableArtifactInputRecovery, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; @@ -113,9 +114,11 @@ const failureForPrimary = ( }, ); if (cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 0cdd5b8e2e..996e0a480b 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -11,6 +11,7 @@ import { } from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -145,9 +146,11 @@ const failureForPrimary = ( }, ); if (cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: [field], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: [field], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index 26a1fc901f..913186c749 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -236,9 +237,11 @@ const appleIpsFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index 9efe2d648e..e96de9954f 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -195,13 +196,17 @@ const elfNotesFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/elf/KernelModuleProvider.ts b/src/native/elf/KernelModuleProvider.ts index d1a4d4113e..1c83e695cb 100644 --- a/src/native/elf/KernelModuleProvider.ts +++ b/src/native/elf/KernelModuleProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -187,13 +188,17 @@ const kernelModuleFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index e5682bc369..d564ed8826 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -160,9 +161,11 @@ const nativeExportsFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 47c95deb42..26c0d9a2ca 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -176,13 +177,17 @@ const peCertificatesFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 2e4cf5d452..7324ff68e6 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -203,9 +204,11 @@ const peImportsFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index 7087a7e0ef..9131c763d0 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -180,9 +181,11 @@ const peLoadConfigFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index 8bb56154c3..291adb7d51 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -165,13 +166,17 @@ const peResourcesFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(operation, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + operation, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts index 10da1660c5..721a4869a3 100644 --- a/src/native/pwntools/PwntoolsFailures.ts +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -3,6 +3,7 @@ import { bindArtifactFileCleanup, } from "../../artifacts/ArtifactFileOperation.js"; import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js"; +import { stableArtifactInputRecovery } from "../../artifacts/StableArtifactFailure.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { AnalysisError, @@ -274,6 +275,7 @@ const classifyPwntoolsDecoderFailure = ( context.operation, { cause, + ...stableArtifactInputRecovery(cause), ...(cause.reason === "limit" ? { userMessage: `${cause.message} Admitted artifact bytes use REA_PWNTOOLS_MAX_INPUT_BYTES (default ${String(PWNTOOLS_LIMITS.inputBytes)} bytes, maximum 1073741824). The 1073741824-byte maximum has no runtime override. Restart REA after changing the environment value. pwntools_timeout_ms does not raise this allowance. ${fixedPwntoolsBudgets}`, diff --git a/src/process/ArtifactAnalysisResources.test.ts b/src/process/ArtifactAnalysisResources.test.ts index 421621f7d1..b48bc8e023 100644 --- a/src/process/ArtifactAnalysisResources.test.ts +++ b/src/process/ArtifactAnalysisResources.test.ts @@ -4,7 +4,7 @@ import { ArtifactResourceScope, } from "../artifacts/ArtifactResourceScope.js"; import { withArtifactRegularFile } from "../artifacts/ArtifactFileOperation.js"; -import { writeFile } from "node:fs/promises"; +import { symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; @@ -28,6 +28,42 @@ import { ArtifactAnalysisResources } from "./ArtifactAnalysisResources.js"; import { readLinuxResourceLimits } from "./LinuxResourceLimits.js"; import { ProviderProcessSupervisor } from "./ProviderProcess.js"; +it.runIf(process.platform !== "win32")( + "offers a resolved-file retry from the shared selected-artifact boundary", + async () => { + const root = await createTestTempDirectory("rea-selected-artifact-"); + const target = join(root, "target.bin"); + const selected = join(root, "selected"); + await writeFile(target, "fixture"); + await symlink(target, selected); + const resources = new ArtifactAnalysisResources( + "fixture-provider", + "decode_signal_recording", + readStableArtifact, + readLinuxResourceLimits, + { message: "busy", details: {} }, + ); + try { + const result = await resources.run(async () => + ok( + await resources.readSelectedArtifact({ + path: selected, + maximumBytes: 1024, + allowanceName: "fixture input allowance", + observedOutput: undefined, + }), + ), + ); + if (result.ok) throw new Error("Expected selected symlink refusal"); + const projected = projectAnalysisError(result.error); + expect(projected.remediation.action).toContain(target); + expect(projected).not.toHaveProperty("recovery"); + } finally { + await resources.close(); + } + }, +); + it.each(["command", "limiter"])( "attributes post-run %s read failure to its exact artifact and retains producer output", async (failedPath) => { diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index aebf66d041..30dc6ae23a 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -10,6 +10,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { completedStableArtifactObservation } from "../artifacts/StableArtifactObservation.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -221,9 +222,16 @@ export class ArtifactAnalysisResources { return new AnalysisAccessDeniedError(operation, path, cause.code, { cause, }); - return new AnalysisInputError(operation, { cause }, [ - { path: ["path"], reason: "invalid_value", message: reason }, - ]); + return new AnalysisInputError( + operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [{ path: ["path"], reason: "invalid_value", message: reason }], + ); } readLimits(limits: LinuxResourceLimits): Promise { diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index a95fc5cb6c..488e44f625 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -1,6 +1,7 @@ import { PYTHON_PYC_PROVIDER_IDENTITY } from "./PythonPycProviderIdentity.js"; import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; @@ -126,13 +127,17 @@ const failureForPrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 5a90462c0e..69d48477e3 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -1,6 +1,7 @@ import { RUST_BINARY_PROVIDER_IDENTITY } from "./RustBinaryProviderIdentity.js"; import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; @@ -343,9 +344,11 @@ const artifactReaderFailure = ( }, ); if (cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { cause, diagnostics: { path, reason: cause.message }, diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index e049b61318..0bf8620e58 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -1,4 +1,5 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { ArtifactFileCleanupFailure, bindArtifactFileCleanup, @@ -172,13 +173,17 @@ export const sqliteDatabaseFailure = ( return new AnalysisCancelledError(operation, { cause: artifactFileCancellationCause(cause), }); - return new AnalysisInputError(operation, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + operation, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && cause.name === "AbortError") return new AnalysisCancelledError(operation, { diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index 79daeec560..b36763829a 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -1,4 +1,4 @@ -import { lstat, open, writeFile } from "node:fs/promises"; +import { lstat, open, realpath, writeFile } from "node:fs/promises"; import { constants, type BigIntStats } from "node:fs"; import { join } from "node:path"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; @@ -24,6 +24,7 @@ import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; const fileSystem: StableArtifactFileSystem = { lstat: (path) => lstat(path, { bigint: true }), open, + realpath, }; type SnapshotMemberRole = "database" | "wal"; @@ -165,14 +166,17 @@ const duringAcquisition = async ( }; const readObservedArtifact = ( + role: SnapshotMemberRole, path: string, observed: BigIntStats, maximumBytes: number, selectedFileSystem: StableArtifactFileSystem, resourceScope: ArtifactResourceScope, signal?: AbortSignal, -) => - duringAcquisition(path, () => +) => { + const resolveSelectedPath = + role === "database" ? selectedFileSystem.realpath : undefined; + return duringAcquisition(path, () => readStableArtifact( path, maximumBytes, @@ -180,9 +184,15 @@ const readObservedArtifact = ( { open: (selectedPath, flags) => selectedFileSystem.open(selectedPath, flags), + ...(resolveSelectedPath === undefined + ? {} + : { + realpath: (selectedPath: string) => + resolveSelectedPath.call(selectedFileSystem, selectedPath), + }), lstat: async (selectedPath) => { const current = await selectedFileSystem.lstat(selectedPath); - if (!sameState(observed, current)) + if (selectedPath === path && !sameState(observed, current)) throw new ArtifactReaderFailure( "integrity", `SQLite snapshot file set changed during acquisition: ${selectedPath}`, @@ -193,6 +203,7 @@ const readObservedArtifact = ( resourceScope, ), ); +}; const verifyInactiveJournal = async ( path: string, @@ -301,6 +312,7 @@ export const captureSqliteDatabaseSnapshot = async ( return recordMember( role, await readObservedArtifact( + role, role === "database" ? path : walPath, observed, maximumBytes, diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 27ba86a1bc..d7bc63aaed 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -1,5 +1,6 @@ import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFileCancellationCause, bindArtifactFileCleanup, @@ -307,7 +308,13 @@ const classifyWabtFailure = ( if (phase === "artifact") return new AnalysisInputError( "inspect_wasm_artifact", - { cause, ...artifactOptions }, + { + cause, + ...artifactOptions, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, [ { path: [...(execution?.inputField ?? ["path"])], diff --git a/tests/boundary/go/goBinaryProvider.test.ts b/tests/boundary/go/goBinaryProvider.test.ts index 077f2ecbf9..cfda1f065e 100644 --- a/tests/boundary/go/goBinaryProvider.test.ts +++ b/tests/boundary/go/goBinaryProvider.test.ts @@ -131,7 +131,12 @@ it.skipIf(process.platform === "win32")( await symlink(original, selected); const result = await new GoBinaryProvider().inspect({ path: selected }); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisInputError); + const projected = projectAnalysisError(result.error); + expect(projected.remediation.action).toContain(original); + expect(projected).not.toHaveProperty("recovery"); + } }, ); diff --git a/tests/boundary/mcp/goBinaryMcp.test.ts b/tests/boundary/mcp/goBinaryMcp.test.ts index 909071e375..0ccb8c3cd9 100644 --- a/tests/boundary/mcp/goBinaryMcp.test.ts +++ b/tests/boundary/mcp/goBinaryMcp.test.ts @@ -1,7 +1,7 @@ import { goBinarySchema } from "../../../src/domain/go/goBinary.js"; import type { Client } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; -import { writeFile } from "node:fs/promises"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; @@ -184,6 +184,69 @@ for (const expectedErrno of ["ELOOP", "ENAMETOOLONG"] as const) { ); } +goMcpTest.skipIf(process.platform === "win32")( + "suggests a resolved regular file after rejecting a selected symlink", + async ({ go: { client } }) => { + const root = await createTestTempDirectory("rea-go-mcp-symlink-"); + const original = join(root, "original.elf"); + const selected = join(root, "selected"); + await writeFile(original, createGoBinaryFixture().bytes); + await symlink(original, selected); + + const rejected = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: selected }, + }); + const diagnostic = parseMcpToolError(rejected).error; + expect(diagnostic).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + remediation: { action: expect.stringContaining(original) }, + }); + expect(diagnostic).not.toHaveProperty("recovery"); + + const retried = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: original }, + }); + expect(retried.isError).not.toBe(true); + }, +); + +for (const targetKind of ["dangling", "directory"] as const) { + goMcpTest.skipIf(process.platform === "win32")( + `keeps a ${targetKind} symlink rejection without a resolved-file hint`, + async ({ go: { client } }) => { + const root = await createTestTempDirectory("rea-go-mcp-symlink-"); + const target = join(root, "target"); + const selected = join(root, "selected"); + if (targetKind === "directory") await mkdir(target); + await symlink(target, selected); + + const rejected = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: selected }, + }); + const diagnostic = parseMcpToolError(rejected).error; + expect(diagnostic).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining(selected), + }, + ], + }, + }); + expect(diagnostic.remediation.action).not.toContain(target); + }, + ); +} + goMcpTest( "preserves invalid embedded Go text bytes through MCP", async ({ go: { client, session } }) => { diff --git a/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts b/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts new file mode 100644 index 0000000000..c1611a1021 --- /dev/null +++ b/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts @@ -0,0 +1,56 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +mcpTest.skipIf(process.platform === "win32")( + "offers one resolved-file retry for sibling stable-artifact inspectors", + async ({ mcp }) => { + const root = await createTestTempDirectory("rea-stable-recovery-mcp-"); + const target = join(root, "target.bin"); + const selected = join(root, "selected"); + await writeFile(target, "carrier"); + await symlink(target, selected); + const session = createTestBinarySession(() => { + throw new Error("Target-free inspection must not start a deep provider"); + }); + try { + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + for (const name of [ + "inspect_rust_binary", + "inspect_elf_notes", + "inspect_devicetree", + "inspect_sqlite_database", + ]) { + const response = await client.callTool({ + name, + arguments: { path: selected }, + }); + const diagnostic = parseMcpToolError(response).error; + expect(diagnostic.remediation.action, name).toContain(target); + expect(diagnostic, name).not.toHaveProperty("recovery"); + } + const captureResponse = await client.callTool({ + name: "inspect_web_network_capture", + arguments: { + capture_path: selected, + format: "har", + sensitive_values: ["unrelated marked value"], + }, + }); + const captureError = parseMcpToolError(captureResponse).error; + expect(captureError.remediation.action).toContain(target); + expect(captureError).not.toHaveProperty("recovery"); + } finally { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts index 5cfd3a6f8d..09d8710062 100644 --- a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts @@ -206,6 +206,20 @@ it.runIf(process.platform !== "win32")( }, ); +it.runIf(process.platform !== "win32")( + "does not offer a resolved-file retry for a derived WAL symlink", + async () => { + const { database, workspace, capture } = await fixture(true); + const target = workspace.path("wal-target"); + await rename(database.walPath, target); + await symlink(target, database.walPath); + await expect(capture()).rejects.toMatchObject({ + operationCause: { reason: "path", resolvedPath: undefined }, + completedMembers: [{ role: "database", path: database.path }], + }); + }, +); + it.each(["activate", "replace", "remove"])( "rejects a cold journal changed during database acquisition: %s", async (change) => {