diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 43baab1c0..769fc60c6 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -4,9 +4,9 @@ When an installation directory is supplied and its runtime is unknown, use `inspect_desktop_application` with its absolute path. Default MCP summary -delivery retains complete Evidence. Read `files`, `candidates`, `components` or +delivery retains complete Evidence. Read `files`, `omissions`, `candidates`, `components` or `next_probes` with `inspect_analysis_view` kind `desktop-application`, selecting -exact paths or families and only needed fields and pages. Preserve source +exact paths, batched file/omission IDs or families and only needed fields and pages. Preserve source coverage, read-range identity and unknowns; directory snapshot identity is not a digest of every file's contents. Component path markers do not establish runtime presence. For compact CLI output, use diff --git a/docs/desktop-applications.md b/docs/desktop-applications.md index e4dcb1770..020191439 100644 --- a/docs/desktop-applications.md +++ b/docs/desktop-applications.md @@ -39,8 +39,12 @@ Read exact files in one request: } ``` -Collections are `files`, `candidates`, `components` and `next_probes`. -`candidates` accepts exact `families`; `files` accepts exact recorded `paths`. +Collections are `files`, `omissions`, `candidates`, `components` and `next_probes`. +Files and omissions accept exact `paths` and batched `file_ids` / `omission_ids`. +When both are supplied, all filters apply; an empty selection returns no rows. +Omission records retain the observed path, kind and stable `omission_id`; their +payloads are not read, and other special-entry metadata remains unavailable. +`candidates` accepts exact `families`. Omitted selectors match all retained rows; an empty selector matches none. Every collection supports `fields`, `offset` and `limit`; its stable row ID is always included. Selection changes delivery, not source inspection. Pages keep @@ -87,7 +91,9 @@ and SHA-256 digests; typed facts expose CLI RVAs, metadata/pack versions and assembly tokens. Directory and symlink identities unavailable from traversal remain null. -The directory's `snapshot_sha256` commits to examined metadata and range digests. +The directory's `snapshot_sha256` commits to retained paths, omission kinds, +available metadata and range digests. Omission records share the entry and +representation budgets and can be fetched independently through their view. **It is not a digest of all file contents.** These are sequential stable entry observations, not an atomic directory snapshot. Unread bytes remain unknown. diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index fadae75f8..db504f032 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -4,6 +4,7 @@ import type { Readable } from "node:stream"; import type { ArtifactCommand, ArtifactInventoryCoverage, + ArtifactTraversalOmissionKind, } from "../domain/artifactGraph.js"; import type { ZipPackageFormat } from "../domain/zipPackageFormat.js"; import type { @@ -106,13 +107,7 @@ export const sameArtifactEntry = ( /** One entry or subtree deliberately excluded from a traversal. */ export interface ArtifactTraversalOmission { readonly path: string; - readonly kind: - | "fifo" - | "socket" - | "block-device" - | "character-device" - | "unsupported" - | "directory-descendants"; + readonly kind: ArtifactTraversalOmissionKind; } /** Facts from an exhausted traversal, separate from entry content certainty. */ diff --git a/src/artifacts/DesktopApplicationReader.ts b/src/artifacts/DesktopApplicationReader.ts index e335d40da..31948fe93 100644 --- a/src/artifacts/DesktopApplicationReader.ts +++ b/src/artifacts/DesktopApplicationReader.ts @@ -25,6 +25,7 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { DesktopApplication, DesktopFile, + DesktopOmission, InspectDesktopApplicationInput, } from "../domain/desktopApplication.js"; import { projectDesktopApplication } from "../domain/desktopApplicationProjection.js"; @@ -36,6 +37,17 @@ import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js" const RETAINED_METADATA_BYTES = 32 * 1024 * 1024; +/** Stop traversal without discarding the preceding bounded observations. */ +class DesktopRepresentationLimit extends ArtifactReaderFailure { + constructor() { + super( + "limit", + "Directory metadata reached the 32 MiB representation budget; remaining entry and omission facts are unavailable.", + { boundary: "memory" }, + ); + } +} + /** Existing artifact ownership with bounded metadata, serial probes and no payload cache. */ export class DesktopApplicationReader implements DesktopApplicationPort { readonly #resources = new ArtifactResourceScope(); @@ -173,6 +185,7 @@ const scanDirectory = async ( }; const namespaceId = `dsr_${digestCanonicalValue(root, "Admitted desktop directory namespace")}`; const files: DesktopFile[] = []; + const omissions: DesktopOmission[] = []; const unityWitnessRoots = new Set(); const budget: DesktopReadBudget = { bytes: 0, @@ -180,6 +193,11 @@ const scanDirectory = async ( retainedBytes: 1024 + (input.path.length + root.canonical_path.length) * 24, maximumRetainedBytes: RETAINED_METADATA_BYTES, }; + const reserve = (bytes: number) => { + if (bytes > budget.maximumRetainedBytes - budget.retainedBytes) + throw new DesktopRepresentationLimit(); + budget.retainedBytes += bytes; + }; let entries = 0; let probed = 0; let symlinks = 0; @@ -188,7 +206,8 @@ const scanDirectory = async ( let failure: { readonly cause: unknown } | undefined; const limitations = [ "Runtime candidates and component markers do not establish loaded code, execution, or build correspondence.", - "The directory SHA-256 commits only to examined paths, filesystem metadata and read-range digests, not to every file's contents. Unread bytes are unknown.", + "The directory SHA-256 commits only to retained paths, omission kinds, available filesystem metadata and read-range digests, not to every file's contents. Unread bytes are unknown.", + "Special-entry payloads are not read. Their paths and kinds are retained; other special-entry metadata is unavailable in this report.", "Child symlinks are not followed. Executable section-embedded PCKs without a trailer and encrypted contents remain unexamined.", "This is a sequence of stable entry observations, not an atomic snapshot of the entire directory.", "Unity reference decoding stops after a witness in each Data/Managed directory; other assemblies' references are unexamined. Header facts remain per-file.", @@ -196,10 +215,23 @@ const scanDirectory = async ( try { for await (const entry of reader.entries(signal, { maximumEntries: input.max_entries, - onOmission: () => { - special++; - entries++; + onOmission: (omission) => { + if (omission.kind !== "directory-descendants") { + special++; + entries++; + } partial = true; + reserve(512 + omission.path.length * 24); + const semantic = { + root_namespace_id: namespaceId, + path: omission.path, + kind: omission.kind, + }; + omissions.push({ + omission_id: `dso_${digestCanonicalValue(semantic, "Desktop traversal omission")}`, + path: omission.path, + kind: omission.kind, + }); }, })) { entries++; @@ -207,14 +239,7 @@ const scanDirectory = async ( // Reserve a conservative representation estimate before retaining path-dependent objects. const reservation = 1024 + (entry.path.length + entry.adapterKey.length) * 24; - if (reservation > RETAINED_METADATA_BYTES - budget.retainedBytes) { - partial = true; - limitations.push( - "Directory metadata reached the 32 MiB representation budget; remaining entries are unknown.", - ); - break; - } - budget.retainedBytes += reservation; + reserve(reservation); const row = fileRow(entry, namespaceId); files.push(row); if (entry.kind === "symlink") { @@ -287,6 +312,8 @@ const scanDirectory = async ( limitations.push( "Directory traversal reached max_entries; unvisited paths and candidate families remain unknown.", ); + } else if (cause instanceof DesktopRepresentationLimit) { + limitations.push(cause.message); } else { failure = { cause }; limitations.push( @@ -295,6 +322,17 @@ const scanDirectory = async ( } } files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + omissions.sort((a, b) => + a.path < b.path + ? -1 + : a.path > b.path + ? 1 + : a.kind < b.kind + ? -1 + : a.kind > b.kind + ? 1 + : 0, + ); const coverage: DesktopApplication["coverage"] = { status: partial ? "partial" : "complete-within-scan", entries_examined: entries, @@ -308,7 +346,7 @@ const scanDirectory = async ( }, }; const snapshot = digestCanonicalValue( - { root, files, coverage }, + { root, files, omissions, coverage }, "Examined desktop directory facts", ); const report: DesktopApplication = { @@ -320,6 +358,7 @@ const scanDirectory = async ( identity_scope: "examined-metadata-and-read-ranges", }, files, + omissions, ...projectDesktopApplication(files), coverage, limitations, diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 8cea01676..6fe55a1e2 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Desktop directory views use kind desktop-application and collection files/candidates/components/next_probes, with fields and pagination; files accept exact paths and candidates accept families. Source coverage and stable row IDs remain available. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/domain/analysisView/desktopApplicationView.ts b/src/domain/analysisView/desktopApplicationView.ts index bbe1ef780..3674d82f5 100644 --- a/src/domain/analysisView/desktopApplicationView.ts +++ b/src/domain/analysisView/desktopApplicationView.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { desktopApplicationSchema, desktopFileSchema, + desktopOmissionSchema, desktopCandidateSchema, desktopComponentSchema, desktopProbeSchema, @@ -33,6 +34,7 @@ export const desktopViewRequestSchema = z.discriminatedUnion("collection", [ ...page, collection: z.literal("files"), paths: z.array(z.string()).optional(), + file_ids: z.array(desktopFileSchema.shape.file_id).optional(), fields: z.array(desktopFileSchema.keyof()).optional(), }), z.strictObject({ @@ -41,6 +43,13 @@ export const desktopViewRequestSchema = z.discriminatedUnion("collection", [ families: z.array(desktopFamilySchema).optional(), fields: z.array(desktopCandidateSchema.keyof()).optional(), }), + z.strictObject({ + ...page, + collection: z.literal("omissions"), + paths: z.array(z.string()).optional(), + omission_ids: z.array(desktopOmissionSchema.shape.omission_id).optional(), + fields: z.array(desktopOmissionSchema.keyof()).optional(), + }), z.strictObject({ ...page, collection: z.literal("components"), @@ -55,6 +64,7 @@ export const desktopViewRequestSchema = z.discriminatedUnion("collection", [ export const desktopViewItemsSchema = z.array( z.union([ desktopFileSchema.partial(), + desktopOmissionSchema.partial(), desktopCandidateSchema.partial(), desktopComponentSchema.partial(), desktopProbeSchema.partial(), @@ -65,6 +75,7 @@ export const desktopSummarySchema = z.strictObject({ source_coverage: desktopCoverageSchema, available_counts: z.strictObject({ files: z.number().int().nonnegative(), + omissions: z.number().int().nonnegative(), candidates: z.number().int().nonnegative(), components: z.number().int().nonnegative(), next_probes: z.number().int().nonnegative(), @@ -90,6 +101,9 @@ export const desktopSummarySchema = z.strictObject({ ), }); +type DesktopCollection = z.infer["collection"]; +type DesktopViewRow = DesktopApplication[DesktopCollection][number]; + // Only immutable authenticated parent objects are reused. They are not live filesystem caches. const validatedParents = new WeakSet(); export const projectDesktopApplicationView = ( @@ -199,6 +213,7 @@ export const projectDesktopApplicationView = ( source_coverage: report.coverage, available_counts: { files: report.files.length, + omissions: report.omissions.length, candidates: report.candidates.length, components: report.components.length, next_probes: report.next_probes.length, @@ -213,10 +228,18 @@ export const projectDesktopApplicationView = ( ), }); } - let rows = report[view.collection]; - if (view.collection === "files" && view.paths !== undefined) { - const selected = new Set(view.paths); - rows = report.files.filter((file) => selected.has(file.path)); + let rows: readonly DesktopViewRow[] = report[view.collection]; + if (view.collection === "files" || view.collection === "omissions") { + const paths = view.paths === undefined ? undefined : new Set(view.paths); + const ids = view.collection === "files" ? view.file_ids : view.omission_ids; + const selected = ids === undefined ? undefined : new Set(ids); + if (paths !== undefined || selected !== undefined) + rows = report[view.collection].filter( + (row) => + (paths === undefined || paths.has(row.path)) && + (selected === undefined || + selected.has("file_id" in row ? row.file_id : row.omission_id)), + ); } if (view.collection === "candidates" && view.families !== undefined) { const selected = new Set(view.families); @@ -224,6 +247,7 @@ export const projectDesktopApplicationView = ( } const idField = { files: "file_id", + omissions: "omission_id", candidates: "candidate_id", components: "component_id", next_probes: "probe_id", diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index da27ad1f7..c4e939741 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -1,6 +1,19 @@ import { z } from "zod"; import { prefixedDigestSchema } from "./../domain/digests.js"; +/** Entry kinds or subtrees deliberately excluded by an artifact traversal. */ +export const artifactTraversalOmissionKindSchema = z.enum([ + "fifo", + "socket", + "block-device", + "character-device", + "unsupported", + "directory-descendants", +]); +export type ArtifactTraversalOmissionKind = z.infer< + typeof artifactTraversalOmissionKindSchema +>; + const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); const artifactIdSchema = prefixedDigestSchema("art"); const occurrenceIdSchema = prefixedDigestSchema("occ"); diff --git a/src/domain/desktopApplication.ts b/src/domain/desktopApplication.ts index cf79470a4..8437e9109 100644 --- a/src/domain/desktopApplication.ts +++ b/src/domain/desktopApplication.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { digestSchema, prefixedDigestSchema } from "./digests.js"; import { localPathStringSchema } from "./localPath.js"; +import { artifactTraversalOmissionKindSchema } from "./artifactGraph.js"; export const desktopFamilySchema = z.enum([ "dotnet", @@ -121,6 +122,12 @@ export const desktopFileSchema = z.strictObject({ issues: z.array(z.string()), }); export type DesktopFile = z.infer; +export const desktopOmissionSchema = z.strictObject({ + omission_id: prefixedDigestSchema("dso"), + path: z.string(), + kind: artifactTraversalOmissionKindSchema, +}); +export type DesktopOmission = z.infer; export const desktopCandidateSchema = z.strictObject({ candidate_id: prefixedDigestSchema("dsc"), family: desktopFamilySchema, @@ -173,6 +180,7 @@ export const desktopApplicationSchema = z.strictObject({ identity_scope: z.literal("examined-metadata-and-read-ranges"), }), files: z.array(desktopFileSchema), + omissions: z.array(desktopOmissionSchema), candidates: z.array(desktopCandidateSchema), components: z.array(desktopComponentSchema), next_probes: z.array(desktopProbeSchema), diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index 4fa6fa526..8c7215319 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -8,7 +8,9 @@ import { unlink, } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { createServer as createNetServer } from "node:net"; +import { expect, it, onTestFinished } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createDesktopApplicationService } from "../../../src/composition/desktop.js"; import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js"; @@ -442,6 +444,116 @@ it("keeps directory-only marker identities separate across installation roots", expect(a.report.root.snapshot_sha256).not.toBe(b.report.root.snapshot_sha256); }); +it.skipIf(process.platform === "win32").each(["path", "kind"] as const)( + "commits omitted special-entry %s changes and exposes selected omission facts", + async (change) => { + const root = await createTestTempDirectory("rea-desktop-omissions-"); + const original = join(root, "special"); + execFileSync("mkfifo", [original], { timeout: 10_000 }); + const before = await inspect(root); + let selected = "special"; + if (change === "path") { + selected = "renamed"; + await rename(original, join(root, selected)); + } else { + await unlink(original); + const server = createNetServer(); + onTestFinished(async () => { + if (!server.listening) return; + await new Promise((resolve, reject) => + server.close((cause) => (cause ? reject(cause) : resolve())), + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(original, resolve); + }); + } + const after = await inspect(root); + expect(after.report.root.snapshot_sha256).not.toBe( + before.report.root.snapshot_sha256, + ); + expect(before.report.omissions).toEqual([ + { + omission_id: expect.stringMatching(/^dso_[a-f0-9]{64}$/u), + path: "special", + kind: "fifo", + }, + ]); + expect(after.report.omissions[0]?.omission_id).not.toBe( + before.report.omissions[0]?.omission_id, + ); + expect(after.report.coverage).toMatchObject({ + status: "partial", + entries_examined: 1, + special_entries_omitted: 1, + files_probed: 0, + bytes_read: 0, + }); + const page = inspectAnalysisView({ + source: { kind: "inline", evidence: after.evidence }, + view: { + kind: "desktop-application", + collection: "omissions", + paths: [selected], + fields: ["kind"], + offset: 0, + limit: 1, + }, + }); + if (!page.ok) throw page.error; + expect(page.value.evidence_links).toEqual([after.evidence.evidence_id]); + expect(page.value.normalized_result).toHaveProperty("items", [ + { + omission_id: after.report.omissions[0]?.omission_id, + kind: change === "path" ? "fifo" : "socket", + }, + ]); + expect(page.value.normalized_result).toMatchObject({ + coverage: { total: 1, exhausted: true }, + source_coverage: { status: "partial" }, + }); + }, +); + +it("bounds omitted-entry retention before allocating rows or serializing identities", async () => { + const root = await createTestTempDirectory("rea-desktop-omission-capacity-"); + let examined = 0; + // Simulate bounded parsed paths without creating hundreds of deep FIFO trees. + const prefix = Array.from({ length: 15 }, () => "d".repeat(255)).join("/"); + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + reader.entries = async function* (_signal, options) { + if (typeof options === "function") + throw new Error("Expected traversal options"); + for (let index = 0; index < 400; index++) { + examined++; + options?.onOmission?.({ + path: `${prefix}/${index}/entry`, + kind: "fifo", + }); + } + }; + return reader; + }), + ); + onTestFinished(() => service.close()); + const result = await service.inspect({ path: root }); + if (!result.ok) throw result.error; + const report = desktopApplicationSchema.parse(result.value.normalized_result); + expect(examined).toBeLessThan(400); + expect(report.omissions).toHaveLength(examined - 1); + expect(report.coverage).toMatchObject({ + status: "partial", + entries_examined: examined, + special_entries_omitted: examined, + }); + expect(report.limitations).toContainEqual( + expect.stringContaining("representation budget"), + ); +}); + it("preserves the selected root alias and directs follow-ups to the admitted physical root", async () => { const root = await createTestTempDirectory("rea-desktop-root-alias-"); const admitted = join(root, "admitted"); diff --git a/tests/boundary/mcp/desktopApplicationMcp.test.ts b/tests/boundary/mcp/desktopApplicationMcp.test.ts index b2da5465f..3b775e8cd 100644 --- a/tests/boundary/mcp/desktopApplicationMcp.test.ts +++ b/tests/boundary/mcp/desktopApplicationMcp.test.ts @@ -1,6 +1,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { execFileSync } from "node:child_process"; import { expect, it, onTestFinished } from "vitest"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -9,6 +10,7 @@ import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js"; import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; it("advertises exact schemas and retains a compact directory answer with typed selected pages", async () => { const root = await createTestTempDirectory("rea-desktop-mcp-"); @@ -55,6 +57,9 @@ it("advertises exact schemas and retains a compact directory answer with typed s expect(full.candidates.map((candidate) => candidate.family)).toEqual([ "electron", ]); + const archive = full.files.find((file) => file.path === "resources/app.asar"); + if (archive === undefined) + throw new Error("Missing retained ASAR convention"); const selected = await client.callTool({ name: "inspect_analysis_view", arguments: { @@ -65,7 +70,7 @@ it("advertises exact schemas and retains a compact directory answer with typed s view: { kind: "desktop-application", collection: "files", - paths: ["resources/app.asar"], + file_ids: [archive.file_id], fields: ["path", "read_status"], limit: 1, }, @@ -98,3 +103,92 @@ it("advertises exact schemas and retains a compact directory answer with typed s view.parent_evidence_id, ); }); + +it.skipIf(process.platform === "win32")( + "retains special-entry facts and advertises batched omission selections through MCP", + async () => { + const root = await createTestTempDirectory("rea-desktop-omissions-mcp-"); + execFileSync("mkfifo", [join(root, "b"), join(root, "a")], { + timeout: 10_000, + }); + const harness = await createApplicationMcpHarness(); + onTestFinished(harness.close); + const schemas = await advertisedMcpToolSchemas( + harness.client, + "inspect_analysis_view", + ); + const inspected = await harness.client.callTool({ + name: "inspect_desktop_application", + arguments: { path: root }, + }); + expect(inspected.isError).not.toBe(true); + const summary = analysisViewResultSchema.parse( + parseEvidence(inspected.structuredContent).normalized_result, + ); + expect(summary).toMatchObject({ + summary: { available_counts: { omissions: 2 } }, + }); + const source = { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }; + const listed = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { + kind: "desktop-application", + collection: "omissions", + limit: 2, + }, + }, + }); + expect(listed.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, listed.structuredContent), + ).toBe(true); + const page = analysisViewResultSchema.parse( + parseEvidence(listed.structuredContent).normalized_result, + ); + if (page.kind !== "desktop-application") throw new Error("Expected rows"); + const ids = page.items.map((row) => { + if (!("omission_id" in row) || typeof row.omission_id !== "string") + throw new Error("Missing stable omission identity"); + return row.omission_id; + }); + expect(page.items).toMatchObject([ + { path: "a", kind: "fifo" }, + { path: "b", kind: "fifo" }, + ]); + for (const selection of [ids, []]) { + const args = { + source, + view: { + kind: "desktop-application", + collection: "omissions", + omission_ids: selection, + fields: ["kind"], + limit: 2, + }, + }; + expect(schemas.ajv.validate(schemas.inputSchema, args)).toBe(true); + const selected = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: args, + }); + expect(selected.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, selected.structuredContent), + ).toBe(true); + const evidence = parseEvidence(selected.structuredContent); + expect(evidence.evidence_links).toEqual([summary.parent_evidence_id]); + expect(evidence.normalized_result).toHaveProperty( + "items", + selection.map((omission_id) => ({ omission_id, kind: "fifo" })), + ); + expect(evidence.normalized_result).toMatchObject({ + source_coverage: { status: "partial" }, + }); + } + }, +);