diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02ec8150a0..64b3f83a64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -274,6 +274,8 @@ jobs: - run: npm ci - run: npm run verify:keyed-archive - run: npm run verify:asset-catalog + - name: Native compiled NIB hierarchy regressions + run: npm run test:focused -- src/application/NibNativeHierarchy.test.ts - name: Real binary metadata and Interface Builder integration run: npm run verify:apple-dispatch && npm run verify:interface-builder diff --git a/docs/browser-observation.md b/docs/browser-observation.md index b60f1a4747..1ded16b08f 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -153,3 +153,5 @@ Network and console coverage starts only after REA attaches and enables the rele The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and returns an image only when requested. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket. This feature is not a browser sandbox or network containment mechanism. The attached page and browser continue running with their existing privileges and may make external requests independently of REA. CDP gives deep access to the selected browser profile, so use a dedicated profile, include only origins you intend to inspect, and treat other same-user processes as outside this boundary. + +Response metadata normalizes a `Referrer-Policy` fallback list to its last recognized nonempty policy, including mixed-case tokens. Unknown policy names are skipped; malformed token syntax and lists without a recognized policy remain `null`. This reports the response's declared policy, not the effective policy of a document or an inferred browser default. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index a8cd6ac92d..bd80e84b26 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -6,6 +6,12 @@ coverage and unknowns. The CLI and MCP use the same application workflows. ## Static inspection +`inspect_macho` retains otool segment `file_offset` values relative to the +selected Mach-O slice. Its evidence file-offset ranges address the original +input file and include the observed lipo slice offset for universal binaries. +If that offset is unavailable, segment evidence locations are omitted with an +explicit limitation; architecture inventory locations remain available. + - `inspect_asset_catalog` / `rea inspect-asset-catalog ` reads compiled `Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw rendition fields, pagination and exact UI resource-name matches are returned. diff --git a/package-lock.json b/package-lock.json index 7aae3494a6..d886f4ab04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2659,9 +2659,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "license": "MIT", "engines": { "node": ">=14.6" @@ -2846,15 +2846,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer": { diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 65bfb8bc03..3dbf8f5183 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -57,7 +57,7 @@ export async function startBrowserVerifierSite() { "rea-app-cookie=app-fixture; Path=/app; SameSite=Lax", ]); response.end( - 'REA browser verifier

Browser evidence

', + 'REA browser verifier

Browser evidence

Guide
', ); }); const webSockets = new WebSocketServer({ noServer: true }); diff --git a/scripts/lib/browser-verifier-assertions.mjs b/scripts/lib/browser-verifier-assertions.mjs index 568100d717..ad22ff7122 100644 --- a/scripts/lib/browser-verifier-assertions.mjs +++ b/scripts/lib/browser-verifier-assertions.mjs @@ -22,7 +22,7 @@ export function assertObservation(result, origin) { assertObservationInventory(result, origin); assertObservationEvents(result); assertObservationPrivacy(result); - assertObservationMetadata(result); + assertObservationMetadata(result, origin); } function assertObservationInventory(result, origin) { @@ -86,7 +86,16 @@ function assertObservationPrivacy(result) { ); } -function assertObservationMetadata(result) { +function assertObservationMetadata(result, origin) { + if ( + !result.metadata.dom_urls.some( + ({ attribute, url }) => + attribute === "href" && url === `${origin}/assets/guide`, + ) + ) + throw new Error( + "Real Chrome relative DOM URL did not use the document base", + ); if (!result.storage.local_storage_keys.includes("rea-storage-key")) throw new Error("Real Chrome local-storage key inventory was missing"); if ( diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index af556aff4f..21a6a8ebfb 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -271,7 +271,7 @@ const parseXmlPlistExecutable = (plist: string): string => { ); if (match?.[1] === undefined) throw new Error("CFBundleExecutable is missing"); - return decodeXml(match[1].trim()); + return decodeXml(match[1]); }; const readBinaryPlistExecutable = async (plistPath: string): Promise => @@ -280,11 +280,12 @@ const readBinaryPlistExecutable = async (plistPath: string): Promise => "-extract", "CFBundleExecutable", "raw", + "-n", "-o", "-", plistPath, ]) - ).stdout.trim(); + ).stdout; const isSafeExecutableName = (name: string): boolean => name.length > 0 && diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 306c7dca0d..7c3b9dbf28 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -133,13 +133,6 @@ const registrationAligned = ( return false; if (!isOwnedClientRegistrationCommand(command, currentCommandPath)) return false; - if ( - command.length === 3 && - command[2] === "mcp" && - resolve(command[0] ?? "") === resolve(process.execPath) && - resolve(command[1] ?? "") === currentCommandPath - ) - return true; if ( client.name === "codex" && registration.startup_timeout_sec !== @@ -155,6 +148,13 @@ const registrationAligned = ( return false; if (client.format === "commandcode" && registration.transport !== "stdio") return false; + if ( + command.length === 3 && + command[2] === "mcp" && + resolve(command[0] ?? "") === resolve(process.execPath) && + resolve(command[1] ?? "") === currentCommandPath + ) + return true; if ( command.length === 4 && command[0] === "npx" && diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index dc0340b2fc..5b64623f46 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -12,6 +12,11 @@ import { interfaceBuilderLimitsSchema, type InterfaceBuilderDocumentInput, } from "../domain/interfaceBuilderGraph.js"; +import { + mergeNibHierarchies, + projectNibViewHierarchy, + type NibHierarchyNode, +} from "./NibViewHierarchy.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; @@ -27,6 +32,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const reader = new DirectoryArtifactReader(input.bundlePath); const documents: InterfaceBuilderDocumentInput[] = []; const invalid: string[] = []; + const incompleteHierarchies = new Map(); let omitted = 0; let attempted = 0; try { @@ -40,10 +46,13 @@ export const analyzeInterfaceBuilderBundle = async (input: { attempted += 1; try { const bytes = await readEntry(reader, entry, input.signal); - const raw = + const nib = bytes.subarray(0, 10).toString("ascii") === "NIBArchive" ? projectNibArchive(decodeNibArchive(bytes)) - : decodePlist(bytes); + : null; + const raw = nib === null ? decodePlist(bytes) : nib.raw; + if (nib !== null && nib.omitted > 0) + incompleteHierarchies.set(entry.path, nib.omitted); const documentHash = createHash("sha256").update(bytes).digest("hex"); documents.push({ relativePath: entry.path, @@ -69,26 +78,71 @@ export const analyzeInterfaceBuilderBundle = async (input: { documents, limits, }); + return finalizeHierarchyCoverage(result, { + incompleteHierarchies, + invalid, + attempted, + omitted, + }); +}; + +const finalizeHierarchyCoverage = ( + result: ReturnType, + counts: { + incompleteHierarchies: ReadonlyMap; + invalid: readonly string[]; + attempted: number; + omitted: number; + }, +) => { + const { incompleteHierarchies, invalid } = counts; return { ...result, + documents: result.documents.map((document) => ({ + ...document, + hierarchy_complete: + document.hierarchy_complete && + !incompleteHierarchies.has(document.relative_path), + })), graph: { ...result.graph, coverage: [ - ...result.graph.coverage, + ...result.graph.coverage.map((facet) => { + const omitted = incompleteHierarchies.get( + facet.facet.replace(/^hierarchy:/u, ""), + ); + return facet.facet.startsWith("hierarchy:") && omitted !== undefined + ? { + ...facet, + status: "partial" as const, + reason: "serialized_view_hierarchy_incomplete", + omitted: facet.omitted + omitted, + } + : facet; + }), { facet: "archive_decode", status: invalid.length > 0 ? ("partial" as const) : ("complete" as const), reason: invalid.length > 0 ? "one_or_more_archives_invalid" : null, - examined: attempted, - omitted, + examined: counts.attempted, + omitted: counts.omitted, }, ], - truncated: result.graph.truncated || omitted > 0 || invalid.length > 0, + truncated: + result.graph.truncated || + counts.omitted > 0 || + invalid.length > 0 || + incompleteHierarchies.size > 0, }, limitations: [ ...result.limitations, "Compiled Interface Builder archives are private serialized object graphs. The decoder reports only recognized keyed-archive objects and connections; unrecognized object classes and fields remain unknown.", + ...(incompleteHierarchies.size === 0 + ? [] + : [ + "Some serialized hierarchy links could not be projected within the bounded hierarchy; their parentage remains unknown.", + ]), ...(invalid.length === 0 ? [] : [ @@ -99,7 +153,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { }; /** Project decoded NIB records into the same bounded graph input as ibtool. */ -const projectNibArchive = (archive: NibArchiveDocument): JsonValue => { +const projectNibArchive = (archive: NibArchiveDocument) => { const byId = new Map(archive.objects.map((object) => [object.id, object])); const dereference = (value: JsonValue | undefined): JsonValue | undefined => { if (typeof value !== "object" || value === null || Array.isArray(value)) @@ -181,7 +235,7 @@ const projectNibArchive = (archive: NibArchiveDocument): JsonValue => { }), ); const connections: Record = {}; - const hierarchy: JsonValue[] = []; + const hierarchy: NibHierarchyNode[] = []; const reference = (value: JsonValue | undefined): number | null => { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -214,23 +268,33 @@ const projectNibArchive = (archive: NibArchiveDocument): JsonValue => { }); } } + let hierarchyOmitted = 0; const hierarchyFor = ( objectId: number, seen: Set, depth: number, - ): JsonValue | null => { - if (depth > 32 || seen.has(objectId)) return null; + ): NibHierarchyNode | null => { + if (depth > 32 || seen.has(objectId)) { + hierarchyOmitted += 1; + return null; + } const object = byId.get(objectId); - if (object === undefined) return null; + if (object === undefined) { + hierarchyOmitted += 1; + return null; + } const nextSeen = new Set(seen).add(objectId); - const children: JsonValue[] = []; + const children: NibHierarchyNode[] = []; for (const [key, value] of Object.entries(object.values)) { if (!/(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key)) continue; const direct = reference(value); if (direct === null) continue; const target = byId.get(direct); - if (target === undefined) continue; + if (target === undefined) { + hierarchyOmitted += 1; + continue; + } if (/array|set/iu.test(target.class_name)) { for (const candidate of Object.values(target.values)) { const child = reference(candidate); @@ -254,14 +318,22 @@ const projectNibArchive = (archive: NibArchiveDocument): JsonValue => { const root = hierarchyFor(hierarchyRoot, new Set(), 0); if (root !== null) hierarchy.push(root); } - return jsonValueSchema.parse({ - "com.apple.ibtool.document.objects": objects, - "com.apple.ibtool.document.connections": connections, - "com.apple.ibtool.document.hierarchy": hierarchy, - "com.apple.ibtool.document.classes": Object.fromEntries( - archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]), - ), - }); + const viewHierarchy = projectNibViewHierarchy( + archive.objects, + new Set(Object.keys(objects).map(Number)), + ); + const merged = mergeNibHierarchies(hierarchy, viewHierarchy.hierarchy ?? []); + return { + omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted, + raw: jsonValueSchema.parse({ + "com.apple.ibtool.document.objects": objects, + "com.apple.ibtool.document.connections": connections, + "com.apple.ibtool.document.hierarchy": merged.hierarchy, + "com.apple.ibtool.document.classes": Object.fromEntries( + archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]), + ), + }), + }; }; const isInterfaceBuilderArchive = (path: string): boolean => { diff --git a/src/application/JavaScriptArtifactFiles.ts b/src/application/JavaScriptArtifactFiles.ts index 72e94c414b..0257c64724 100644 --- a/src/application/JavaScriptArtifactFiles.ts +++ b/src/application/JavaScriptArtifactFiles.ts @@ -14,6 +14,7 @@ import { type ArtifactReader, } from "../artifacts/ArtifactReader.js"; import { streamChunkToBuffer } from "../artifacts/StreamBytes.js"; +import { hashReadable } from "./ArtifactInventory/hash.js"; import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js"; /** Relevant file categories projected from the complete artifact inventory. */ @@ -89,6 +90,21 @@ export const readJavaScriptArtifactFiles = async ( snapshot: ArtifactInventorySnapshot, signal?: AbortSignal, ): Promise => { + if (reader instanceof AsarArtifactReader) { + const root = snapshot.nodes.find( + ({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id, + ); + if (root === undefined) + throw new ArtifactReaderFailure( + "integrity", + "Root artifact node is missing", + ); + await verifyEntryBytes( + reader.openContainer(signal), + { path: ".", sha256: root.sha256, bytes: root.size }, + signal, + ); + } const inventory = expectedInventory(snapshot); const expected = inventory.files; const context: ReadContext = { @@ -156,6 +172,11 @@ const visitReader = async ( context.registry.add(path, nestedAsar ? "directory" : entry.kind); if (nestedAsar) { const inventory = expectedContainer(path, context); + await verifyEntryBytes( + await frame.reader.open(entry, context.signal), + inventory, + context.signal, + ); context.containers.push(inventory); const nested = new AsarArtifactReader(entry.adapterKey); stack.push({ @@ -214,8 +235,14 @@ const readText = async ( context: ReadContext, input: ReadTextInput, ): Promise => { - if (input.expected.kind === "native-addon") + if (input.expected.kind === "native-addon") { + await verifyEntryBytes( + await input.reader.open(input.entry, context.signal), + input.expected, + context.signal, + ); return { included: false, reason: "not-applicable" }; + } const bytes = await readAll( await input.reader.open(input.entry, context.signal), context.signal, @@ -240,6 +267,24 @@ const readText = async ( } }; +const verifyEntryBytes = async ( + stream: Readable, + expected: Pick, + signal?: AbortSignal, +): Promise => { + try { + const digest = await hashReadable(stream, signal); + if (digest.sha256 !== expected.sha256 || digest.bytes !== expected.bytes) + throw new ArtifactReaderFailure( + "integrity", + `Artifact entry changed after inventory: ${expected.path}`, + ); + } catch (cause: unknown) { + abortIfNeeded(signal); + throw cause; + } +}; + const expectedInventory = ( snapshot: ArtifactInventorySnapshot, ): { diff --git a/src/application/JavaScriptSemanticGraphBuilder.test.ts b/src/application/JavaScriptSemanticGraphBuilder.test.ts index 45bad5403a..8b0c5ec90a 100644 --- a/src/application/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/JavaScriptSemanticGraphBuilder.test.ts @@ -349,3 +349,40 @@ it.each([ ); }, ); +it.each([ + "Promise.resolve(1)", + "(Promise.resolve(1) as Promise)", + "(Promise.resolve(1) satisfies Promise)", + "Promise.resolve(1)!", + "((Promise.resolve(1) as Promise)!)", + "(new Promise(resolve => resolve(1)) as Promise)", + "(Promise.all([Promise.resolve(1)]) as Promise)", + "(Promise.resolve(1).then(value => value) as Promise)", +])("projects a traversable returned-task link for %s", (expression) => { + const graph = graphFor(`const run = () => ${expression};`); + const promise = graph.nodes.find( + ({ kind, properties }) => + kind === "promise" && properties.ownership === "returned", + ); + const relation = graph.relations.find( + ({ relation: kind }) => kind === "returns-task", + ); + expect(promise).toBeDefined(); + expect(relation).toMatchObject({ + target_node_id: promise?.node_id, + resolution: "resolved", + }); + const returnNode = graph.nodes.find( + ({ node_id }) => node_id === relation?.source_node_id, + ); + expect(returnNode?.kind).toBe("return-site"); + if (returnNode === undefined) + throw new Error("Expected the linked return site"); + const query = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: returnNode.node_id }, + direction: "forward-influence", + allowed_relations: ["returns-task"], + }); + expect(query.relations).toContainEqual(relation); + expect(query.nodes.map(({ node_id }) => node_id)).toContain(promise?.node_id); +}); diff --git a/src/application/NibNativeHierarchy.test.ts b/src/application/NibNativeHierarchy.test.ts new file mode 100644 index 0000000000..3d8bc42ad1 --- /dev/null +++ b/src/application/NibNativeHierarchy.test.ts @@ -0,0 +1,177 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { decodeNibArchive } from "../artifacts/NibArchive.js"; +import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; + +const compileNestedViews = async (depth: number, withWindow = false) => { + const root = await createTestTempDirectory("rea-nib-hierarchy-"); + const bundle = join(root, "Fixture.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + const source = await readFile( + "tests/conformance/interface-builder/Main.xib", + "utf8", + ); + const nested = + Array.from( + { length: depth }, + (_, i) => + ``, + ).join("") + "".repeat(depth); + const xib = join(root, "Nested.xib"); + let xibSource = source.replace("", `${nested}`); + if (withWindow) + xibSource = xibSource + .replace( + '', + '', + ) + .replace("\n ", "\n "); + await writeFile(xib, xibSource); + const nib = join(resources, "Main.nib"); + execFileSync("/usr/bin/xcrun", [ + "ibtool", + "--errors", + "--warnings", + "--compile", + nib, + xib, + ]); + return { bundle, archive: decodeNibArchive(await readFile(nib)) }; +}; + +describe.skipIf(process.platform !== "darwin")( + "native compiled NIB view hierarchy", + () => { + it.each([4, 40])( + "recovers every serialized superview link for %i nested views", + async (depth) => { + const { bundle, archive } = await compileNestedViews(depth); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + const parents = archive.objects.flatMap(({ id, values }) => { + const parent = values.NSSuperview; + return typeof parent === "object" && + parent !== null && + !Array.isArray(parent) && + typeof parent.$nib_object_ref === "number" + ? [{ child: String(id), parent: String(parent.$nib_object_ref) }] + : []; + }); + expect(parents).toHaveLength(depth + 1); + for (const pair of parents) { + const child = result.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === pair.child, + ); + const parent = result.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === pair.parent, + ); + expect(child).toBeDefined(); + expect(parent).toBeDefined(); + expect( + result.graph.edges.some( + ({ from, to, relation }) => + from === parent?.id && + to === child?.id && + relation === "contains", + ), + ).toBe(true); + } + expect(result.documents[0]?.hierarchy_complete).toBe(true); + expect(result.graph.truncated).toBe(false); + }, + ); + + it("retains the native window-to-content-view relation alongside superview links", async () => { + const { bundle, archive } = await compileNestedViews(4, true); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "c".repeat(64), + }); + const window = archive.objects.find( + ({ class_name }) => class_name === "NSWindowTemplate", + ); + const content = window?.values.NSWindowView; + if ( + window === undefined || + typeof content !== "object" || + content === null || + Array.isArray(content) + ) + throw new Error("native window content link missing"); + const windowNode = result.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === String(window.id), + ); + const viewNode = result.graph.nodes.find( + ({ attributes }) => + attributes.interface_builder_object_id === + String(content.$nib_object_ref), + ); + expect(windowNode).toBeDefined(); + expect(viewNode).toBeDefined(); + expect( + result.graph.edges.some( + ({ from, to, relation }) => + from === windowNode?.id && + to === viewNode?.id && + relation === "contains", + ), + ).toBe(true); + expect(result.documents[0]?.hierarchy_complete).toBe(true); + expect(result.graph.truncated).toBe(false); + }); + + it("reports partial coverage for a native hierarchy beyond the projection depth bound", async () => { + const { bundle } = await compileNestedViews(132); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "d".repeat(64), + }); + expect(result.documents[0]?.hierarchy_complete).toBe(false); + expect(result.graph.truncated).toBe(true); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "hierarchy:Contents/Resources/Main.nib", + status: "partial", + reason: "serialized_view_hierarchy_incomplete", + omitted: expect.any(Number), + }), + ); + expect( + result.graph.coverage.find( + ({ facet }) => facet === "hierarchy:Contents/Resources/Main.nib", + )?.omitted, + ).toBeGreaterThan(0); + }); + + it.each([false, true])( + "reports partial hierarchy at the object budget (window=%s)", + async (withWindow) => { + const { bundle } = await compileNestedViews(4, withWindow); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "b".repeat(64), + limits: { max_objects: 2 }, + }); + expect(result.documents[0]?.hierarchy_complete).toBe(false); + expect(result.graph.truncated).toBe(true); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "hierarchy:Contents/Resources/Main.nib", + status: "partial", + }), + ); + }, + ); + }, +); diff --git a/src/application/NibViewHierarchy.test.ts b/src/application/NibViewHierarchy.test.ts new file mode 100644 index 0000000000..bcda88ee0c --- /dev/null +++ b/src/application/NibViewHierarchy.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeNibHierarchies, + projectNibViewHierarchy, +} from "./NibViewHierarchy.js"; + +const object = (id: number, parent?: number) => ({ + id, + class_name: "NSView", + values: + parent === undefined ? {} : { NSSuperview: { $nib_object_ref: parent } }, +}); + +describe("serialized NIB view hierarchy bounds", () => { + it("leaves archives without superview links to the legacy projection", () => { + expect(projectNibViewHierarchy([object(0)], new Set([0]))).toEqual({ + hierarchy: null, + omitted: 0, + }); + }); + + it("uses the serialized parent direction and retains all sibling links", () => { + expect( + projectNibViewHierarchy( + [object(0), object(1, 0), object(2, 0)], + new Set([0, 1, 2]), + ), + ).toEqual({ + hierarchy: [ + { + objectID: "0", + children: [ + { objectID: "1", children: [] }, + { objectID: "2", children: [] }, + ], + }, + ], + omitted: 0, + }); + }); + + it("reports missing parents without inventing parentage", () => { + expect(projectNibViewHierarchy([object(0, 99)], new Set([0]))).toEqual({ + hierarchy: [], + omitted: 1, + }); + }); + + it("reports a missing encoded window content view as incomplete", () => { + expect( + projectNibViewHierarchy( + [ + { + id: 0, + class_name: "NSWindowTemplate", + values: { NSWindowView: { $nib_object_ref: 99 } }, + }, + ], + new Set([0]), + ), + ).toEqual({ hierarchy: [{ objectID: "0", children: [] }], omitted: 1 }); + }); + + it("marks unsupported serialized parent values as incomplete", () => { + expect( + projectNibViewHierarchy( + [{ id: 0, class_name: "NSView", values: { NSSuperview: "unknown" } }], + new Set([0]), + ), + ).toEqual({ hierarchy: null, omitted: 1 }); + }); + + it("reports cyclic and self-referential parentage as incomplete", () => { + expect( + projectNibViewHierarchy( + [object(0, 1), object(1, 0), object(2, 2)], + new Set([0, 1, 2]), + ), + ).toEqual({ + hierarchy: [], + omitted: 3, + }); + }); + + it("does not project children whose parent was not retained", () => { + expect( + projectNibViewHierarchy([object(0), object(1, 0)], new Set([1])), + ).toEqual({ + hierarchy: [], + omitted: 1, + }); + }); + + it("reports nodes beyond the depth bound instead of claiming complete coverage", () => { + const objects = Array.from({ length: 132 }, (_, id) => + object(id, id === 0 ? undefined : id - 1), + ); + expect( + projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id))) + .omitted, + ).toBe(3); + }); + + it("reports nodes beyond the record bound", () => { + const objects = Array.from({ length: 20_002 }, (_, id) => + object(id, id === 0 ? undefined : 0), + ); + expect( + projectNibViewHierarchy(objects, new Set(objects.map(({ id }) => id))) + .omitted, + ).toBe(2); + }); +}); + +describe("legacy NIB hierarchy preservation", () => { + it("keeps encoded window parents while merging shared views only once", () => { + const legacy = [ + { + objectID: "window", + children: [ + { objectID: "view", children: [{ objectID: "first", children: [] }] }, + ], + }, + ]; + const recovered = [ + { + objectID: "view", + children: [ + { objectID: "first", children: [] }, + { objectID: "second", children: [] }, + ], + }, + ]; + expect(mergeNibHierarchies(legacy, recovered).hierarchy).toEqual([ + { + objectID: "window", + children: [ + { + objectID: "view", + children: [ + { objectID: "first", children: [] }, + { objectID: "second", children: [] }, + ], + }, + ], + }, + ]); + }); + it("moves an existing root under its encoded parent without duplicate visits", () => { + expect( + mergeNibHierarchies( + [{ objectID: "child", children: [] }], + [ + { + objectID: "parent", + children: [{ objectID: "child", children: [] }], + }, + ], + ), + ).toEqual({ + hierarchy: [ + { objectID: "parent", children: [{ objectID: "child", children: [] }] }, + ], + omitted: 0, + }); + }); + it("reports conflicting encoded parents rather than dropping a link silently", () => { + const legacy = [ + { objectID: "first", children: [{ objectID: "child", children: [] }] }, + ]; + const recovered = [ + { objectID: "second", children: [{ objectID: "child", children: [] }] }, + ]; + expect(mergeNibHierarchies(legacy, recovered).omitted).toBe(1); + }); + it("does not introduce a cycle when moving an existing root", () => { + const legacy = [ + { objectID: "child", children: [{ objectID: "parent", children: [] }] }, + ]; + const recovered = [ + { objectID: "parent", children: [{ objectID: "child", children: [] }] }, + ]; + expect(mergeNibHierarchies(legacy, recovered)).toEqual({ + hierarchy: legacy, + omitted: 1, + }); + }); +}); diff --git a/src/application/NibViewHierarchy.ts b/src/application/NibViewHierarchy.ts new file mode 100644 index 0000000000..231f456b6e --- /dev/null +++ b/src/application/NibViewHierarchy.ts @@ -0,0 +1,176 @@ +import type { NibArchiveDocument } from "../artifacts/NibArchive.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +export type NibHierarchyNode = { + objectID: string; + children: NibHierarchyNode[]; +}; + +/** Project encoded view parents; archive owners are not inferred view parents. */ +export const projectNibViewHierarchy = ( + objects: NibArchiveDocument["objects"], + retainedIds: ReadonlySet, +) => { + const { parents, involved, invalid } = collectNibParents( + objects, + retainedIds, + ); + if (parents.size === 0) return { hierarchy: null, omitted: invalid }; + const children = new Map(); + for (const [child, parent] of parents) { + if (!retainedIds.has(parent) || !retainedIds.has(child)) continue; + const siblings = children.get(parent) ?? []; + siblings.push(child); + children.set(parent, siblings); + } + const roots: NibHierarchyNode[] = []; + const pending: { id: number; depth: number; output: NibHierarchyNode[] }[] = + []; + for (const id of involved) + if (!parents.has(id)) pending.push({ id, depth: 0, output: roots }); + const visited = new Set(); + while (pending.length > 0) { + const item = pending.pop(); + if (item === undefined) break; + if (visited.has(item.id) || item.depth > 128 || visited.size >= 20_000) + continue; + visited.add(item.id); + const node: NibHierarchyNode = { objectID: String(item.id), children: [] }; + item.output.push(node); + for (const child of [...(children.get(item.id) ?? [])].reverse()) + pending.push({ id: child, depth: item.depth + 1, output: node.children }); + } + return { + hierarchy: roots, + omitted: involved.size - visited.size + invalid, + }; +}; + +/** Add encoded parent links once; conflicting legacy parents remain unknown. */ +export const mergeNibHierarchies = ( + legacy: NibHierarchyNode[], + recovered: NibHierarchyNode[], +) => { + const existing = new Map(); + const owners = new Map(); + const parents = new Map(); + const pending = legacy.map((node) => ({ + node, + output: legacy, + parent: null as string | null, + })); + while (pending.length > 0) { + const entry = pending.pop(); + if (entry === undefined || existing.has(entry.node.objectID)) continue; + existing.set(entry.node.objectID, entry.node); + owners.set(entry.node.objectID, entry.output); + if (entry.parent !== null) parents.set(entry.node.objectID, entry.parent); + pending.push( + ...entry.node.children.map((node) => ({ + node, + output: entry.node.children, + parent: entry.node.objectID, + })), + ); + } + let omitted = 0; + const additions = recovered.map((node) => ({ + node, + output: legacy, + parent: null as string | null, + })); + while (additions.length > 0) { + const entry = additions.pop(); + if (entry === undefined) break; + let target = existing.get(entry.node.objectID); + if (target === undefined) { + target = { objectID: entry.node.objectID, children: [] }; + existing.set(target.objectID, target); + entry.output.push(target); + owners.set(target.objectID, entry.output); + if (entry.parent !== null) parents.set(target.objectID, entry.parent); + } else if ( + entry.parent !== null && + owners.get(target.objectID) !== entry.output + ) { + const owner = owners.get(target.objectID); + if ( + owner !== legacy || + wouldCycle(target.objectID, entry.parent, parents) + ) { + omitted += 1; + } else { + owner.splice(owner.indexOf(target), 1); + entry.output.push(target); + owners.set(target.objectID, entry.output); + parents.set(target.objectID, entry.parent); + } + } + for (const node of [...entry.node.children].reverse()) + additions.push({ + node, + output: target.children, + parent: target.objectID, + }); + } + return { hierarchy: legacy, omitted }; +}; + +const wouldCycle = ( + child: string, + parent: string, + parents: ReadonlyMap, +): boolean => { + const seen = new Set(); + let current: string | undefined = parent; + while (current !== undefined && !seen.has(current)) { + if (current === child) return true; + seen.add(current); + current = parents.get(current); + } + return false; +}; + +const reference = (value: JsonValue | undefined): number | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return null; + return typeof value.$nib_object_ref === "number" + ? value.$nib_object_ref + : null; +}; + +const collectNibParents = ( + objects: NibArchiveDocument["objects"], + retainedIds: ReadonlySet, +) => { + const parents = new Map(); + const involved = new Set(); + let invalid = 0; + for (const object of objects) { + if (!retainedIds.has(object.id)) continue; + const child = reference(object.values.NSWindowView); + if ( + object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" && + invalidReference(object.values.NSWindowView) + ) + invalid += 1; + if ( + object.class_name.replace(/\0+$/u, "") === "NSWindowTemplate" && + child !== null + ) { + parents.set(child, object.id); + involved.add(object.id); + involved.add(child); + } + const parent = reference(object.values.NSSuperview); + if (invalidReference(object.values.NSSuperview)) invalid += 1; + if (parent === null) continue; + parents.set(object.id, parent); + involved.add(object.id); + if (retainedIds.has(parent)) involved.add(parent); + } + return { parents, involved, invalid }; +}; + +const invalidReference = (value: JsonValue | undefined): boolean => + value !== undefined && value !== null && reference(value) === null; diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts new file mode 100644 index 0000000000..38d308c3ca --- /dev/null +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { createEvidence } from "../domain/evidence.js"; +import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { + APPLICATION_GRAPH_DIGESTS, + artifactEvidence, + buildSyntheticJavaScriptApplicationGraph, + completeCoverage, +} from "../domain/javascriptApplicationGraph.fixture.js"; +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "../domain/javascriptApplicationGraph.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; +import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; + +describe("reconstruction obligation candidates", () => { + it("preserves admitted unknown application nodes as limitations", () => { + const source = createEvidence( + undefined, + { id: "fixture-source", name: "Fixture source", version: "1" }, + { + operation: "inspect_fixture_source", + parameters: {}, + result: {}, + confidence: "observed", + authority: "shipped-artifact", + }, + ); + const base = buildSyntheticJavaScriptApplicationGraph(); + const unknownNode = createJavaScriptApplicationNode({ + kind: "unknown", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + namespace: "fixture", + key: "unclassified-boundary", + }, + observations: [ + { + label: "Unclassified application boundary", + properties: {}, + evidence: artifactEvidence( + APPLICATION_GRAPH_DIGESTS.asar, + "unclassified.js", + ), + }, + ], + }); + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: base.root_node_ids, + nodes: [...base.nodes, unknownNode], + edges: base.edges, + coverage: completeCoverage, + limitations: base.limitations, + }); + const application = createEvidence(undefined, MANAGED_WORKFLOW_PROVIDER, { + predicateType: "rea.managed-application-graph", + operation: "project_managed_application_graph", + parameters: {}, + result: jsonValueSchema.parse({ + projection_id: `magp_${"a".repeat(64)}`, + root_artifact_sha256: APPLICATION_GRAPH_DIGESTS.asar, + source_evidence: { + managed_artifact_evidence_id: source.evidence_id, + managed_members_evidence_id: null, + managed_native_boundaries_evidence_id: null, + }, + summary: { + graph_nodes: graph.nodes.length, + graph_edges: graph.edges.length, + assemblies: 0, + modules: 0, + types: 0, + methods: 0, + fields: 0, + pinvoke_imports: 0, + native_implementations: 0, + }, + graph, + coverage: { status: "complete-within-inputs" }, + evidence_links: [source.evidence_id], + limitations: [], + }), + confidence: "inferred", + authority: "analyst-inference", + evidenceLinks: [source.evidence_id], + }); + + const generated = deriveReconstructionObligationCandidates( + createEvidenceBundle([source, application]), + [], + ); + + expect(generated.candidates.length).toBeGreaterThan(0); + expect( + generated.candidates.some( + ({ target }) => target.application_node_id === unknownNode.node_id, + ), + ).toBe(false); + expect(generated.limitations).toEqual([ + `Application graph ${graph.graph_id} contains unresolved node ${unknownNode.node_id}; reconstruction obligations remain unknown.`, + ]); + }); +}); diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 9423c84f56..64d9d6ed58 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -78,7 +78,13 @@ const deriveApplicationCandidates = ( const source = parseApplicationGraphEvidence(evidence); for (const node of source.graph.nodes) { const candidatePolicy = applicationObligationPolicy(node.kind); - if (candidatePolicy === undefined) continue; + if (candidatePolicy === undefined) { + if (node.kind === "unknown") + limitations.add( + `Application graph ${source.graph.graph_id} contains unresolved node ${node.node_id}; reconstruction obligations remain unknown.`, + ); + continue; + } const reference = node.observations.map((observation) => graphReference( evidence, diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 9aa9d401e4..95e80c5f7b 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -20,14 +20,18 @@ export interface ParsedReferenceSourceEntries { readonly limitations: string[]; } -/** Project a low-level entry failure into safe import guidance. */ +/** Preserve the observed reader failure and add entry-specific recovery guidance. */ export const projectReferenceSourceEntryFailure = ( entry: Extract, +): string => `${entry.message} ${entryRecoveryGuidance(entry)}`; + +const entryRecoveryGuidance = ( + entry: Extract, ): string => { if (entry.code === "cancelled") return "This entry was not read because the import was cancelled. Start the import again when ready."; if (entry.code === "unsupported") - return "This entry cannot be read safely on this system. Exclude it or import the directory on a supported system."; + return "Exclude this entry or replace it with a regular file."; if (entry.kind === "directory") return "This directory could not be read. Check its permissions, then try again."; if (entry.kind === "symlink") @@ -89,6 +93,22 @@ const resolveInternalSpecifier = ( `${normalized}${suffix}`, `${normalized}/index${suffix}`, ]); + // TypeScript source commonly names the extension emitted for NodeNext. + // Retain present runtime files first; substitute only a missing counterpart. + // Counterparts cover explicit filenames, not directory/index resolution. + if (/\.(?:ts|tsx|mts|cts)$/u.test(fromPath)) { + const sourceSuffixes = normalized.endsWith(".js") + ? [".ts", ".tsx", ".d.ts"] + : normalized.endsWith(".mjs") + ? [".mts", ".d.mts"] + : normalized.endsWith(".cjs") + ? [".cts", ".d.cts"] + : normalized.endsWith(".jsx") + ? [".tsx", ".d.ts"] + : []; + const stem = normalized.slice(0, -posix.extname(normalized).length); + candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`)); + } const match = candidates.find((candidate) => filePaths.has(candidate)); return match === undefined ? { to: normalized, resolution: "unresolved" } diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 3e63ab0e39..674595a042 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -1,3 +1,4 @@ +import { createReadStream } from "node:fs"; import { sep } from "node:path"; import { Readable } from "node:stream"; @@ -112,6 +113,12 @@ export class AsarArtifactReader implements ArtifactReader { } } + /** Open raw container bytes to verify the inventory identity before analysis. */ + openContainer(signal?: AbortSignal): Readable { + abortIfNeeded(signal); + return createReadStream(this.path, signal === undefined ? {} : { signal }); + } + close(): Promise { uncache(this.path); return Promise.resolve(); diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts new file mode 100644 index 0000000000..d7ffd3e1cb --- /dev/null +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { captureDom } from "./CdpCaptureDocuments.js"; + +const origin = "https://app.example.test"; +const documentUrl = `${origin}/screens/app`; +const input = inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", +}); + +const snapshot = ( + baseUrl?: string, + baseHref = "assets/", + pageUrl = documentUrl, + linkHref = "guide", +) => ({ + strings: [ + pageUrl, + baseUrl ?? "", + "#document", + "BASE", + "A", + "LINK", + "FORM", + "", + "href", + baseHref, + linkHref, + "rel", + "mcp", + "agent", + "action", + "submit", + ], + documents: [ + { + documentURL: 0, + ...(baseUrl === undefined ? {} : { baseURL: 1 }), + nodes: { + nodeType: [9, 1, 1, 1, 1], + nodeName: [2, 3, 4, 5, 6], + nodeValue: [7, 7, 7, 7, 7], + parentIndex: [-1, 0, 0, 0, 0], + attributes: [[], [8, 9], [8, 10], [8, 13, 11, 12], [14, 15]], + }, + }, + ], +}); + +const capture = (value: ReturnType) => + captureDom(value, new Set([origin]), input); + +describe("DOM metadata document base URLs", () => { + it("resolves links, actions and agent hints against the captured document base", () => { + const result = capture(snapshot(`${origin}/screens/assets/`)); + expect(result.urls.map(({ url }) => url)).toEqual([ + `${origin}/screens/assets/`, + `${origin}/screens/assets/guide`, + `${origin}/screens/assets/agent`, + `${origin}/screens/assets/submit`, + ]); + expect(result.agentHints[0]?.url).toBe(`${origin}/screens/assets/agent`); + }); + + it("uses the document URL as fallback when no baseURL was supplied", () => { + const result = capture(snapshot()); + expect(result.urls.map(({ url }) => url)).toEqual([ + `${origin}/screens/assets/`, + `${origin}/screens/guide`, + `${origin}/screens/agent`, + `${origin}/screens/submit`, + ]); + }); + + it("applies destination policy after resolving an ordinary foreign base", () => { + const foreign = "https://cdn.example.test/assets/"; + const result = capture(snapshot(foreign, foreign)); + expect(result.nodes).toHaveLength(5); + expect(result.urls).toHaveLength(4); + expect( + result.urls.every( + ({ url, destination_scope }) => + url === null && destination_scope === "outside_policy", + ), + ).toBe(true); + expect(result.agentHints[0]?.url).toBeNull(); + }); + + it("keeps an absolute approved link independent of a foreign base", () => { + const foreign = "https://cdn.example.test/assets/"; + const result = capture( + snapshot(foreign, foreign, documentUrl, `${origin}/guide`), + ); + expect(result.urls[1]).toMatchObject({ + url: `${origin}/guide`, + destination_scope: "approved", + }); + }); + + it("does not authorize a foreign document using its approved base URL", () => { + const result = capture( + snapshot( + `${origin}/assets/`, + "assets/", + "https://outside.example.test/app", + ), + ); + expect(result.nodes).toEqual([]); + expect(result.urls).toEqual([]); + expect(result.agentHints).toEqual([]); + }); +}); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index baa7eab853..53c608053a 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -162,6 +162,7 @@ export const captureDom = ( completeness?.exclude("dom", exclusionReasonForUrl(documentUrl)); continue; } + const baseUrl = indexedString(strings, document.baseURL) || documentUrl; const documentNodes = recordValue(document.nodes); if (documentNodes === undefined) continue; const nodeTypes = numberArray(documentNodes.nodeType); @@ -191,7 +192,8 @@ export const captureDom = ( const metadata = domMetadata({ strings, attributes: attributeIndexes, - documentUrl, + // The BASE element itself resolves against the document fallback URL. + baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl, nodeIndex, nodeName, allowedOrigins, @@ -360,7 +362,7 @@ const numberArray = (value: unknown): readonly number[] => interface DomMetadataOptions { readonly strings: readonly string[]; readonly attributes: readonly number[]; - readonly documentUrl: string; + readonly baseUrl: string; readonly nodeIndex: number; readonly nodeName: string; readonly allowedOrigins: ReadonlySet; @@ -372,14 +374,8 @@ const domMetadata = ( readonly urls: WebPageInspection["metadata"]["dom_urls"]; readonly agentHints: WebPageInspection["metadata"]["agent_hints"]; } => { - const { - strings, - attributes, - documentUrl, - nodeIndex, - nodeName, - allowedOrigins, - } = options; + const { strings, attributes, baseUrl, nodeIndex, nodeName, allowedOrigins } = + options; const pairs = new Map(); for (let index = 0; index + 1 < attributes.length; index += 2) { const name = indexedString(strings, attributes[index]).toLowerCase(); @@ -390,7 +386,7 @@ const domMetadata = ( for (const attribute of domUrlAttributes) { const value = pairs.get(attribute); if (value === undefined) continue; - const destination = domDestination(value, documentUrl, allowedOrigins); + const destination = domDestination(value, baseUrl, allowedOrigins); urls.push({ node_index: nodeIndex, attribute, @@ -417,14 +413,14 @@ const domMetadata = ( const domDestination = ( value: string, - documentUrl: string, + baseUrl: string, allowedOrigins: ReadonlySet, ): { readonly url: string | null; readonly scope: "approved" | "outside_policy" | "unsupported"; } => { try { - const parsed = new URL(value, documentUrl); + const parsed = new URL(value, baseUrl); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return { url: null, scope: "unsupported" }; if (!allowedOrigins.has(parsed.origin)) diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index ba5c1800d9..ab91a55930 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -51,7 +51,7 @@ export const safeResponseMetadata = ( coop: policyToken(headers.get("cross-origin-opener-policy")), coep: policyToken(headers.get("cross-origin-embedder-policy")), corp: policyToken(headers.get("cross-origin-resource-policy")), - referrer_policy: policyToken(headers.get("referrer-policy")), + referrer_policy: referrerPolicy(headers.get("referrer-policy")), x_content_type_options: policyToken( headers.get("x-content-type-options"), ), @@ -316,6 +316,27 @@ const agentHint = ( const isAgentRel = (value: string): boolean => ["mcp", "model-context", "ai-plugin", "service-desc"].includes(value); +const referrerPolicies = new Set([ + "no-referrer", + "no-referrer-when-downgrade", + "same-origin", + "origin", + "strict-origin", + "origin-when-cross-origin", + "strict-origin-when-cross-origin", + "unsafe-url", +]); + +const referrerPolicy = (value: string | undefined): string | null => { + let policy: string | null = null; + for (const raw of (value ?? "").split(",")) { + const token = raw.trim().toLowerCase(); + if (!/^[a-z-]*$/u.test(token)) return null; + if (referrerPolicies.has(token)) policy = token; + } + return policy; +}; + const policyToken = (value: string | undefined): string | null => { const token = (value ?? "").trim().toLowerCase(); return /^[a-z][a-z0-9_.-]*$/u.test(token) ? token : null; diff --git a/src/domain/javascriptSemanticAnalysis.promise_ownership.test.ts b/src/domain/javascriptSemanticAnalysis.promise_ownership.test.ts index 2a5348aa64..66d4845069 100644 --- a/src/domain/javascriptSemanticAnalysis.promise_ownership.test.ts +++ b/src/domain/javascriptSemanticAnalysis.promise_ownership.test.ts @@ -59,3 +59,91 @@ it.each([ if (ownership === "unknown") expect(promise).toMatchObject({ ownerBindingId: null, returnSiteId: null }); }); + +it.each([ + ["Promise.resolve(1)", "resolve"], + ["((Promise.resolve(1)))", "resolve"], + ["(Promise.resolve(1) as Promise)", "resolve"], + ["(Promise.resolve(1) satisfies Promise)", "resolve"], + ["Promise.resolve(1)!", "resolve"], + ["((Promise.resolve(1) as Promise)!)", "resolve"], + ["(new Promise(resolve => resolve(1)) as Promise)", "new"], + ["(Promise.all([Promise.resolve(1)]) as Promise)", "all"], + ["(Promise.resolve(1).then(value => value) as Promise)", "then"], +])("links the exact arrow return site for %s", (expression, method) => { + const ir = analyzeJavaScriptSemantics(`const run = () => ${expression};`); + const callable = ir.callables.find(({ name }) => name === "run"); + expect(callable?.returnSites).toHaveLength(1); + const site = callable?.returnSites[0]; + if (site === undefined) throw new Error("Expected the arrow return site"); + const promise = ir.promiseOperations.find( + (operation) => operation.method === method, + ); + expect(promise).toMatchObject({ + ownership: "returned", + ownerCallableId: callable?.callableId, + returnSiteId: site.returnSiteId, + }); +}); + +it.each([ + "const outer = () => () => (Promise.resolve(1) as Promise);", + "const outer = () => { const inner = () => Promise.resolve(1)!; return inner; };", +])("keeps a wrapped return owned by its nested arrow: %s", (source) => { + const ir = analyzeJavaScriptSemantics(source); + const promise = ir.promiseOperations.find( + ({ method }) => method === "resolve", + ); + const owner = ir.callables.find( + ({ callableId }) => callableId === promise?.ownerCallableId, + ); + const outer = ir.callables.find(({ name }) => name === "outer"); + const site = owner?.returnSites[0]; + if (site === undefined) + throw new Error("Expected the nested arrow return site"); + expect(owner?.callableId).not.toBe(outer?.callableId); + expect(promise).toMatchObject({ + ownership: "returned", + returnSiteId: site.returnSiteId, + }); + expect( + outer?.returnSites.map(({ returnSiteId }) => returnSiteId), + ).not.toContain(promise?.returnSiteId); +}); + +it.each([ + ["consume((Promise.resolve(1) as Promise))", "unknown"], + ["[(Promise.resolve(1) as Promise)]", "unknown"], + ["({ task: Promise.resolve(1)! })", "unknown"], + ["(condition ? Promise.resolve(1)! : other)", "unknown"], + ["((0, Promise.resolve(1)) as Promise)", "unknown"], + ["await (Promise.resolve(1) as Promise)", "awaited"], +])("does not invent a return link through %s", (expression, ownership) => { + const ir = analyzeJavaScriptSemantics( + `const run = async () => ${expression};`, + ); + expect( + ir.promiseOperations.find(({ method }) => method === "resolve"), + ).toMatchObject({ + ownership, + returnSiteId: null, + }); +}); + +it("keeps an explicit return statement's full range and the producer's location", () => { + const source = + "const run = () => { return (Promise.resolve(1) as Promise); };"; + const ir = analyzeJavaScriptSemantics(source); + const site = ir.callables.find(({ name }) => name === "run")?.returnSites[0]; + if (site === undefined) throw new Error("Expected a return statement"); + const start = source.indexOf("Promise.resolve(1)"); + expect(ir.promiseOperations[0]).toMatchObject({ + ownership: "returned", + returnSiteId: site.returnSiteId, + location: { + start: { line: 1, column: start }, + end: { line: 1, column: start + "Promise.resolve(1)".length }, + }, + }); + expect(site.location.start.column).toBe(source.indexOf("return")); +}); diff --git a/src/domain/javascriptSemanticPromises.ts b/src/domain/javascriptSemanticPromises.ts index 6e718db748..3c99e90d55 100644 --- a/src/domain/javascriptSemanticPromises.ts +++ b/src/domain/javascriptSemanticPromises.ts @@ -248,7 +248,7 @@ const ownershipAtAncestor = ( unwrapExpression(ancestor.body) === candidate.node ? returnedPromiseOwnership( candidate.ownerCallableId, - range(candidate.node), + range(ancestor.body), callables, ) : "boundary"; diff --git a/src/ghidra/extensions/GhidraExtensions.test.ts b/src/ghidra/extensions/GhidraExtensions.test.ts index 0d5219121f..183ce74a71 100644 --- a/src/ghidra/extensions/GhidraExtensions.test.ts +++ b/src/ghidra/extensions/GhidraExtensions.test.ts @@ -411,4 +411,43 @@ describe("Ghidra extension recovery coverage", () => { ), ).not.toBeNull(); }); + it.each(["not_applicable", "unsupported", "failed"] as const)( + "rejects contradictory %s reports without discarding discovery context", + async (status) => { + const { extension } = await artifact(); + const unavailable = result(extension.sha256); + const report = { + ...unavailable, + status, + result: { + ...unavailable.result, + status, + header_address: "0x401000", + discovery: "signature-heuristic", + format_major: 9, + format_minor: 1, + }, + } satisfies GhidraExtensionResult; + expect(validateGhidraExtensionResults([extension], [report])).toBeNull(); + expect( + validateGhidraExtensionResults( + [extension], + [{ ...report, result: { ...report.result, method_tables: 1 } }], + ), + ).not.toBeNull(); + for (const reason of [null, "", " "] as const) + expect( + validateGhidraExtensionResults( + [extension], + [ + { + ...report, + reason, + result: { ...report.result, reason }, + }, + ], + ), + ).not.toBeNull(); + }, + ); }); diff --git a/src/ghidra/extensions/nativeaot/NativeAotReport.ts b/src/ghidra/extensions/nativeaot/NativeAotReport.ts index 3b2eedbb3d..327263009f 100644 --- a/src/ghidra/extensions/nativeaot/NativeAotReport.ts +++ b/src/ghidra/extensions/nativeaot/NativeAotReport.ts @@ -54,6 +54,20 @@ const report = z type NativeAotReport = z.infer; +const validateNonRecoveryMetadata = ( + value: NativeAotReport, + recovered: boolean, +): string | null => + !recovered && + (value.reason === null || + value.reason.trim().length === 0 || + value.method_tables !== 0 || + value.types !== undefined || + value.derived_memory !== undefined || + value.coverage !== undefined) + ? "NativeAOT non-recovery status contradicts its reason or recovered metadata." + : null; + /** Validate the pinned producer representation before its result enters Evidence. */ export const validateNativeAotReport = ( value: GhidraExtensionResult, @@ -75,8 +89,9 @@ export const validateNativeAotReport = ( }; const validateRecoveredMetadata = (value: NativeAotReport): string | null => { + const recovered = ["complete", "partial"].includes(value.status); if ( - ["complete", "partial"].includes(value.status) && + recovered && (value.method_tables === 0 || value.format_major !== 9 || value.format_minor !== 1 || @@ -88,6 +103,8 @@ const validateRecoveredMetadata = (value: NativeAotReport): string | null => { value.reason !== null) ) return "NativeAOT recovery omitted its supported format, method-table inventory or derived-memory identity."; + const nonRecoveryInvalid = validateNonRecoveryMetadata(value, recovered); + if (nonRecoveryInvalid !== null) return nonRecoveryInvalid; const coverage = value.coverage; if ( coverage !== undefined && diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 5077f24172..4414e23a6a 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -97,12 +97,27 @@ export const inspectNativeMacho = async ( else if (optional.error._tag === "AnalysisCapabilityUnavailableError") limitations.push(VTOOL_UNAVAILABLE_LIMITATION); else return err(optional.error); - const result = normalizeMacho(captures, context.invocation, limitations); + const normalized = normalizeMacho(captures, context.invocation, limitations); + const segmentOffset = segmentContainerOffset( + normalized, + selectedArchitecture, + /^Non-fat file:/mu.test(lipo.stdout), + ); + const result = + segmentOffset === undefined + ? inspectMachoSchema.parse({ + ...normalized, + limitations: [ + ...normalized.limitations, + "Segment evidence file offsets are unavailable because the selected Mach-O slice's container offset was not observed.", + ], + }) + : normalized; return ok({ result: jsonValueSchema.parse(result), provenance: result.provenance, limitations: result.limitations, - locations: fileOffsetLocations(result), + locations: fileOffsetLocations(result, segmentOffset), }); }; @@ -119,15 +134,14 @@ const normalizeMacho = ( load.segments.find( (segment) => segment.file_offset === 0 && (segment.file_size ?? 0) > 0, )?.vm_address ?? null; + const nmExports = parseNmExports(byTool("nm").stdout); const dyldExports = parseDyldSymbols( byTool("dyld_info", 1).stdout, "exports", imageBase, + new Set(nmExports.map(({ name }) => name)), ); - const exports = uniqueSymbols([ - ...dyldExports, - ...parseNmExports(byTool("nm").stdout), - ]); + const exports = uniqueSymbols([...dyldExports, ...nmExports]); const uuid = /UUID:\s*([A-Fa-f0-9-]+)/u.exec(byTool("dwarfdump").stdout)?.[1] ?? load.uuid; @@ -246,19 +260,45 @@ const uniqueSymbols = ( const fileOffsetLocations = ( macho: ReturnType, + segmentOffset: number | undefined, ): EvidenceLocation[] => { const locations = architectureLocations(macho.architectures.items); + if (segmentOffset === undefined) return locations; for (const segment of macho.segments.items) { if (segment.file_offset === null || segment.file_size === null) continue; locations.push({ kind: "file-offset-range", - start: segment.file_offset, - end: segment.file_offset + segment.file_size, + start: segmentOffset + segment.file_offset, + end: segmentOffset + segment.file_offset + segment.file_size, }); } return locations; }; +/** otool segment offsets are slice-relative; evidence addresses the input file. */ +const segmentContainerOffset = ( + macho: ReturnType, + selectedArchitecture: string | null, + thinInput: boolean, +): number | undefined => { + const architectures = macho.architectures.items; + const slice = + selectedArchitecture === null + ? architectures.length === 1 + ? architectures[0] + : undefined + : architectures.find(({ name }) => name === selectedArchitecture); + if (slice?.file_offset !== null && slice?.file_offset !== undefined) + return slice.file_offset; + // A thin lipo observation has no separate slice offset. Unknown FAT slice + // offsets must not silently become zero and identify unrelated container bytes. + return thinInput && + selectedArchitecture === null && + architectures.length === 1 + ? 0 + : undefined; +}; + /** Project architecture slices into evidence file-offset locations. */ export const architectureLocations = ( architectures: readonly { diff --git a/src/native/parsers/codesign.ts b/src/native/parsers/codesign.ts index f2e2cc09a1..eddffc890b 100644 --- a/src/native/parsers/codesign.ts +++ b/src/native/parsers/codesign.ts @@ -19,7 +19,8 @@ export const parseCodeSignature = ( const separator = line.indexOf("="); if (separator < 1) continue; const key = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim(); + const rawValue = line.slice(separator + 1); + const value = key === "Identifier" ? rawValue : rawValue.trim(); if (key === "Authority") authorities.push(value); else if (key === "CDHash") cdhashes.push(value); else values.set(key, value); diff --git a/src/native/parsers/dyldInfo.ts b/src/native/parsers/dyldInfo.ts index d981cef9d4..f08dc8bdda 100644 --- a/src/native/parsers/dyldInfo.ts +++ b/src/native/parsers/dyldInfo.ts @@ -11,6 +11,7 @@ export const parseDyldSymbols = ( output: string, mode: "imports" | "exports", imageBase: string | null = null, + knownExportNames: ReadonlySet = new Set(), ) => { const offsets = /^\s*offset\s+symbol\s*$/mu.test(output); return output.split(/\r?\n/u).flatMap((rawLine) => { @@ -45,7 +46,18 @@ export const parseDyldSymbols = ( ]; const exported = /^(0x[\da-f]+)\s+(.+?)(?:\s+\[([^\]]+)\])?$/iu.exec(line); if (exported?.[1] === undefined || exported[2] === undefined) return []; - const absolute = /\babsolute\b/u.test(exported[3] ?? ""); + const rawName = /^0x[\da-f]+\s+(.+)$/iu.exec(line)?.[1]; + const literalName = rawName !== undefined && knownExportNames.has(rawName); + // If nm establishes both interpretations, the textual row cannot bind an address or flags. + // Let the caller retain the exact nm names without guessing which one owns this row. + if ( + literalName && + exported[3] !== undefined && + knownExportNames.has(exported[2]) + ) + return []; + const annotation = literalName ? undefined : exported[3]; + const absolute = /\babsolute\b/u.test(annotation ?? ""); const address = offsets && !absolute ? imageBase === null @@ -54,9 +66,9 @@ export const parseDyldSymbols = ( : canonicalHex(exported[1]); return [ { - name: exported[2], + name: literalName ? rawName : exported[2], address, - weak: /\bweak-def\b/u.test(exported[3] ?? "") ? true : null, + weak: /\bweak-def\b/u.test(annotation ?? "") ? true : null, reexport: false, source: null, }, diff --git a/src/native/parsers/lipo.ts b/src/native/parsers/lipo.ts index 5112b42262..6d55c12ac4 100644 --- a/src/native/parsers/lipo.ts +++ b/src/native/parsers/lipo.ts @@ -13,6 +13,23 @@ export type LipoArchitecture = z.infer; /** Parse `lipo -detailed_info` into deterministic slice metadata. */ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => { + // lipo echoes a thin file's pathname verbatim, including embedded newlines. + // Match its final architecture field before splitting records into lines. + const thinArchitecture = + /(?:^|\r?\n)Non-fat file:[\s\S]* is architecture: ([^\s]+)\s*$/u.exec( + output, + )?.[1]; + if (thinArchitecture !== undefined) + return [ + architectureSchema.parse({ + name: thinArchitecture, + cpu_type: null, + cpu_subtype: null, + file_offset: null, + size: null, + alignment: null, + }), + ]; const architectures: LipoArchitecture[] = []; let current: Record | undefined; const flush = (): void => { diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index d3a1d9b4c6..cd0fb90244 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -58,7 +58,7 @@ export const parseOtoolLoadCommands = (raw: string) => { const output = normalizeLineEndings(raw); const header = parseHeader(output); const state = createLoadCommandState(header?.flags); - for (const block of output.split(/(?=Load command \d+)/u)) + for (const block of output.split(/(?=^Load command \d+$)/mu)) parseLoadCommand(block, state); return { fileType: header?.fileType ?? null, diff --git a/src/server/mcpClientMetadata.ts b/src/server/mcpClientMetadata.ts index a475a9a138..94690eae98 100644 --- a/src/server/mcpClientMetadata.ts +++ b/src/server/mcpClientMetadata.ts @@ -43,10 +43,14 @@ const capabilityFeatures = (value: unknown): ClientFeatureAvailability => { const capabilities = isRecord(value) ? value : {}; const elicitation = isRecord(capabilities.elicitation) ? capabilities.elicitation - : {}; + : undefined; return { - elicitation_form: elicitation.form !== undefined, - elicitation_url: elicitation.url !== undefined, + // A declared capability without modes retains the protocol's form-only + // compatibility rule; a missing capability still grants no support. + elicitation_form: + elicitation !== undefined && + (elicitation.form !== undefined || elicitation.url === undefined), + elicitation_url: elicitation?.url !== undefined, roots: capabilities.roots !== undefined, sampling: capabilities.sampling !== undefined, }; diff --git a/tests/boundary/browser/cdpSafeMetadata.test.ts b/tests/boundary/browser/cdpSafeMetadata.test.ts index 67a52a11f9..56df3421ea 100644 --- a/tests/boundary/browser/cdpSafeMetadata.test.ts +++ b/tests/boundary/browser/cdpSafeMetadata.test.ts @@ -249,3 +249,44 @@ describe("Link header value delimiters", () => { expect(links[1]?.destination_scope).toBe("approved"); }); }); +describe("safe CDP response metadata referrer policy", () => { + it.each<[string | undefined, string | null]>([ + [ + "no-referrer, strict-origin-when-cross-origin", + "strict-origin-when-cross-origin", + ], + ["origin, future-policy", "origin"], + ["future-policy, no-referrer", "no-referrer"], + ["unsafe-url, NO-REFERRER", "no-referrer"], + ["origin,, no-referrer", "no-referrer"], + ["future-policy", null], + ['unsafe-url, "no-referrer"', null], + ["no-referrer; report-to=x, origin", null], + ["", null], + [undefined, null], + ...[ + "no-referrer", + "no-referrer-when-downgrade", + "same-origin", + "origin", + "strict-origin", + "origin-when-cross-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + ].map((policy): [string, string] => [policy, policy]), + ])("normalizes declared Referrer-Policy %s", (raw, expected) => { + const captured = safeResponseMetadata( + "request-referrer", + `${origin}/api`, + { + headers: { + "Referrer-Policy": raw, + "Cross-Origin-Opener-Policy": "same-origin", + }, + }, + new Set([origin]), + ); + expect(captured.response.policies.referrer_policy).toBe(expected); + expect(captured.response.policies.coop).toBe("same-origin"); + }); +}); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 76a2d26db1..227b3bc4a9 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -163,6 +165,58 @@ describe("binary target I/O", () => { }); }); +describe("app executable filename fidelity", () => { + it.each([" leading", "trailing ", " both "])( + "preserves executable filename whitespace in an XML plist: %j", + async (name) => { + const directory = await createTestTempDirectory("rea-app-name-"); + const app = join(directory, "Whitespace.app"); + const contents = join(app, "Contents"); + const executable = join(contents, "MacOS", name); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + `CFBundleExecutable${name}`, + ); + await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(executable), + format: "mach-o", + }); + }, + ); + + it.skipIf(process.platform !== "darwin")( + "preserves executable filename whitespace from native binary plists", + async () => { + const directory = await createTestTempDirectory("rea-app-binary-name-"); + for (const name of ["Ordinary", " App ", "Tab\t"]) { + const app = join(directory, `${name}.app`); + const contents = join(app, "Contents"); + const executable = join(contents, "MacOS", name); + const plist = join(contents, "Info.plist"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + plist, + `CFBundleExecutable${name}`, + ); + await promisify(execFile)("/usr/bin/plutil", [ + "-convert", + "binary1", + plist, + ]); + await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(executable), + format: "mach-o", + }); + } + }, + ); +}); + describe("DOS binary target I/O", () => { it("validates a complete DOS file beyond the initial metadata probe", async () => { const directory = await createTestTempDirectory("rea-dos-target-"); diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index 39c87690c4..49fc5c276f 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -1,5 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -182,3 +182,58 @@ describe("commandcode registration status", () => { ]); }); }); + +describe("Node-wrapped registration policy", () => { + it.each([1, 30])( + "checks Codex startup timeout %d independently of launcher", + async (timeout) => { + const home = await createTestTempDirectory("rea-node-registration-"); + await mkdir(join(home, ".codex")); + const entry = resolve("scripts/rea.mjs"); + await writeFile( + join(home, ".codex/config.toml"), + `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entry)}, "mcp"]\nstartup_timeout_sec = ${String(timeout)}\n`, + ); + const statuses = await readClientRegistrationStatuses(home, entry, { + environment: {}, + }); + expect(statuses).toEqual([ + expect.objectContaining({ + client: "codex", + state: timeout === 30 ? "aligned" : "stale", + }), + ]); + }, + ); + + it.each([{ tools: ["binary_session"] }, { tools: ["*"] }])( + "checks Copilot tool selection $tools independently of launcher", + async ({ tools }) => { + const home = await createTestTempDirectory("rea-node-copilot-"); + await mkdir(join(home, ".copilot")); + const entry = resolve("scripts/rea.mjs"); + await writeFile( + join(home, ".copilot/mcp-config.json"), + JSON.stringify({ + mcpServers: { + rea: { + type: "stdio", + command: process.execPath, + args: [entry, "mcp"], + tools, + }, + }, + }), + ); + const statuses = await readClientRegistrationStatuses(home, entry, { + environment: {}, + }); + expect(statuses).toEqual([ + expect.objectContaining({ + client: "copilot_cli", + state: tools.includes("*") ? "aligned" : "stale", + }), + ]); + }, + ); +}); diff --git a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts new file mode 100644 index 0000000000..05b28088ec --- /dev/null +++ b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts @@ -0,0 +1,141 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackage } from "@electron/asar"; +import { describe, expect, it } from "vitest"; + +import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; +import { readJavaScriptArtifactFiles } from "../../../src/application/JavaScriptArtifactFiles.js"; +import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; +import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const fixture = async () => { + const root = await createTestTempDirectory("rea-ingestion-content-identity-"); + const source = join(root, "source"); + const bundle = join(root, "bundle"); + await Promise.all([mkdir(source), mkdir(bundle)]); + await writeFile(join(source, "main.js"), "module.exports = 42;\n"); + await writeFile(join(source, "asset.txt"), "original asset"); + const archive = join(bundle, "app.asar"); + await createPackage(source, archive); + return { source, bundle, archive }; +}; + +describe("JavaScript artifact ingestion content identities", () => { + it.each([ + ["changed-asset", "directory"], + ["added-asset", "directory"], + ["changed-asset", "asar"], + ["added-asset", "asar"], + ])( + "rejects a rebuilt container with %s for a %s input and unchanged source", + async (change, format) => { + const { source, bundle, archive } = await fixture(); + const snapshot = await scanArtifactInventory( + format === "asar" ? archive : bundle, + ); + await writeFile( + join(source, change === "changed-asset" ? "asset.txt" : "new.txt"), + "rebuilt asset", + ); + await createPackage(source, archive); + const reader = + format === "asar" + ? new AsarArtifactReader(archive) + : new DirectoryArtifactReader(bundle); + try { + await expect( + readJavaScriptArtifactFiles(reader, snapshot), + ).rejects.toMatchObject({ reason: "integrity" }); + } finally { + await reader.close(); + } + }, + ); + + it("rejects changed opaque native-addon bytes after inventory", async () => { + const root = await createTestTempDirectory("rea-ingestion-native-bytes-"); + const addon = join(root, "addon.node"); + await writeFile(addon, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 1])); + const snapshot = await scanArtifactInventory(root); + await writeFile(addon, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 2])); + const reader = new DirectoryArtifactReader(root); + try { + await expect( + readJavaScriptArtifactFiles(reader, snapshot), + ).rejects.toMatchObject({ reason: "integrity" }); + } finally { + await reader.close(); + } + }); + + it("preserves an unchanged direct ASAR raw identity and source", async () => { + const { archive } = await fixture(); + const snapshot = await scanArtifactInventory(archive); + const reader = new AsarArtifactReader(archive); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + expect(files.containers).toEqual([]); + expect(files.files.find(({ path }) => path === "main.js")).toMatchObject({ + container_sha256: snapshot.manifest.root_sha256, + text: { included: true, value: "module.exports = 42;\n" }, + }); + } finally { + await reader.close(); + } + }); + + it("preserves typed cancellation when the direct ASAR filesystem stream aborts", async () => { + const { archive } = await fixture(); + const snapshot = await scanArtifactInventory(archive); + const reader = new AsarArtifactReader(archive); + const controller = new AbortController(); + try { + const reading = readJavaScriptArtifactFiles( + reader, + snapshot, + controller.signal, + ); + controller.abort(); + await expect(reading).rejects.toMatchObject({ reason: "cancelled" }); + } finally { + await reader.close(); + } + }); + + it("retains native filesystem errors for outer IO classification", async () => { + const { archive } = await fixture(); + const snapshot = await scanArtifactInventory(archive); + await rm(archive); + const reader = new AsarArtifactReader(archive); + try { + await expect( + readJavaScriptArtifactFiles(reader, snapshot), + ).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await reader.close(); + } + }); + + it("preserves unchanged container and opaque addon identities", async () => { + const { bundle } = await fixture(); + await writeFile(join(bundle, "addon.node"), Buffer.from([1, 2, 3])); + const snapshot = await scanArtifactInventory(bundle); + const reader = new DirectoryArtifactReader(bundle); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + expect(files.containers).toHaveLength(1); + expect( + files.files.find(({ path }) => path === "addon.node"), + ).toMatchObject({ + text: { included: false, reason: "not-applicable" }, + }); + expect( + files.files.find(({ path }) => path === "app.asar/main.js")?.text, + ).toEqual({ included: true, value: "module.exports = 42;\n" }); + } finally { + await reader.close(); + } + }); +}); diff --git a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts new file mode 100644 index 0000000000..47898ad069 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts @@ -0,0 +1,81 @@ +import { execFile } from "node:child_process"; +import { chmod, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; +import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const importTree = (root: string) => + importReferenceSource({ + root, + caller: "native-entry-failure-detail-test", + policy: { secretPatterns: [] }, + }); + +const permissionChecksUnavailable = + process.platform === "win32" || process.getuid?.() === 0; + +describe("reference import native entry failure details", () => { + it.skipIf(permissionChecksUnavailable)( + "retains the actual operating-system reason for an unreadable file", + async () => { + const root = await createTestTempDirectory("rea-import-native-detail-"); + const path = join(root, "blocked.ts"); + await writeFile(path, "export const value = 1;\n"); + await chmod(path, 0); + try { + const read = await readReferenceSource(root); + if (!read.ok) throw new Error(read.error.message); + const failure = read.value.entries.find( + (entry) => entry.status === "failed" && entry.path === "blocked.ts", + ); + if (failure?.status !== "failed") + throw new Error( + "Owned permission fixture did not produce a read failure", + ); + expect(failure.message).toContain("EACCES"); + const imported = await importTree(root); + if (!imported.ok) throw new Error(imported.error.message); + const entry = imported.value.entries.find( + (item) => item.path === failure.path, + ); + expect(entry?.limitations.join(" ")).toContain(failure.message); + } finally { + await chmod(path, 0o600); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "retains the unsupported file-type reason without suggesting another OS", + async () => { + const root = await createTestTempDirectory("rea-import-native-fifo-"); + await promisify(execFile)("mkfifo", [join(root, "events.pipe")], { + timeout: 2_000, + }); + const imported = await importTree(root); + if (!imported.ok) throw new Error(imported.error.message); + const entry = imported.value.entries.find( + (item) => item.path === "events.pipe", + ); + expect(entry?.limitations.join(" ")).toContain( + "Entry is not a regular file", + ); + expect(entry?.limitations.join(" ")).not.toContain("supported system"); + }, + ); + + it("preserves an ordinary readable source-file control", async () => { + const root = await createTestTempDirectory("rea-import-native-readable-"); + await writeFile(join(root, "main.ts"), "export const value = 1;\n"); + const imported = await importTree(root); + if (!imported.ok) throw new Error(imported.error.message); + expect( + imported.value.entries.find(({ path }) => path === "main.ts"), + ).toMatchObject({ kind: "file", content_state: "hashed", limitations: [] }); + }); +}); diff --git a/tests/boundary/filesystem/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts index c3f15b2b8b..adec0d8474 100644 --- a/tests/boundary/filesystem/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -67,7 +67,7 @@ describe("reference source import error projection", () => { ).toEqual([malformed, unexpected]); }); - it("projects entry failures without low-level reader diagnostics", () => { + it("retains entry failure diagnostics alongside recovery guidance", () => { for (const [kind, code] of [ ["directory", "io"], ["symlink", "io"], @@ -80,11 +80,12 @@ describe("reference source import error projection", () => { path: "safe/path", kind, code, - message: "SECRET internal diagnostic /private/path", + message: "Observed entry failure at /owned/fixture/path", }); - expect(message).not.toContain("SECRET"); - expect(message).not.toContain("/private/path"); - expect(message).toMatch(/Check|try again|when ready|smaller|supported/u); + expect(message).toContain( + "Observed entry failure at /owned/fixture/path", + ); + expect(message).toMatch(/Check|try again|when ready|Exclude/u); } }); diff --git a/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts b/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts new file mode 100644 index 0000000000..d0b842c8e4 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts @@ -0,0 +1,97 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + ["main.ts", "dep.js", "dep.ts"], + ["main.mts", "dep.mjs", "dep.mts"], + ["main.cts", "dep.cjs", "dep.cts"], + ["main.tsx", "dep.js", "dep.tsx"], + ["main.ts", "dep.jsx", "dep.tsx"], + ["main.ts", "dep.js", "dep.d.ts"], + ["main.mts", "dep.mjs", "dep.d.mts"], + ["main.cts", "dep.cjs", "dep.d.cts"], + ["main.d.ts", "dep.js", "dep.d.ts"], + ["main.d.mts", "dep.mjs", "dep.d.mts"], + ["main.d.cts", "dep.cjs", "dep.d.cts"], +])( + "resolves %s runtime-extension imports to present source %s/%s", + async (from, specifier, target) => { + const root = await createTestTempDirectory("rea-reference-ts-extension-"); + await Promise.all([ + writeFile(join(root, from), `export { value } from "./${specifier}";\n`), + writeFile( + join(root, target), + target.includes(".d.") + ? "export declare const value: number;\n" + : "export const value = 1;\n", + ), + ]); + const imported = await importReferenceSource({ + root, + caller: "native-typescript-source", + policy: { secretPatterns: [] }, + }); + expect(imported.ok).toBe(true); + if (!imported.ok) return; + expect(imported.value.relationships).toContainEqual({ + from_path: from, + to: target, + kind: "imports", + resolution: "internal", + parse_state: "parsed", + }); + }, +); + +it.each(["main.ts", "main.js"])( + "retains an existing declared runtime file for %s", + async (from) => { + const root = await createTestTempDirectory("rea-reference-ts-control-"); + await Promise.all([ + writeFile(join(root, from), 'export { value } from "./dep.js";\n'), + writeFile(join(root, "dep.js"), "export const value = 2;\n"), + writeFile(join(root, "dep.ts"), "export const value = 1;\n"), + ]); + const imported = await importReferenceSource({ + root, + caller: "native-typescript-source", + policy: { secretPatterns: [] }, + }); + expect(imported.ok).toBe(true); + if (!imported.ok) return; + expect(imported.value.relationships).toContainEqual( + expect.objectContaining({ + from_path: from, + to: "dep.js", + resolution: "internal", + }), + ); + }, +); + +it("does not substitute TypeScript sources for a JavaScript runtime import", async () => { + const root = await createTestTempDirectory("rea-reference-js-control-"); + await Promise.all([ + writeFile(join(root, "main.js"), 'export { value } from "./dep.js";\n'), + writeFile(join(root, "dep.ts"), "export const value = 1;\n"), + ]); + const imported = await importReferenceSource({ + root, + caller: "native-typescript-source", + policy: { secretPatterns: [] }, + }); + expect(imported.ok).toBe(true); + if (!imported.ok) return; + expect(imported.value.relationships).toContainEqual( + expect.objectContaining({ + from_path: "main.js", + to: "dep.js", + resolution: "unresolved", + }), + ); +}); diff --git a/tests/boundary/mcp/clientCapabilities.test.ts b/tests/boundary/mcp/clientCapabilities.test.ts new file mode 100644 index 0000000000..f39d87e0fb --- /dev/null +++ b/tests/boundary/mcp/clientCapabilities.test.ts @@ -0,0 +1,56 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from "@modelcontextprotocol/server"; +import { describe, expect, it } from "vitest"; + +import { createServer } from "../../../src/server/createServer.js"; +import { + createCacheProvider, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; + +describe("per-request client capability reporting", () => { + it.each([ + [{ elicitation: {} }, true, false], + [{ elicitation: { form: {} } }, true, false], + [{ elicitation: { url: {} } }, false, true], + [{ elicitation: { form: {}, url: {} } }, true, true], + [{}, false, false], + ] as const)( + "reports the negotiated capability %j", + async (capabilities, form, url) => { + const session = createTestBinarySession(createCacheProvider([])); + const server = createServer(session, session); + const client = new Client({ name: "capabilities-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + _meta: { + [PROTOCOL_VERSION_META_KEY]: "2026-07-28", + [CLIENT_INFO_META_KEY]: { name: "capabilities-test", version: "1" }, + [CLIENT_CAPABILITIES_META_KEY]: capabilities, + }, + }); + expect(status.structuredContent).toMatchObject({ + result: { + client_features: { elicitation_form: form, elicitation_url: url }, + }, + }); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }, + ); +}); diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index 59ded77f09..33fc9303c2 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -319,3 +319,97 @@ const signatureRunner = (outputs: { ); }, }); + +it.each([ + "/tmp/Load command 12/plugin.dylib", + "@rpath/plugin-Load command 34.dylib", +])( + "keeps load-command-looking text inside native install names: %s", + (installName) => { + const parsed = parseOtoolLoadCommands(`Load command 0 + cmd LC_ID_DYLIB + cmdsize 80 + name ${installName} (offset 24) + time stamp 2 Thu Jan 1 00:00:02 1970 + current version 3.2.1 +compatibility version 1.0.0 +Load command 1 + cmd LC_UUID + cmdsize 24 + uuid 01234567-89AB-CDEF-0123-456789ABCDEF +`); + expect(parsed.commands.map(({ index, kind }) => ({ index, kind }))).toEqual( + [ + { index: 0, kind: "LC_ID_DYLIB" }, + { index: 1, kind: "LC_UUID" }, + ], + ); + expect(parsed.dependencies).toEqual([ + { + path: installName, + kind: "LC_ID_DYLIB", + current_version: "3.2.1", + compatibility_version: "1.0.0", + }, + ]); + expect(parsed.uuid).toBe("01234567-89AB-CDEF-0123-456789ABCDEF"); + }, +); +it.each(["weak-def", "absolute", "literal suffix"])( + "preserves an nm-proven export name ending in [%s]", + (suffix) => { + const name = `symbol [${suffix}]`; + expect( + parseDyldSymbols( + `offset symbol\n0x120 ${name}`, + "exports", + "0x1000", + new Set([name]), + ), + ).toEqual([ + { name, address: "0x1120", weak: null, reexport: false, source: null }, + ]); + }, +); + +it("keeps true weak annotations when nm establishes the unannotated name", () => { + expect( + parseDyldSymbols( + "0x120 _weak [weak-def]", + "exports", + null, + new Set(["_weak"]), + ), + ).toEqual([ + { + name: "_weak", + address: "0x120", + weak: true, + reexport: false, + source: null, + }, + ]); +}); + +it("leaves an ambiguous short and literal name to the caller's nm inventory", () => { + expect( + parseDyldSymbols( + "0x120 symbol [weak-def]", + "exports", + null, + new Set(["symbol", "symbol [weak-def]"]), + ), + ).toEqual([]); +}); + +it("retains annotation parsing without nm name evidence", () => { + expect(parseDyldSymbols("0x120 _weak [weak-def]", "exports")).toEqual([ + { + name: "_weak", + address: "0x120", + weak: true, + reexport: false, + source: null, + }, + ]); +}); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index a7d3b3b461..27adb9cd43 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -29,6 +29,19 @@ afterEach(async () => { }); describe("native macOS provider discovery and inspection", () => { + it.each(["ordinary.fixture", " spaced.fixture ", " leading", "trailing "])( + "preserves the exact signing identifier %j reported by codesign", + (identifier) => { + const parsed = parseCodeSignature( + `Identifier=${identifier}\nTeamIdentifier=not set\nFormat=Mach-O thin (arm64)\n`, + false, + ); + expect(parsed.identifier).toBe(identifier); + expect(parsed.team_identifier).toBeNull(); + expect(parsed.format).toBe("Mach-O thin (arm64)"); + }, + ); + it("retries a failed native tool resolution and caches only success", async () => { let resolutions = 0; const runner = new XcrunCommandRunner((tool) => { diff --git a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts index fb9de3c47a..5e2816454a 100644 --- a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts +++ b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts @@ -111,3 +111,99 @@ it.each([ ).toBe(true); }, ); + +it("projects universal segment evidence into the selected slice's container offsets", async () => { + const client = new NativeMacOSProvider( + new NativeFixtureRunner(), + "darwin", + ).createClient(nativeMachoTarget("/owned/universal")); + const result = await client.execute("inspect_macho", {}); + if (!result.ok) throw result.error; + expect(result.value.locations).toContainEqual({ + kind: "file-offset-range", + start: 32768, + end: 40960, + }); + expect(result.value.locations).not.toContainEqual({ + kind: "file-offset-range", + start: 0, + end: 8192, + }); + // The normalized tool field remains slice-relative; only evidence is projected. + expect( + inspectMachoSchema.parse(result.value.result).segments.items[0] + ?.file_offset, + ).toBe(0); +}); + +it.each([ + { + description: "thin", + lipo: "Non-fat file: /owned/input is architecture: arm64\n", + offset: 0, + }, + { + description: "one-slice FAT", + lipo: "architecture arm64\n offset 16384\n size 8192\n align 2^14 (16384)\n", + offset: 16384, + }, +])( + "keeps $description segment evidence in the input file's coordinate system", + async ({ lipo, offset }) => { + const base = nativeMachoTarget("/owned/input"); + if (base.kind !== "executable") + throw new Error("Expected executable fixture"); + const target = { ...base, availableArchitectures: ["arm64"] as const }; + const result = await new NativeMacOSProvider( + new NativeFixtureRunner({ lipo }), + "darwin", + ) + .createClient(target) + .execute("inspect_macho", {}); + if (!result.ok) throw result.error; + expect(result.value.locations).toContainEqual({ + kind: "file-offset-range", + start: offset, + end: offset + 8192, + }); + if (offset > 0) + expect(result.value.locations).not.toContainEqual({ + kind: "file-offset-range", + start: 0, + end: 8192, + }); + }, +); + +it.each([ + { + lipo: "architecture x86_64\n offset 16384\n size 8192\narchitecture arm64\n size 8192\n", + availableArchitectures: ["x86_64", "arm64"] as const, + }, + { + lipo: "architecture arm64\n size 8192\n", + availableArchitectures: ["arm64"] as const, + }, +])( + "does not invent a container offset when the selected slice has no observed offset ($availableArchitectures)", + async ({ lipo, availableArchitectures }) => { + const base = nativeMachoTarget("/owned/universal"); + if (base.kind !== "executable") + throw new Error("Expected executable fixture"); + const result = await new NativeMacOSProvider( + new NativeFixtureRunner({ lipo }), + "darwin", + ) + .createClient({ ...base, availableArchitectures }) + .execute("inspect_macho", {}); + if (!result.ok) throw result.error; + expect(result.value.locations).not.toContainEqual({ + kind: "file-offset-range", + start: 0, + end: 8192, + }); + expect(result.value.limitations).toContain( + "Segment evidence file offsets are unavailable because the selected Mach-O slice's container offset was not observed.", + ); + }, +); diff --git a/tests/boundary/providers/native/nativeTextRobustness.test.ts b/tests/boundary/providers/native/nativeTextRobustness.test.ts index daea94ff99..05ae9e0da6 100644 --- a/tests/boundary/providers/native/nativeTextRobustness.test.ts +++ b/tests/boundary/providers/native/nativeTextRobustness.test.ts @@ -2,10 +2,23 @@ import { expect, it } from "vitest"; import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js"; import { parsePlistJson } from "../../../../src/native/parsers/plist.js"; +import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js"; import { nativeFixture } from "../../../fixtures/nativeCommands.js"; const toCrlf = (value: string): string => value.replaceAll("\n", "\r\n"); +it.each([ + "line\nbreak", + "line\r\nbreak", + "name is architecture: x86_64\ncontinued", +])( + "retains the reported thin architecture when lipo echoes filename %j", + (filename) => { + const output = `input file /owned/${filename} is not a fat file\nNon-fat file: /owned/${filename} is architecture: arm64\n`; + expect(parseLipoArchitectures(output)).toMatchObject([{ name: "arm64" }]); + }, +); + it("keeps section blocks separate under CRLF captures", async () => { const lf = parseOtoolLoadCommands(await nativeFixture("otool-load.txt")); const crlf = parseOtoolLoadCommands(