From 8c24272ee7e6d30b1493ee7ccd63e45734fe2f95 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:15:49 +0800 Subject: [PATCH] refactor(artifacts): remove selectors and traversal quotas --- docs/mcp-contracts.md | 6 +- .../references/native-and-artifacts.md | 7 +- src/application/ArtifactExtraction.ts | 149 +++-------------- src/application/ArtifactGraphConstruction.ts | 16 +- src/application/ArtifactInventory.ts | 10 +- src/application/ArtifactInventory/hash.ts | 45 +---- .../ArtifactInventory/scanCanonical.ts | 25 +-- .../ArtifactInventory/scanReader.ts | 158 +++++++++--------- src/application/ArtifactInventory/types.ts | 1 - .../AuthorizedArtifactInventory.ts | 4 +- src/application/JavaScriptArtifactFiles.ts | 122 ++++++++------ .../JavaScriptArtifactGraphBuilder.ts | 10 -- .../JavaScriptArtifactReconstruction.ts | 7 +- .../JavaScriptArtifactReconstructionInput.ts | 14 -- src/artifacts/ArtifactPaths.ts | 13 +- src/artifacts/ArtifactProvider.ts | 53 ++---- src/artifacts/ArtifactReader.ts | 11 -- src/artifacts/SafeOutputTree.ts | 32 +--- src/cli/artifactCommands.ts | 29 +--- src/contracts/artifactComparisonExample.ts | 8 - src/contracts/artifactToolContracts.ts | 34 +--- src/domain/artifactGraph.ts | 20 +-- src/server/createServer.ts | 2 +- .../artifactInventorySnapshot.test.ts | 11 +- .../authorizedArtifactInventory.test.ts | 11 +- ...scriptArtifactReconstructionLimits.test.ts | 15 +- .../filesystem/safeOutputTree.test.ts | 17 +- .../artifactProvider.archiveSafety.test.ts | 47 ++---- .../artifactProvider.extraction.test.ts | 38 +---- .../artifactComparison.test.ts | 10 +- .../javascriptArtifactReconstruction.test.ts | 9 +- 31 files changed, 268 insertions(+), 666 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 07e586c332..dc0cdda2f0 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -89,8 +89,10 @@ maximum authority: - reference-source roots; - native mount enablement. -Artifact extraction retains its existing explicit per-call approval and maps to -an administrator root ceiling of `/` for compatibility. +Artifact extraction takes no path or occurrence selectors. It materializes all +regular files into a fresh REA-chosen temporary directory. The write still +passes through the active permission evaluator; the tool caller does not choose +an output path. `rea policy status`, `list`, `explain`, and `revoke` inspect the same evaluator used by MCP. Optional project grants require both diff --git a/skills/reverse-engineer-anything/references/native-and-artifacts.md b/skills/reverse-engineer-anything/references/native-and-artifacts.md index e11931893c..bcac20b712 100644 --- a/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -27,9 +27,10 @@ Call `inspect_artifact` before extraction for application bundles, archives, ZIP/APK/IPA/MSIX/AppX, ASAR, or DMG inputs. It returns the complete artifact graph inline. Cite graph manifest IDs. -`extract_artifact` requires explicit approval, an absent absolute output root, -and selected occurrence IDs. Never extract every entry implicitly. Symlinks and -encrypted entries are inventory facts, not extractable files. +`extract_artifact` takes no arguments and materializes all regular files into a +fresh temporary directory chosen by REA. Symlinks and encrypted entries are +inventory facts, not extractable files. The operation still requires the +filesystem-write permission grant; callers do not choose the destination. Native DMG traversal is macOS-only, read-only, and requires both operator policy and `native_mount_approved: true`; without both, retain the root-hash-only result. diff --git a/src/application/ArtifactExtraction.ts b/src/application/ArtifactExtraction.ts index 444232912b..efb618b461 100644 --- a/src/application/ArtifactExtraction.ts +++ b/src/application/ArtifactExtraction.ts @@ -8,7 +8,6 @@ import { import { ArtifactReaderFailure, type ArtifactEntry, - type ArtifactLimits, type ArtifactReader, } from "../artifacts/ArtifactReader.js"; import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js"; @@ -24,35 +23,28 @@ import { } from "../domain/artifactGraph.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import { scanArtifactInventory } from "./ArtifactInventory.js"; -import { - digestCanonical, - toOutputLimits, -} from "./ArtifactGraphConstruction.js"; +import { digestCanonical } from "./ArtifactGraphConstruction.js"; /** Already-approved extraction request from a caller-owned policy boundary. */ export interface ArtifactExtractionInput { readonly inputPath: string; readonly inputFormat: BinaryTarget["format"]; readonly outputRoot: string; - readonly selection: - | { readonly paths: readonly string[] } - | { readonly occurrenceIds: readonly string[] }; - readonly limits: ArtifactLimits; } -/** Extract selected inventory occurrences into an exclusively owned absent root. */ +/** Extract every regular inventory occurrence into an exclusively owned absent root. */ export const extractArtifact = async ( input: ArtifactExtractionInput, signal?: AbortSignal, ): Promise => { - validateSelection(input.selection, input.limits); const sourcePath = await realpath(input.inputPath); - const snapshot = await scanArtifactInventory(sourcePath, input.limits, { + const snapshot = await scanArtifactInventory(sourcePath, { signal, }); - const selectedOccurrences = resolveSelection( - snapshot.occurrences, - input.selection, + const selectedOccurrences = snapshot.occurrences.filter( + (occurrence) => + (occurrence.entry_kind === "file" || occurrence.entry_kind === "slice") && + occurrence.logical_path !== ".", ); const selectedIds = new Set( selectedOccurrences.map(({ occurrence_id: id }) => id), @@ -130,25 +122,25 @@ const materializeSelection = async ({ selected.map((item) => [item.occurrence.logical_path, item]), ); const reader = await createReader(sourcePath, input.inputFormat); - const output = await SafeOutputTree.create(input.outputRoot, input.limits); + const output = await SafeOutputTree.create(input.outputRoot); let readerClosed = false; const extracted: ExtractedOccurrence[] = []; try { - const found = new Set(); + const materialized: SelectedOccurrence[] = []; const registry = new ArtifactPathRegistry(); - let entryCount = 0; for await (const entry of reader.entries(signal)) { - entryCount += 1; - if (entryCount > input.limits.maxEntries) - throw new ArtifactReaderFailure( - "limit", - "Artifact entry limit exceeded during extraction", - ); - const path = normalizeArtifactPath(entry.path, input.limits); + const path = normalizeArtifactPath(entry.path); registry.add(path, entry.kind); const selectedItem = byPath.get(path); - if (selectedItem === undefined) continue; - preflight(entry, input.limits); + if (selectedItem === undefined) { + if (entry.kind === "file" || entry.kind === "slice") + throw new ArtifactReaderFailure( + "integrity", + `Regular artifact entry is missing from inventory: ${path}`, + ); + continue; + } + preflight(entry); const stream = await reader.open(entry, signal); const written = await output.write( path, @@ -163,13 +155,8 @@ const materializeSelection = async ({ bytes_written: written.bytesWritten, created: true, }); - found.add(path); + materialized.push(selectedItem); } - if (found.size !== selected.length) - throw new ArtifactReaderFailure( - "integrity", - "Selected inventory occurrence disappeared before extraction", - ); await reader.close(); readerClosed = true; extracted.sort((left, right) => @@ -178,7 +165,7 @@ const materializeSelection = async ({ const result = createExtractionResult( input, inventory, - selected, + materialized, extracted, ); await output.commit(); @@ -214,10 +201,9 @@ const createExtractionResult = ( artifacts: extracted, containment_verified: true, cleanup: { attempted: false, verified: true, residual_paths: [] }, - limits: toOutputLimits(input.limits), provenance: [], limitations: [ - "Only caller-selected regular file occurrences were materialized.", + "All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.", ], }); }; @@ -272,99 +258,10 @@ const createReader = async ( ); }; -type ArtifactSelection = ArtifactExtractionInput["selection"]; - -const validateSelection = ( - selection: ArtifactSelection, - limits: ArtifactLimits, -): void => { - const values = - "paths" in selection ? selection.paths : selection.occurrenceIds; - if (values.length === 0) - throw new ArtifactReaderFailure( - "limit", - "Extraction requires at least one selected path or occurrence", - ); - const normalized = - "paths" in selection - ? values.map((path) => normalizeArtifactPath(path, limits)) - : values; - if (new Set(normalized).size !== normalized.length) - throw new ArtifactReaderFailure( - "path", - "Extraction selection contains duplicates", - ); -}; - -const resolveSelection = ( - occurrences: readonly ArtifactOccurrence[], - selection: ArtifactSelection, -): ArtifactOccurrence[] => { - if ("occurrenceIds" in selection) { - const byId = new Map( - occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]), - ); - return selection.occurrenceIds.map((id) => { - const occurrence = byId.get(id); - if (occurrence === undefined) - throw new ArtifactReaderFailure( - "unavailable", - `Selected artifact occurrence was not found: ${id}`, - ); - return occurrence; - }); - } - - const byPath = new Map(); - for (const occurrence of occurrences) { - const matches = byPath.get(occurrence.logical_path); - if (matches === undefined) - byPath.set(occurrence.logical_path, [occurrence]); - else matches.push(occurrence); - } - return selection.paths.map((path) => { - const normalizedPath = path.normalize("NFC"); - const matches = byPath.get(normalizedPath) ?? []; - if (matches.length === 0) - throw new ArtifactReaderFailure( - "unavailable", - `Selected artifact path was not found: ${path}`, - ); - if (matches.length > 1) - throw new ArtifactReaderFailure( - "path", - `Selected artifact path is ambiguous: ${path}`, - ); - const match = matches[0]; - if (match === undefined) - throw new ArtifactReaderFailure( - "unavailable", - `Selected artifact path was not found: ${path}`, - ); - return match; - }); -}; - -const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => { +const preflight = (entry: ArtifactEntry): void => { if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted) throw new ArtifactReaderFailure( "format", `Selected artifact entry cannot be read: ${entry.path}`, ); - if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes) - throw new ArtifactReaderFailure( - "limit", - `Selected artifact exceeds byte limit: ${entry.path}`, - ); - if ( - entry.declaredSize !== null && - entry.compressedSize !== null && - (entry.compressedSize === 0 - ? entry.declaredSize > 0 - : entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio) - ) - throw new ArtifactReaderFailure( - "limit", - `Selected artifact exceeds compression ratio limit: ${entry.path}`, - ); }; diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index b9959328e4..b67e5b2e19 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -2,10 +2,7 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; -import type { - ArtifactEntry, - ArtifactLimits, -} from "../artifacts/ArtifactReader.js"; +import type { ArtifactEntry } from "../artifacts/ArtifactReader.js"; import type { ArtifactCommand, ArtifactEdge, @@ -55,7 +52,7 @@ export const createOccurrence = ( compressed_size: entry.compressedSize, executable: entry.executable, encrypted: entry.encrypted, - hash_status: entry.encrypted ? "unavailable" : "not-hashed-limit", + hash_status: entry.encrypted ? "unavailable" : "not-hashed", source_location: entry.byteOffset === null || entry.declaredSize === null ? null @@ -318,15 +315,6 @@ export const classifyArtifactContent = ( return byPath; }; -export const toOutputLimits = (limits: ArtifactLimits) => ({ - max_entries: limits.maxEntries, - max_total_bytes: limits.maxTotalBytes, - max_entry_bytes: limits.maxEntryBytes, - max_compression_ratio: limits.maxCompressionRatio, - max_depth: limits.maxDepth, - max_path_bytes: limits.maxPathBytes, -}); - export const digestCanonical = (value: unknown): string => { const encoded = canonicalize(value); if (encoded === undefined) diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index 7b0ac2ebfd..fdd867b622 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -1,6 +1,5 @@ import { realpath } from "node:fs/promises"; -import type { ArtifactLimits } from "../artifacts/ArtifactReader.js"; import { artifactInventoryResultSchema, type ArtifactInventoryResult, @@ -26,38 +25,35 @@ export type { /** Inventory one local artifact without extracting or mounting it. */ export const inventoryArtifact = async ( inputPath: string, - limits: ArtifactLimits, options: { readonly signal?: AbortSignal; readonly nativeMount?: ArtifactNativeMountPolicy; readonly integrity?: ArtifactIntegrityPolicy; } = {}, ): Promise => { - const snapshot = await scanArtifactInventory(inputPath, limits, options); + const snapshot = await scanArtifactInventory(inputPath, options); return artifactInventoryResultSchema.parse(snapshot); }; /** Inventory one artifact and return every graph collection in one response. */ export const inventoryArtifactFully = async ( inputPath: string, - limits: ArtifactLimits, options: { readonly signal?: AbortSignal; readonly nativeMount?: ArtifactNativeMountPolicy; readonly integrity?: ArtifactIntegrityPolicy; } = {}, ): Promise => { - const snapshot = await scanArtifactInventory(inputPath, limits, options); + const snapshot = await scanArtifactInventory(inputPath, options); return artifactInventoryResultSchema.parse(snapshot); }; /** Scan an artifact once and retain the complete immutable graph for projection. */ export const scanArtifactInventory = async ( inputPath: string, - limits: ArtifactLimits, options: ArtifactInventoryOptions = {}, ): Promise => { abortIfNeeded(options.signal); const path = await realpath(inputPath); - return scanCanonicalArtifactInventory(path, limits, options); + return scanCanonicalArtifactInventory(path, options); }; diff --git a/src/application/ArtifactInventory/hash.ts b/src/application/ArtifactInventory/hash.ts index 02c76a21e2..17b65d6979 100644 --- a/src/application/ArtifactInventory/hash.ts +++ b/src/application/ArtifactInventory/hash.ts @@ -1,11 +1,7 @@ import { createHash } from "node:crypto"; import type { Readable } from "node:stream"; -import { - ArtifactReaderFailure, - type ArtifactEntry, - type ArtifactLimits, -} from "../../artifacts/ArtifactReader.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js"; export type HashResult = { @@ -22,40 +18,8 @@ export const abortIfNeeded = (signal?: AbortSignal): void => { ); }; -export const preflightEntry = ( - entry: ArtifactEntry, - limits: ArtifactLimits, -): void => { - if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes) - throw new ArtifactReaderFailure( - "limit", - `Entry exceeds byte limit: ${entry.path}`, - ); - if ( - entry.declaredSize !== null && - entry.compressedSize !== null && - entry.compressedSize === 0 && - entry.declaredSize > 0 - ) - throw new ArtifactReaderFailure( - "limit", - `Invalid compression ratio: ${entry.path}`, - ); - if ( - entry.declaredSize !== null && - entry.compressedSize !== null && - entry.compressedSize > 0 && - entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio - ) - throw new ArtifactReaderFailure( - "limit", - `Compression ratio exceeds limit: ${entry.path}`, - ); -}; - export const hashReadable = async ( stream: Readable, - maximum: number, signal?: AbortSignal, ): Promise => { const hash = createHash("sha256"); @@ -66,13 +30,6 @@ export const hashReadable = async ( abortIfNeeded(signal); const chunk = streamChunkToBuffer(raw); bytes += chunk.length; - if (bytes > maximum) { - stream.destroy(); - throw new ArtifactReaderFailure( - "limit", - "Observed entry bytes exceed limit", - ); - } hash.update(chunk); if (prefixBytes < 16) { const selected = chunk.subarray(0, 16 - prefixBytes); diff --git a/src/application/ArtifactInventory/scanCanonical.ts b/src/application/ArtifactInventory/scanCanonical.ts index 60f5604987..9a0446d7a1 100644 --- a/src/application/ArtifactInventory/scanCanonical.ts +++ b/src/application/ArtifactInventory/scanCanonical.ts @@ -4,7 +4,6 @@ import type { Stats } from "node:fs"; import { ArtifactReaderFailure, - type ArtifactLimits, type ArtifactReader, } from "../../artifacts/ArtifactReader.js"; import { @@ -21,7 +20,6 @@ import { materializeDirectoryNodes, rekeyOccurrences, rootOccurrenceFor, - toOutputLimits, type MutableOccurrence, } from "../ArtifactGraphConstruction.js"; import { classifyRoot } from "./classify.js"; @@ -40,7 +38,6 @@ import { export const scanCanonicalArtifactInventory = async ( path: string, - limits: ArtifactLimits, options: ArtifactInventoryOptions = {}, ): Promise => { const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY; @@ -48,11 +45,7 @@ export const scanCanonicalArtifactInventory = async ( const rootFormat = await classifyRoot(path, metadata.isDirectory()); const rootDigest = metadata.isDirectory() ? null - : await hashReadable( - createReadStream(path), - limits.maxTotalBytes, - options.signal, - ); + : await hashReadable(createReadStream(path), options.signal); const reader = await createReader( path, rootFormat, @@ -63,7 +56,6 @@ export const scanCanonicalArtifactInventory = async ( try { const { nodes, occurrences, pendingContradictions } = await scanReader( reader, - limits, options.signal, integrity, ); @@ -73,7 +65,6 @@ export const scanCanonicalArtifactInventory = async ( rootFormat, rootDigest, reader, - limits, signal: options.signal, nodes, occurrences, @@ -90,7 +81,6 @@ interface SnapshotBuildInput { readonly rootFormat: ArtifactNode["format"]; readonly rootDigest: HashResult | null; readonly reader: ArtifactReader | undefined; - readonly limits: ArtifactLimits; readonly signal: AbortSignal | undefined; readonly nodes: Map; readonly occurrences: MutableOccurrence[]; @@ -100,8 +90,7 @@ interface SnapshotBuildInput { const buildInventorySnapshot = async ( input: SnapshotBuildInput, ): Promise => { - const { path, metadata, rootFormat, rootDigest, reader, limits, signal } = - input; + const { path, metadata, rootFormat, rootDigest, reader, signal } = input; materializeDirectoryNodes(input.occurrences, input.nodes); const rootNode = createRootNode({ path, @@ -140,7 +129,7 @@ const buildInventorySnapshot = async ( const orderedOccurrences = sortOccurrences(input.occurrences); const orderedEdges = sortEdges(edges); - await verifyRootDigest(path, rootDigest, limits.maxTotalBytes, signal); + await verifyRootDigest(path, rootDigest, signal); const graphSha256 = digestCanonical({ nodes: orderedNodes, @@ -160,7 +149,6 @@ const buildInventorySnapshot = async ( nodes: orderedNodes, occurrences: orderedOccurrences, edges: orderedEdges, - limits: toOutputLimits(limits), provenance: reader?.provenance() ?? [], integrity_contradictions: integrityContradictions, limitations: buildLimitations(rootFormat, reader, integrityContradictions), @@ -210,15 +198,10 @@ const buildIntegrityContradictions = ( const verifyRootDigest = async ( path: string, rootDigest: HashResult | null, - maxTotalBytes: number, signal: AbortSignal | undefined, ): Promise => { if (rootDigest === null) return; - const verified = await hashReadable( - createReadStream(path), - maxTotalBytes, - signal, - ); + const verified = await hashReadable(createReadStream(path), signal); if ( verified.sha256 !== rootDigest.sha256 || verified.bytes !== rootDigest.bytes diff --git a/src/application/ArtifactInventory/scanReader.ts b/src/application/ArtifactInventory/scanReader.ts index 138de46d89..0ed4f135fd 100644 --- a/src/application/ArtifactInventory/scanReader.ts +++ b/src/application/ArtifactInventory/scanReader.ts @@ -5,7 +5,6 @@ import { import { ArtifactReaderFailure, type ArtifactEntry, - type ArtifactLimits, type ArtifactReader, } from "../../artifacts/ArtifactReader.js"; import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js"; @@ -21,7 +20,7 @@ import { STRICT_INTEGRITY_POLICY, type ArtifactIntegrityPolicy, } from "./types.js"; -import { hashReadable, preflightEntry } from "./hash.js"; +import { hashReadable } from "./hash.js"; export interface PendingIntegrityContradiction { readonly logicalPath: string; @@ -42,7 +41,6 @@ const emptyScan = (): { interface ScanContext { readonly reader: ArtifactReader; - readonly limits: ArtifactLimits; readonly signal: AbortSignal | undefined; readonly integrity: ArtifactIntegrityPolicy; readonly nodes: Map; @@ -50,12 +48,10 @@ interface ScanContext { readonly pendingContradictions: PendingIntegrityContradiction[]; readonly occurrenceByPath: Map; readonly registry: ArtifactPathRegistry; - totalBytes: number; } export const scanReader = async ( reader: ArtifactReader | undefined, - limits: ArtifactLimits, signal?: AbortSignal, integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY, ): Promise<{ @@ -68,7 +64,6 @@ export const scanReader = async ( return { nodes, occurrences, pendingContradictions }; const context: ScanContext = { reader, - limits, signal, integrity, nodes, @@ -76,7 +71,6 @@ export const scanReader = async ( pendingContradictions, occurrenceByPath: new Map(), registry: new ArtifactPathRegistry(), - totalBytes: 0, }; await visitArtifactEntries(context, reader, ""); return { nodes, occurrences, pendingContradictions }; @@ -87,53 +81,93 @@ const visitArtifactEntries = async ( currentReader: ArtifactReader, prefix: string, ): Promise => { - for await (const entry of currentReader.entries(context.signal)) { - if (context.occurrences.length >= context.limits.maxEntries) - throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded"); - const logicalPath = normalizeArtifactPath( - prefix.length === 0 ? entry.path : `${prefix}/${entry.path}`, - context.limits, - ); - const expandableAsar = isExpandableAsar(entry, logicalPath); - context.registry.add( - logicalPath, - expandableAsar ? "directory" : entry.kind, - ); - preflightEntry(entry, context.limits); - const parent = nearestParent(logicalPath, context.occurrenceByPath); - const occurrence = createOccurrence( - entry, - logicalPath, - parent?.occurrence_id ?? null, - ); - let digested: - | { readonly node: ArtifactNode; readonly mismatched: boolean } - | undefined; - try { - digested = await digestArtifactEntry( - context, - currentReader, + const stack: Array<{ + readonly reader: ArtifactReader; + readonly prefix: string; + readonly iterator: AsyncIterator; + readonly owned: boolean; + }> = [ + { + reader: currentReader, + prefix, + iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](), + owned: false, + }, + ]; + try { + while (stack.length > 0) { + const frame = stack.at(-1); + if (frame === undefined) break; + const next = await frame.iterator.next(); + if (next.done) { + stack.pop(); + if (frame.owned) await frame.reader.close(); + continue; + } + const entry = next.value; + const logicalPath = normalizeArtifactPath( + frame.prefix.length === 0 + ? entry.path + : `${frame.prefix}/${entry.path}`, + ); + const expandableAsar = isExpandableAsar(entry, logicalPath); + context.registry.add( + logicalPath, + expandableAsar ? "directory" : entry.kind, + ); + const parent = nearestParent(logicalPath, context.occurrenceByPath); + const occurrence = createOccurrence( entry, logicalPath, + parent?.occurrence_id ?? null, ); - } catch (cause: unknown) { - if (!isUnavailableUnpackedEntry(cause, entry)) throw cause; - occurrence.hash_status = "unavailable"; - occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION); - } - if (digested !== undefined) { - context.nodes.set(digested.node.artifact_id, digested.node); - occurrence.artifact_id = digested.node.artifact_id; - occurrence.hash_status = digested.mismatched ? "mismatched" : "verified"; - if (digested.mismatched) - occurrence.limitations.push( - "Declared integrity metadata contradicts observed bytes.", + let digested: + | { readonly node: ArtifactNode; readonly mismatched: boolean } + | undefined; + try { + digested = await digestArtifactEntry( + context, + frame.reader, + entry, + logicalPath, ); + } catch (cause: unknown) { + if (!isUnavailableUnpackedEntry(cause, entry)) throw cause; + occurrence.hash_status = "unavailable"; + occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION); + } + if (digested !== undefined) { + context.nodes.set(digested.node.artifact_id, digested.node); + occurrence.artifact_id = digested.node.artifact_id; + occurrence.hash_status = digested.mismatched + ? "mismatched" + : "verified"; + if (digested.mismatched) + occurrence.limitations.push( + "Declared integrity metadata contradicts observed bytes.", + ); + } + context.occurrences.push(occurrence); + context.occurrenceByPath.set(logicalPath, occurrence); + if (expandableAsar && digested?.mismatched !== true) { + const nested = new AsarArtifactReader(entry.adapterKey); + stack.push({ + reader: nested, + prefix: logicalPath, + iterator: nested.entries(context.signal)[Symbol.asyncIterator](), + owned: true, + }); + } } - context.occurrences.push(occurrence); - context.occurrenceByPath.set(logicalPath, occurrence); - if (expandableAsar && digested?.mismatched !== true) - await visitNestedAsar(context, entry.adapterKey, logicalPath); + } finally { + await Promise.allSettled( + stack + .filter(({ owned }) => owned) + .map(async ({ reader, iterator }) => { + await iterator.return?.(); + await reader.close(); + }), + ); } }; @@ -147,23 +181,10 @@ const digestArtifactEntry = async ( > => { if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted) return undefined; - const remainingBytes = context.limits.maxTotalBytes - context.totalBytes; - if (remainingBytes <= 0) - throw new ArtifactReaderFailure( - "limit", - "Artifact total byte limit exceeded", - ); - if (entry.declaredSize !== null && entry.declaredSize > remainingBytes) - throw new ArtifactReaderFailure( - "limit", - "Declared artifact bytes exceed remaining cumulative limit", - ); const digest = await hashReadable( await currentReader.open(entry, context.signal), - Math.min(context.limits.maxEntryBytes, remainingBytes), context.signal, ); - context.totalBytes += digest.bytes; const mismatched = entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256; if (mismatched && entry.declaredSha256 !== null) { @@ -209,19 +230,6 @@ const digestArtifactEntry = async ( }; }; -const visitNestedAsar = async ( - context: ScanContext, - adapterKey: string, - logicalPath: string, -): Promise => { - const nested = new AsarArtifactReader(adapterKey); - try { - await visitArtifactEntries(context, nested, logicalPath); - } finally { - await nested.close(); - } -}; - const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean => entry.kind === "file" && logicalPath.toLowerCase().endsWith(".asar") && diff --git a/src/application/ArtifactInventory/types.ts b/src/application/ArtifactInventory/types.ts index 7f6237ff7f..d1b76ffbef 100644 --- a/src/application/ArtifactInventory/types.ts +++ b/src/application/ArtifactInventory/types.ts @@ -35,7 +35,6 @@ export interface ArtifactInventorySnapshot { readonly nodes: readonly ArtifactNode[]; readonly occurrences: ArtifactInventoryResult["occurrences"]; readonly edges: ArtifactInventoryResult["edges"]; - readonly limits: ArtifactInventoryResult["limits"]; readonly provenance: ReadonlyArray< ArtifactInventoryResult["provenance"][number] >; diff --git a/src/application/AuthorizedArtifactInventory.ts b/src/application/AuthorizedArtifactInventory.ts index 2bd7369ea2..71e2306a07 100644 --- a/src/application/AuthorizedArtifactInventory.ts +++ b/src/application/AuthorizedArtifactInventory.ts @@ -1,6 +1,5 @@ import { realpath } from "node:fs/promises"; -import type { ArtifactLimits } from "../artifacts/ArtifactReader.js"; import { scanCanonicalArtifactInventory, type ArtifactInventoryOptions, @@ -10,9 +9,8 @@ import { /** Resolve, authorize, and scan one artifact without a second path resolution. */ export const scanAuthorizedArtifactInventory = async ( inputPath: string, - limits: ArtifactLimits, options: ArtifactInventoryOptions = {}, ): Promise => { const path = await realpath(inputPath); - return scanCanonicalArtifactInventory(path, limits, options); + return scanCanonicalArtifactInventory(path, options); }; diff --git a/src/application/JavaScriptArtifactFiles.ts b/src/application/JavaScriptArtifactFiles.ts index ee6dd8c086..a1b1c6b9e2 100644 --- a/src/application/JavaScriptArtifactFiles.ts +++ b/src/application/JavaScriptArtifactFiles.ts @@ -148,56 +148,80 @@ const visitReader = async ( containerSha256: string, context: ReadContext, ): Promise => { - for await (const entry of reader.entries(context.signal)) { - abortIfNeeded(context.signal); - const path = normalizeArtifactPath( - prefix === "" ? entry.path : `${prefix}/${entry.path}`, - { - maxDepth: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxDepth, - maxPathBytes: - JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxPathBytes, - }, - ); - const nestedAsar = isFilesystemAsar(entry, path); - context.registry.add(path, nestedAsar ? "directory" : entry.kind); - if (nestedAsar) { - await visitNestedAsar(entry, path, context); - continue; - } - const expected = context.expected.get(path); - if (expected === undefined) continue; - const selection = context.selections.get(path); - const text = await readTextIfSelected(context, { + const stack: Array<{ + readonly reader: ArtifactReader; + readonly prefix: string; + readonly containerSha256: string; + readonly iterator: AsyncIterator; + readonly owned: boolean; + }> = [ + { reader, - entry, - expected, - selection, - }); - context.files.push({ - path, - container_sha256: containerSha256, - sha256: expected.sha256, - bytes: expected.bytes, - inventory_artifact_id: expected.inventoryArtifactId, - kind: expected.kind, - unpacked: entry.unpacked, - text, - }); - } -}; - -const visitNestedAsar = async ( - entry: ArtifactEntry, - path: string, - context: ReadContext, -): Promise => { - const inventory = expectedContainer(path, context); - context.containers.push(inventory); - const nested = new AsarArtifactReader(entry.adapterKey); + prefix, + containerSha256, + iterator: reader.entries(context.signal)[Symbol.asyncIterator](), + owned: false, + }, + ]; try { - await visitReader(nested, path, inventory.sha256, context); + while (stack.length > 0) { + const frame = stack.at(-1); + if (frame === undefined) break; + const next = await frame.iterator.next(); + if (next.done) { + stack.pop(); + if (frame.owned) await frame.reader.close(); + continue; + } + const entry = next.value; + abortIfNeeded(context.signal); + const path = normalizeArtifactPath( + frame.prefix === "" ? entry.path : `${frame.prefix}/${entry.path}`, + ); + const nestedAsar = isFilesystemAsar(entry, path); + context.registry.add(path, nestedAsar ? "directory" : entry.kind); + if (nestedAsar) { + const inventory = expectedContainer(path, context); + context.containers.push(inventory); + const nested = new AsarArtifactReader(entry.adapterKey); + stack.push({ + reader: nested, + prefix: path, + containerSha256: inventory.sha256, + iterator: nested.entries(context.signal)[Symbol.asyncIterator](), + owned: true, + }); + continue; + } + const expected = context.expected.get(path); + if (expected === undefined) continue; + const selection = context.selections.get(path); + const text = await readTextIfSelected(context, { + reader: frame.reader, + entry, + expected, + selection, + }); + context.files.push({ + path, + container_sha256: frame.containerSha256, + sha256: expected.sha256, + bytes: expected.bytes, + inventory_artifact_id: expected.inventoryArtifactId, + kind: expected.kind, + unpacked: entry.unpacked, + text, + }); + } } finally { - await nested.close(); + await Promise.allSettled( + stack + .filter(({ owned }) => owned) + .map(async ({ reader, iterator }) => { + await iterator.return?.(); + await reader.close(); + }), + ); } }; @@ -333,8 +357,8 @@ const readBounded = async ( if (bytes > maximum) { stream.destroy(); throw new ArtifactReaderFailure( - "limit", - "Selected JavaScript artifact text exceeded its byte limit", + "integrity", + "Artifact entry grew after inventory and exceeded the selected text read size", ); } chunks.push(chunk); diff --git a/src/application/JavaScriptArtifactGraphBuilder.ts b/src/application/JavaScriptArtifactGraphBuilder.ts index 2234c35312..1a81a3df91 100644 --- a/src/application/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/JavaScriptArtifactGraphBuilder.ts @@ -204,16 +204,6 @@ const graphLimitations = ( }; const reconstructionLimits = () => [ - { - name: "max-entries", - value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxEntries, - unit: "items" as const, - }, - { - name: "max-total-artifact-bytes", - value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact.maxTotalBytes, - unit: "bytes" as const, - }, { name: "max-text-files", value: JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.maxTextFiles, diff --git a/src/application/JavaScriptArtifactReconstruction.ts b/src/application/JavaScriptArtifactReconstruction.ts index 2431eee573..3a0cba7149 100644 --- a/src/application/JavaScriptArtifactReconstruction.ts +++ b/src/application/JavaScriptArtifactReconstruction.ts @@ -14,7 +14,6 @@ import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js" import { readJavaScriptArtifactFiles } from "./JavaScriptArtifactFiles.js"; import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js"; import { - artifactLimitsForReconstruction, javascriptArtifactReconstructionInputSchema, type JavaScriptArtifactReconstructionInput, } from "./JavaScriptArtifactReconstructionInput.js"; @@ -59,11 +58,7 @@ export const reconstructJavaScriptArtifact = async ( abortIfNeeded(signal); const path = await realpath(input.input_path); const format = await resolveFormat(path, input); - const snapshot = await scanCanonicalArtifactInventory( - path, - artifactLimitsForReconstruction(), - { signal }, - ); + const snapshot = await scanCanonicalArtifactInventory(path, { signal }); if (snapshot.manifest.root_format !== format) throw new ArtifactReaderFailure( "format", diff --git a/src/application/JavaScriptArtifactReconstructionInput.ts b/src/application/JavaScriptArtifactReconstructionInput.ts index 707189f59d..f43d2ee493 100644 --- a/src/application/JavaScriptArtifactReconstructionInput.ts +++ b/src/application/JavaScriptArtifactReconstructionInput.ts @@ -1,17 +1,7 @@ import { z } from "zod"; -import type { ArtifactLimits } from "../artifacts/ArtifactReader.js"; - /** Resource safeguards for local artifact traversal and static parsing. */ export const JAVASCRIPT_APPLICATION_RESOURCE_LIMITS = { - artifact: { - maxEntries: 8_000, - maxTotalBytes: 512 * 1_024 * 1_024, - maxEntryBytes: 128 * 1_024 * 1_024, - maxCompressionRatio: 1_000, - maxDepth: 64, - maxPathBytes: 4_096, - } satisfies ArtifactLimits, maxTextFiles: 5_000, maxTotalTextBytes: 128 * 1_024 * 1_024, maxTextFileBytes: 8 * 1_024 * 1_024, @@ -29,7 +19,3 @@ export const javascriptArtifactReconstructionInputSchema = z.strictObject({ export type JavaScriptArtifactReconstructionInput = z.infer< typeof javascriptArtifactReconstructionInputSchema >; - -/** Project private resource safeguards into the artifact traversal policy. */ -export const artifactLimitsForReconstruction = (): ArtifactLimits => - JAVASCRIPT_APPLICATION_RESOURCE_LIMITS.artifact; diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index a45f364935..b6fe76f272 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -1,18 +1,11 @@ -import { Buffer } from "node:buffer"; import { posix } from "node:path"; -import { - ArtifactReaderFailure, - type ArtifactLimits, -} from "./ArtifactReader.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u; /** Normalize an untrusted logical archive path without touching filesystem. */ -export const normalizeArtifactPath = ( - input: string, - limits: Pick, -): string => { +export const normalizeArtifactPath = (input: string): string => { if ( input.includes("\0") || input.includes("\\") || @@ -28,8 +21,6 @@ export const normalizeArtifactPath = ( if ( normalized.length === 0 || parts.some((part) => part === "" || part === "." || part === "..") || - parts.length > limits.maxDepth || - Buffer.byteLength(normalized, "utf8") > limits.maxPathBytes || posix.normalize(normalized) !== normalized ) throw new ArtifactReaderFailure("path", "Artifact path is not normalized"); diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 2449e79322..5a39e0d3ce 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -23,10 +23,7 @@ import { } from "../domain/errors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; -import { - ArtifactReaderFailure, - type ArtifactLimits, -} from "./ArtifactReader.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; import { createEvidence } from "../domain/evidence.js"; import { createArtifactInspection } from "../domain/artifactInspection.js"; @@ -119,11 +116,6 @@ class ArtifactClient implements AnalysisClient { inputPath: this.target.sourcePath ?? this.target.path, inputFormat: this.target.format, outputRoot: parsed.output_root, - selection: - "paths" in parsed - ? { paths: parsed.paths } - : { occurrenceIds: parsed.occurrence_ids }, - limits: DEFAULT_ARTIFACT_LIMITS, }, options?.signal, ); @@ -225,37 +217,24 @@ class ArtifactClient implements AnalysisClient { }, options?: ExecutionOptions, ) { - return inventoryArtifactFully( - this.target.sourcePath ?? this.target.path, - DEFAULT_ARTIFACT_LIMITS, - { - ...(options?.signal === undefined ? {} : { signal: options.signal }), - nativeMount: resolveNativeMountPolicy( - parsed.native_mount_approved === true, - this.nativeMountEnabled, - ), - integrity: resolveArtifactIntegrityPolicy( - parsed.integrity_policy === "fail" - ? { mode: "fail" } - : { - mode: parsed.integrity_policy, - }, - this.integrityContinueEnabled, - ), - }, - ); + return inventoryArtifactFully(this.target.sourcePath ?? this.target.path, { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + nativeMount: resolveNativeMountPolicy( + parsed.native_mount_approved === true, + this.nativeMountEnabled, + ), + integrity: resolveArtifactIntegrityPolicy( + parsed.integrity_policy === "fail" + ? { mode: "fail" } + : { + mode: parsed.integrity_policy, + }, + this.integrityContinueEnabled, + ), + }); } } -const DEFAULT_ARTIFACT_LIMITS: ArtifactLimits = { - maxEntries: 10_000, - maxTotalBytes: 1_073_741_824, - maxEntryBytes: 268_435_456, - maxCompressionRatio: 1_000, - maxDepth: 20, - maxPathBytes: 4_096, -}; - const isArtifactOperation = ( operation: AnalysisOperation, ): operation is ArtifactAnalysisOperation => diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 07fddeb829..ecf619d3dd 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -31,16 +31,6 @@ export interface ArtifactReader { close(): Promise; } -/** Hard traversal limits shared by every reader and extraction operation. */ -export interface ArtifactLimits { - readonly maxEntries: number; - readonly maxTotalBytes: number; - readonly maxEntryBytes: number; - readonly maxCompressionRatio: number; - readonly maxDepth: number; - readonly maxPathBytes: number; -} - /** Typed adapter failure translated at provider boundary. */ export class ArtifactReaderFailure extends Error { constructor( @@ -48,7 +38,6 @@ export class ArtifactReaderFailure extends Error { | "cancelled" | "format" | "integrity" - | "limit" | "path" | "policy" | "unavailable", diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index 279c353aff..f45eb20a41 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -16,10 +16,7 @@ import { ArtifactPathRegistry, normalizeArtifactPath, } from "./ArtifactPaths.js"; -import { - ArtifactReaderFailure, - type ArtifactLimits, -} from "./ArtifactReader.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; /** One file durably written to an operation-owned output tree. */ export interface SafeOutputFile { @@ -40,24 +37,18 @@ export type SafeOutputCleanup = /** Symlink-resistant materialization in an exclusively owned, initially absent tree. */ export class SafeOutputTree { readonly #registry = new ArtifactPathRegistry(); - readonly #limits: ArtifactLimits; readonly #outputRoot: string; - #totalBytes = 0; #published = false; #cleanup: SafeOutputCleanup = { status: "not-required", }; - private constructor(outputRoot: string, limits: ArtifactLimits) { + private constructor(outputRoot: string) { this.#outputRoot = outputRoot; - this.#limits = limits; } /** Exclusively create the absent destination as this operation's owned tree. */ - static async create( - outputRoot: string, - limits: ArtifactLimits, - ): Promise { + static async create(outputRoot: string): Promise { if (!isAbsolute(outputRoot)) throw new ArtifactReaderFailure( "path", @@ -96,7 +87,7 @@ export class SafeOutputTree { } finally { await stagingHandle.close(); } - return new SafeOutputTree(canonicalOutput, limits); + return new SafeOutputTree(canonicalOutput); } catch (cause: unknown) { await rm(canonicalOutput, { recursive: true, force: true }); throw cause; @@ -111,7 +102,7 @@ export class SafeOutputTree { return structuredClone(this.#cleanup); } - /** Stream one selected regular file with exact byte and digest verification. */ + /** Stream one regular file with exact byte and digest verification. */ async write( relativePath: string, source: Readable, @@ -119,7 +110,7 @@ export class SafeOutputTree { signal?: AbortSignal, ): Promise { this.#assertWritable(); - const path = normalizeArtifactPath(relativePath, this.#limits); + const path = normalizeArtifactPath(relativePath); this.#registry.add(path, "file"); const destination = await this.#prepareParent(path); const handle = await open( @@ -143,16 +134,6 @@ export class SafeOutputTree { abortIfNeeded(signal); const chunk = streamChunkToBuffer(raw); bytes += chunk.length; - if (bytes > this.#limits.maxEntryBytes) - throw new ArtifactReaderFailure( - "limit", - `Observed entry bytes exceed limit: ${path}`, - ); - if (this.#totalBytes + bytes > this.#limits.maxTotalBytes) - throw new ArtifactReaderFailure( - "limit", - "Observed extraction bytes exceed cumulative limit", - ); hash.update(chunk); await writeAll(handle, chunk); } @@ -170,7 +151,6 @@ export class SafeOutputTree { "integrity", `Durable readback verification failed: ${path}`, ); - this.#totalBytes += bytes; return { relativePath: path, sha256, bytesWritten: bytes }; } catch (cause: unknown) { await handle.close().catch(() => undefined); diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index 6d3ab5085a..70724532e9 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -17,41 +17,18 @@ export const registerArtifactCommands = ( const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => { cli.command(CLI_COMMANDS.extractArtifact, { - description: "Extract explicitly selected artifact occurrences safely", + description: "Extract all regular artifact contents safely", args: z.object({ path: z.string().describe("Application or package path"), }), - options: z - .object({ - paths: z - .array(z.string().min(1)) - .min(1) - .optional() - .describe("Logical artifact paths selected for extraction"), - occurrenceIds: z - .array(z.string().regex(/^occ_[a-f0-9]{64}$/u)) - .min(1) - .optional() - .describe("Alternate exact occurrence IDs selected for extraction"), - }) - .refine( - ({ paths, occurrenceIds }) => - (paths === undefined) !== (occurrenceIds === undefined), - "Provide paths or occurrence IDs", - ), - alias: { occurrenceIds: "occurrence-ids" }, - run: ({ args, options }) => + options: z.object({}), + run: ({ args }) => logCliCommand(logger, "extract-artifact", () => runProviderAnalysis( args.path, "extract_artifact", { output_root: createArtifactExtractionDestination(), - ...(options.paths !== undefined - ? { paths: options.paths } - : options.occurrenceIds !== undefined - ? { occurrence_ids: options.occurrenceIds } - : {}), }, logger, ), diff --git a/src/contracts/artifactComparisonExample.ts b/src/contracts/artifactComparisonExample.ts index 229731999a..31a6d6b9d2 100644 --- a/src/contracts/artifactComparisonExample.ts +++ b/src/contracts/artifactComparisonExample.ts @@ -47,14 +47,6 @@ const inventory = (digit: string) => { }, ], edges: [], - limits: { - max_entries: 100, - max_total_bytes: 1024, - max_entry_bytes: 1024, - max_compression_ratio: 10, - max_depth: 10, - max_path_bytes: 256, - }, provenance: [], limitations: [], }); diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 3abf433979..4361aa1bc4 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -26,36 +26,18 @@ export const artifactInventoryInputSchema = z.union([ z.object({ ...artifactInventoryFacts, ...integrityInput.continue }), ]); -/** Exact caller boundary for approved artifact extraction. */ -const artifactOccurrenceSelectionSchema = z.strictObject({ - occurrence_ids: z - .array(z.string().regex(/^occ_[a-f0-9]{64}$/u)) - .min(1) - .describe("Alternate exact occurrence IDs from inspect_artifact"), -}); -const artifactPathSelectionSchema = z.strictObject({ - paths: z - .array(z.string().min(1)) - .min(1) - .describe("Exact logical_path values from inspect_artifact"), -}); -export const artifactExtractionInputSchema = z.union([ - artifactPathSelectionSchema, - artifactOccurrenceSelectionSchema, -]); +/** Extraction needs no selector: it materializes every regular child file. */ +export const artifactExtractionInputSchema = z.strictObject({}); /** Provider input after the local permission boundary chooses its destination. */ -export const artifactExtractionExecutionSchema = z.union([ - artifactPathSelectionSchema.extend({ output_root: z.string().min(1) }), - artifactOccurrenceSelectionSchema.extend({ output_root: z.string().min(1) }), -]); +export const artifactExtractionExecutionSchema = z.strictObject({ + output_root: z.string().min(1), +}); const exampleInputSchema = z.record(z.string(), jsonValueSchema); const examples: Readonly>>> = { inspect_artifact: {}, - extract_artifact: { - paths: ["Contents/Info.plist"], - }, + extract_artifact: {}, }; const artifact = < @@ -87,12 +69,12 @@ const artifact = < export const ARTIFACT_TOOL_CONTRACTS = [ artifact( "inspect_artifact", - "Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. It does not extract files; use extract_artifact for selected occurrences.", + "Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. It does not extract files; use extract_artifact to materialize its regular contents.", artifactInventoryInputSchema, ), artifact( "extract_artifact", - "Extract selected regular files by logical paths from inspect_artifact (or by occurrence IDs) into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.", + "Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.", artifactExtractionInputSchema, ), ] as const satisfies readonly ToolContract[]; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 7cbbfda901..d4db4629b5 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -10,7 +10,6 @@ const contradictionIdSchema = z.string().regex(/^ic_[a-f0-9]{64}$/u); const boundedRelativePathSchema = z .string() .min(1) - .max(4_096) .refine( (path) => !path.startsWith("/") && @@ -61,16 +60,6 @@ const artifactFormatSchema = z.enum([ "unknown", ]); -/** Exact bounded policy applied while discovering or extracting artifacts. */ -const artifactTraversalLimitsSchema = z.object({ - max_entries: z.number().int().min(1).max(1_000_000), - max_total_bytes: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), - max_entry_bytes: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), - max_compression_ratio: z.number().min(1).max(100_000), - max_depth: z.number().int().min(0).max(100), - max_path_bytes: z.number().int().min(1).max(65_535), -}); - /** File byte interval for an artifact derived without writing it to disk. */ const artifactByteRangeSchema = z.object({ offset: z.number().int().min(0), @@ -125,12 +114,7 @@ const artifactOccurrenceSchema = z.object({ .nullable(), executable: z.boolean(), encrypted: z.boolean(), - hash_status: z.enum([ - "verified", - "mismatched", - "not-hashed-limit", - "unavailable", - ]), + hash_status: z.enum(["verified", "mismatched", "not-hashed", "unavailable"]), source_location: artifactByteRangeSchema.nullable(), limitations: z.array(z.string()), }); @@ -188,7 +172,6 @@ export const artifactInventoryResultSchema = z.object({ nodes: z.array(artifactNodeSchema), occurrences: z.array(artifactOccurrenceSchema), edges: z.array(artifactEdgeSchema), - limits: artifactTraversalLimitsSchema, provenance: z.array(artifactCommandSchema), integrity_contradictions: z.array(integrityContradictionSchema).default([]), limitations: z.array(z.string()), @@ -224,7 +207,6 @@ export const artifactExtractionResultSchema = z.object({ verified: z.boolean(), residual_paths: z.array(boundedRelativePathSchema), }), - limits: artifactTraversalLimitsSchema, provenance: z.array(artifactCommandSchema), limitations: z.array(z.string()), }); diff --git a/src/server/createServer.ts b/src/server/createServer.ts index fd706c5b99..e726b852b3 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -55,7 +55,7 @@ import { } from "./sessionToolPolicies.js"; const TARGET_FREE_INSTRUCTIONS = - "Start with binary_session to see the active target and available providers. For an archive or application package, call open_binary(path), then inspect_artifact for the complete graph and findings; use extract_artifact only for selected files. For standalone JavaScript or ASAR analysis, use analyze_javascript_application. For managed PE/CLI files, use inspect_managed_artifact. For native binaries, open the target and start with binary_overview. For a live page or runtime, discover targets with list_browser_targets, list_electron_targets, or list_javascript_runtime_targets before inspection; scenario capture tools are for controlled interactions."; + "Start with binary_session to see the active target and available providers. For an archive or application package, call open_binary(path), then inspect_artifact for the complete graph and findings; use extract_artifact to materialize its regular contents when needed. For standalone JavaScript or ASAR analysis, use analyze_javascript_application. For managed PE/CLI files, use inspect_managed_artifact. For native binaries, open the target and start with binary_overview. For a live page or runtime, discover targets with list_browser_targets, list_electron_targets, or list_javascript_runtime_targets before inspection; scenario capture tools are for controlled interactions."; const ACTIVE_TARGET_INSTRUCTIONS = "REA analyzes the active reverse-engineering target. For native binaries, call binary_overview for target metadata and inventory counts, then search_procedures or list_procedures to choose a function and analyze_function with its returned name or address for a complete dossier. Use inspect_native_api when you need to reconstruct a native API boundary, and focused procedure tools when you need only one facet such as assembly, callers, or references."; diff --git a/tests/boundary/filesystem/artifactInventorySnapshot.test.ts b/tests/boundary/filesystem/artifactInventorySnapshot.test.ts index 82798bc087..5feec925f6 100644 --- a/tests/boundary/filesystem/artifactInventorySnapshot.test.ts +++ b/tests/boundary/filesystem/artifactInventorySnapshot.test.ts @@ -7,21 +7,12 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; -const LIMITS = { - maxEntries: 10, - maxTotalBytes: 1_024, - maxEntryBytes: 512, - maxCompressionRatio: 10, - maxDepth: 8, - maxPathBytes: 256, -} as const; - describe("artifact inventory snapshot", () => { it("retains the complete scan after the source directory changes", async () => { const root = await createTestTempDirectory("rea-inventory-snapshot-"); await writeFile(join(root, "a.txt"), "a"); await writeFile(join(root, "b.txt"), "b"); - const snapshot = await scanArtifactInventory(root, LIMITS); + const snapshot = await scanArtifactInventory(root); await rm(join(root, "b.txt")); expect(snapshot.occurrences.map(({ logical_path: path }) => path)).toEqual([ diff --git a/tests/boundary/filesystem/authorizedArtifactInventory.test.ts b/tests/boundary/filesystem/authorizedArtifactInventory.test.ts index 0855ca7c65..2598a83a67 100644 --- a/tests/boundary/filesystem/authorizedArtifactInventory.test.ts +++ b/tests/boundary/filesystem/authorizedArtifactInventory.test.ts @@ -7,21 +7,12 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { scanAuthorizedArtifactInventory } from "../../../src/application/AuthorizedArtifactInventory.js"; -const LIMITS = { - maxEntries: 100, - maxTotalBytes: 1024 * 1024, - maxEntryBytes: 1024 * 1024, - maxCompressionRatio: 100, - maxDepth: 10, - maxPathBytes: 1024, -} as const; - describe("authorized artifact inventory", () => { it("scans a local artifact without configured input roots", async () => { const root = await createTestTempDirectory("rea-artifact-roots-"); const path = join(root, "artifact.js"); await writeFile(path, "export const value = 1;\n"); - const inventory = await scanAuthorizedArtifactInventory(path, LIMITS); + const inventory = await scanAuthorizedArtifactInventory(path); expect(inventory.manifest.root_format).toBe("javascript-bundle"); }); }); diff --git a/tests/boundary/filesystem/javascriptArtifactReconstructionLimits.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstructionLimits.test.ts index 727b2970fe..1984d850b9 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstructionLimits.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstructionLimits.test.ts @@ -8,10 +8,6 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readJavaScriptArtifactFiles } from "../../../src/application/JavaScriptArtifactFiles.js"; import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; -import { - artifactLimitsForReconstruction, - javascriptArtifactReconstructionInputSchema, -} from "../../../src/application/JavaScriptArtifactReconstructionInput.js"; import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; import { type ArtifactEntry, @@ -163,10 +159,7 @@ it("retains every repeated content observation and containment path", async () = it("rejects traversal from a production reader seam and malformed ASAR containers", async () => { const root = await fixtureDirectory(); - const snapshot = await scanArtifactInventory( - root, - artifactLimitsForReconstruction(), - ); + const snapshot = await scanArtifactInventory(root); await expect( readJavaScriptArtifactFiles(new TraversalReader(), snapshot), ).rejects.toMatchObject({ reason: "path" }); @@ -194,12 +187,6 @@ it("preserves cancellation and explicit format diagnostics", async () => { reason: "format", message: expect.stringContaining(root), }); - expect( - javascriptArtifactReconstructionInputSchema.safeParse({ - input_path: root, - limits: { max_entries: 100_000 }, - }).success, - ).toBe(false); }); class TraversalReader implements ArtifactReader { diff --git a/tests/boundary/filesystem/safeOutputTree.test.ts b/tests/boundary/filesystem/safeOutputTree.test.ts index f0302cd5f3..541b073e36 100644 --- a/tests/boundary/filesystem/safeOutputTree.test.ts +++ b/tests/boundary/filesystem/safeOutputTree.test.ts @@ -9,22 +9,13 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js"; -const LIMITS = { - maxEntries: 10, - maxTotalBytes: 1_024, - maxEntryBytes: 512, - maxCompressionRatio: 10, - maxDepth: 8, - maxPathBytes: 256, -} as const; - describe("safe artifact output tree", () => { it.skipIf(process.platform !== "linux")( "closes the parent descriptor when the output disappears before commit", async () => { const parent = await createTestTempDirectory("rea-safe-output-"); const output = join(parent, "published"); - const tree = await SafeOutputTree.create(output, LIMITS); + const tree = await SafeOutputTree.create(output); await rm(output, { recursive: true }); await expect(tree.commit()).rejects.toThrow(); const targets = await Promise.all( @@ -42,7 +33,7 @@ describe("safe artifact output tree", () => { it("removes only its owned tree after digest failure and proves absence", async () => { const parent = await createTestTempDirectory("rea-safe-output-"); const output = join(parent, "published"); - const tree = await SafeOutputTree.create(output, LIMITS); + const tree = await SafeOutputTree.create(output); await expect( tree.write( "nested/file.txt", @@ -61,7 +52,7 @@ describe("safe artifact output tree", () => { it("publishes files while building and preserves them after sealing", async () => { const parent = await createTestTempDirectory("rea-safe-output-"); const output = join(parent, "published"); - const tree = await SafeOutputTree.create(output, LIMITS); + const tree = await SafeOutputTree.create(output); const bytes = Buffer.from("visible before seal"); const digest = createHash("sha256").update(bytes).digest("hex"); @@ -79,7 +70,7 @@ describe("safe artifact output tree", () => { it("returns detached cleanup reports", async () => { const parent = await createTestTempDirectory("rea-safe-output-"); - const tree = await SafeOutputTree.create(join(parent, "published"), LIMITS); + const tree = await SafeOutputTree.create(join(parent, "published")); const cleanup = await tree.rollback(); if (cleanup.status !== "complete") throw new Error("expected cleanup"); (cleanup.residualPaths as unknown as string[]).push("/forged/path"); diff --git a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts index 66f9033c5f..5247ffa8cb 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts @@ -50,11 +50,11 @@ describe("artifact archive safety", () => { expect(result.nodes.map(({ kind }) => kind)).toEqual( expect.arrayContaining(["javascript", "native-addon"]), ); - const selected = result.occurrences.find( - ({ logical_path: logicalPath }) => logicalPath === "Assets/main.js", - ); - expect(selected).toBeDefined(); - if (selected === undefined) return; + expect( + result.occurrences.some( + ({ logical_path: logicalPath }) => logicalPath === "Assets/main.js", + ), + ).toBe(true); const output = join(root, "output"); const extracted = await new ArtifactProvider() .createClient(parsed.value) @@ -62,7 +62,6 @@ describe("artifact archive safety", () => { "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output, - occurrence_ids: [selected.occurrence_id], }), ); expect(extracted.ok).toBe(true); @@ -80,10 +79,17 @@ describe("artifact archive safety", () => { }, ); - it("rejects unsafe paths, collisions, and default compression bombs", async () => { + it("rejects unsafe paths and collisions", async () => { + expect( + normalizeArtifactPath( + Array.from({ length: 24 }, (_, index) => `level${String(index)}`).join( + "/", + ) + "/entry.js", + ).split("/"), + ).toHaveLength(25); let unsafePathError: unknown; try { - normalizeArtifactPath("../escape", limits()); + normalizeArtifactPath("../escape"); } catch (error: unknown) { unsafePathError = error; } @@ -105,18 +111,6 @@ describe("artifact archive safety", () => { reason: "path", message: "Artifact path collision: a.js", }); - - const root = await createTestTempDirectory("rea-bomb-"); - const zipPath = join(root, "bomb.zip"); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add("zeros", new TextReader("0".repeat(10_000_000))); - await writeFile(zipPath, await writer.close()); - const client = new ArtifactProvider().createClient(target(zipPath, "zip")); - const result = await client.execute("inventory_artifact", {}); - expect(result).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "limit" }, - }); }); }); const inventory = async (targetValue: BinaryTarget) => { @@ -126,16 +120,3 @@ const inventory = async (targetValue: BinaryTarget) => { if (!result.ok) throw result.error; return artifactInventoryResultSchema.parse(result.value.result); }; - -const target = ( - path: string, - format: Extract["format"] | "directory", -): BinaryTarget => ({ - path, - sourcePath: path, - sha256: "0".repeat(64), - kind: "archive", - format: format === "directory" ? "asar" : format, -}); - -const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 }); diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 838cb7ff99..a7e8aac518 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -14,7 +14,7 @@ import { import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js"; describe("artifact extraction", () => { - it("extracts only selected occurrences through an exclusively owned output tree", async () => { + it("extracts all regular occurrences through an exclusively owned output tree", async () => { const root = await createTestTempDirectory("rea-extract-"); const source = join(root, "source"); await mkdir(join(source, "assets"), { recursive: true }); @@ -22,11 +22,11 @@ describe("artifact extraction", () => { await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); const targetValue = target(source, "directory"); const graph = await inventory(targetValue); - const selected = graph.occurrences.find( - ({ logical_path: path }) => path === "assets/selected.js", - ); - expect(selected).toBeDefined(); - if (selected === undefined) return; + expect( + graph.occurrences.some( + ({ logical_path }) => logical_path === "assets/selected.js", + ), + ).toBe(true); const cancelledOutput = join(root, "cancelled-output"); const controller = new AbortController(); controller.abort(); @@ -36,7 +36,6 @@ describe("artifact extraction", () => { "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: cancelledOutput, - occurrence_ids: [selected.occurrence_id], }), { signal: controller.signal }, ); @@ -52,7 +51,6 @@ describe("artifact extraction", () => { "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output, - paths: ["assets/selected.js"], }), ); expect(result.ok).toBe(true); @@ -71,25 +69,9 @@ describe("artifact extraction", () => { expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe( "selected();\n", ); - await expect( - access(join(output, "assets", "ignored.js")), - ).rejects.toThrow(); - - const traversalOutput = join(root, "traversal-output"); - const traversal = await new ArtifactProvider() - .createClient(targetValue) - .execute( - "extract_artifact", - artifactExtractionExecutionSchema.parse({ - output_root: traversalOutput, - paths: ["../source/assets/ignored.js"], - }), - ); - expect(traversal).toMatchObject({ - ok: false, - error: { _tag: "ArtifactOperationError", reason: "path" }, - }); - await expect(access(traversalOutput)).rejects.toThrow(); + expect(await readFile(join(output, "assets", "ignored.js"), "utf8")).toBe( + "ignored();\n", + ); const relocatedOutput = join(root, "relocated-output"); const relocated = await new ArtifactProvider() @@ -98,7 +80,6 @@ describe("artifact extraction", () => { "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: relocatedOutput, - occurrence_ids: [selected.occurrence_id], }), ); expect(relocated.ok).toBe(true); @@ -114,7 +95,6 @@ describe("artifact extraction", () => { "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output, - occurrence_ids: [selected.occurrence_id], }), ); expect(second).toMatchObject({ diff --git a/tests/composition/analysis-sessions/artifactComparison.test.ts b/tests/composition/analysis-sessions/artifactComparison.test.ts index cb9063693b..750ded2471 100644 --- a/tests/composition/analysis-sessions/artifactComparison.test.ts +++ b/tests/composition/analysis-sessions/artifactComparison.test.ts @@ -14,14 +14,6 @@ import { import { createEvidence } from "../../../src/domain/evidence.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; -const LIMITS = { - maxEntries: 1_000, - maxTotalBytes: 1024 * 1024, - maxEntryBytes: 1024 * 1024, - maxCompressionRatio: 100, - maxDepth: 20, - maxPathBytes: 1024, -} as const; const PROVIDER = { id: "rea-artifact-graph", name: "REA artifact graph", @@ -29,7 +21,7 @@ const PROVIDER = { } as const; const observe = async (path: string) => { - const inventory = await inventoryArtifact(path, LIMITS); + const inventory = await inventoryArtifact(path); return createEvidence( { path, diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index 1e2165e83e..3fbdee3889 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -164,14 +164,7 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent", await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); await rm(join(`${archive}.unpacked`, "native", "addon.node")); - const snapshot = await scanArtifactInventory(archive, { - maxEntries: 8_000, - maxTotalBytes: 512 * 1024 * 1024, - maxEntryBytes: 128 * 1024 * 1024, - maxCompressionRatio: 1_000, - maxDepth: 64, - maxPathBytes: 4_096, - }); + const snapshot = await scanArtifactInventory(archive); const missingNative = snapshot.occurrences.find( ({ logical_path }) => logical_path === "native/addon.node", );