From 4ff4c11a00c4ed32618d51e22dd176a646035a87 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:12:18 +0000 Subject: [PATCH 1/8] Add evidence-backed investigation workflows --- .github/workflows/ci.yml | 12 + bridge/hopper_bridge.py | 154 ++- metadata/completion-manifest.json | 454 +++++++ package-lock.json | 1202 ++++++++++++++++- package.json | 15 + scripts/generate-completion-manifest.mjs | 283 ++++ scripts/rea.mjs | 2 +- scripts/verify-package.mjs | 196 ++- scripts/verify-real-hopper.mjs | 339 ++--- src/application/AnalysisProvider.ts | 78 +- src/application/ArtifactExtraction.ts | 376 ++++++ src/application/ArtifactGraphConstruction.ts | 359 +++++ src/application/ArtifactInventory.ts | 380 ++++++ src/application/BinarySession.ts | 298 +++- src/application/CompositeProvider.ts | 96 ++ src/application/DirectAnalysis.ts | 57 +- src/application/EnhancedTools.ts | 35 +- src/application/EvidenceBundleCommands.ts | 56 + src/application/EvidenceBundleFiles.ts | 159 +++ src/application/EvidenceLedger.ts | 362 ++++- src/application/FilesystemSnapshot.ts | 113 ++ src/application/LoopbackReplay.ts | 276 +++- src/application/ProcessHarness.ts | 488 ++++--- src/application/ProcessNormalization.ts | 77 ++ src/application/ProcessOwnership.ts | 115 ++ src/application/ProcessSampling.ts | 350 +++++ src/application/RealHopperAssertions.ts | 135 ++ src/application/ReferenceSourceImport.ts | 260 ++++ .../ReferenceSourceImportEntries.ts | 166 +++ .../ReferenceSourceImportPolicy.ts | 122 ++ src/application/ReferenceSourceImportTypes.ts | 50 + src/application/ReferenceSourceVcsAdapter.ts | 55 + src/application/Setup.ts | 38 +- src/application/runtime.ts | 11 +- src/artifacts/ArtifactPaths.ts | 86 ++ src/artifacts/ArtifactProvider.ts | 212 +++ src/artifacts/ArtifactReader.ts | 58 + src/artifacts/AsarArtifactReader.ts | 80 ++ src/artifacts/DirectoryArtifactReader.ts | 139 ++ src/artifacts/MachOSliceArtifactReader.ts | 102 ++ src/artifacts/SafeOutputTree.ts | 321 +++++ src/artifacts/StreamBytes.ts | 7 + src/artifacts/ZipArtifactReader.ts | 138 ++ src/cli.ts | 173 ++- src/config.ts | 42 + src/contracts/artifactComparisonExample.ts | 98 ++ src/contracts/artifactToolContracts.ts | 111 ++ src/contracts/enhancedInputs.ts | 4 + src/contracts/functionComparisonExample.ts | 67 + src/contracts/investigationExamples.ts | 139 ++ src/contracts/nativeToolContracts.ts | 83 ++ src/contracts/processCaptureExample.ts | 27 + src/contracts/toolContractExamples.ts | 73 + src/contracts/toolContracts.ts | 237 +++- src/contracts/toolOutputSchemas.ts | 292 ++-- src/contracts/unknownContractExamples.ts | 35 + src/domain/artifactComparison.ts | 390 ++++++ src/domain/artifactGraph.ts | 239 ++++ src/domain/artifactInventoryEvidence.ts | 268 ++++ src/domain/binaryTarget.ts | 85 +- src/domain/bundleComparison.ts | 371 +++++ src/domain/callPath.ts | 466 +++++++ src/domain/changedBehavior.ts | 404 ++++++ src/domain/errors.ts | 272 +++- src/domain/evidence.ts | 70 +- src/domain/evidenceBundle.ts | 262 +++- src/domain/functionComparison.ts | 509 +++++++ src/domain/functionComparisonNormalization.ts | 171 +++ src/domain/functionComparisonResults.ts | 95 ++ src/domain/functionComparisonSchemas.ts | 94 ++ src/domain/functionDossierEvidence.ts | 261 ++++ src/domain/hopperValues.ts | 33 +- src/domain/nativeInspection.ts | 158 +++ src/domain/processCapture.ts | 287 ++-- src/domain/processComparison.ts | 160 +++ src/domain/reconstructionUnknowns.ts | 137 ++ src/domain/reconstructionVerification.ts | 422 ++++++ .../reconstructionVerificationSchemas.ts | 149 ++ src/domain/referenceSourceClassification.ts | 516 +++++++ src/domain/referenceSourceGraph.ts | 524 +++++++ src/domain/referenceSourceImportParsing.ts | 350 +++++ src/domain/referenceSourcePolicy.ts | 9 + src/domain/residualUnknown.ts | 304 +++++ src/domain/staticRuntimeCorrelation.ts | 513 +++++++ src/hopper/BridgeLauncher.ts | 54 +- src/hopper/HopperClient.ts | 19 +- src/hopper/HopperProvider.ts | 110 +- src/identity.ts | 1 + src/main.ts | 9 +- src/native/CommandRunner.ts | 254 ++++ src/native/NativeMacOSProvider.ts | 490 +++++++ src/native/NativeMachoInspection.ts | 206 +++ src/native/parsers/codesign.ts | 62 + src/native/parsers/demangle.ts | 32 + src/native/parsers/dyldInfo.ts | 29 + src/native/parsers/lipo.ts | 71 + src/native/parsers/otool.ts | 296 ++++ src/native/parsers/plist.ts | 24 + src/reference/ReferenceSourceReader.ts | 96 ++ src/reference/ReferenceSourceReaderEntries.ts | 302 +++++ src/reference/ReferenceSourceReaderErrors.ts | 39 + src/reference/ReferenceSourceReaderFile.ts | 246 ++++ src/reference/ReferenceSourceReaderPaths.ts | 27 + src/reference/ReferenceSourceReaderTypes.ts | 105 ++ .../ReferenceSourceReaderValidate.ts | 118 ++ src/server/createServer.ts | 52 +- src/server/recordDerivedEvidence.ts | 19 + src/server/registerArtifactComparisonTool.ts | 122 ++ src/server/registerArtifactTools.ts | 21 + src/server/registerBundleComparisonTool.ts | 92 ++ src/server/registerEnhancedTools.ts | 116 +- src/server/registerEvidenceTools.ts | 75 + src/server/registerFunctionComparisonTool.ts | 118 ++ src/server/registerInvestigationTools.ts | 383 ++++++ src/server/registerNativeTools.ts | 21 + src/server/registerOfficialTools.ts | 99 +- src/server/registerProcessComparisonTool.ts | 159 +++ src/server/registerSessionTools.ts | 402 ++++-- src/server/sessionToolPolicies.ts | 76 ++ src/server/toolResult.ts | 29 +- tests/artifactComparison.test.ts | 174 +++ tests/artifactMcp.test.ts | 145 ++ tests/artifactProvider.test.ts | 372 +++++ tests/binarySession.test.ts | 131 +- tests/binaryTarget.test.ts | 31 + tests/bridgeTruthfulness.test.ts | 31 +- tests/bundleComparison.test.ts | 205 +++ tests/bundleComparisonMcp.test.ts | 134 ++ tests/callPath.test.ts | 180 +++ tests/changedBehavior.test.ts | 214 +++ tests/completionManifest.test.ts | 49 + tests/compositeProvider.test.ts | 126 ++ tests/config.test.ts | 58 + tests/contractSnapshot.test.ts | 55 +- tests/createServer.test.ts | 2 +- tests/enhancedTools.test.ts | 20 +- tests/errors.test.ts | 77 ++ tests/evidence.test.ts | 209 ++- tests/evidenceBundleFiles.test.ts | 153 +++ tests/fixtures/analysisExecution.ts | 12 + tests/fixtures/fakeHopper.mjs | 8 +- tests/fixtures/fakeLauncher.mjs | 5 +- tests/fixtures/native-macos/codesign.txt | 10 + tests/fixtures/native-macos/demangle.txt | 2 + tests/fixtures/native-macos/dyld-exports.txt | 3 + tests/fixtures/native-macos/dyld-imports.txt | 3 + tests/fixtures/native-macos/entitlements.xml | 3 + tests/fixtures/native-macos/lipo-fat.txt | 15 + tests/fixtures/native-macos/lipo-thin.txt | 1 + tests/fixtures/native-macos/otool-load.txt | 49 + tests/fixtures/native-macos/plist.json | 7 + tests/fixtures/processFidelity.mjs | 46 + tests/functionComparison.test.ts | 205 +++ tests/functionComparisonMcp.test.ts | 100 ++ tests/hopperClient.test.ts | 1 + tests/hopperValues.test.ts | 25 +- tests/integration/configIntegration.test.ts | 2 + tests/integration/serverIntegration.test.ts | 212 ++- tests/investigationMcp.test.ts | 217 +++ tests/nativeProvider.test.ts | 235 ++++ tests/officialTools.test.ts | 10 +- tests/processCapture.test.ts | 428 +++++- tests/processOwnership.test.ts | 70 + tests/providerArchitecture.test.ts | 38 + tests/readmeConsistency.test.ts | 23 +- tests/realHopperAssertions.test.ts | 109 ++ tests/reconstructionVerification.test.ts | 330 +++++ tests/referenceSourceGraph.test.ts | 261 ++++ tests/referenceSourceImport.test.ts | 126 ++ tests/referenceSourceReader.test.ts | 224 +++ tests/residualUnknown.test.ts | 286 ++++ tests/runtime.test.ts | 8 +- tests/safeOutputTree.test.ts | 39 + tests/sessionMcp.test.ts | 140 +- tests/setupSkill.test.ts | 37 + tests/staticRuntimeCorrelation.test.ts | 244 ++++ tests/toolContracts.test.ts | 21 +- tests/toolResult.test.ts | 35 + 178 files changed, 26320 insertions(+), 1222 deletions(-) create mode 100644 metadata/completion-manifest.json create mode 100644 scripts/generate-completion-manifest.mjs create mode 100644 src/application/ArtifactExtraction.ts create mode 100644 src/application/ArtifactGraphConstruction.ts create mode 100644 src/application/ArtifactInventory.ts create mode 100644 src/application/CompositeProvider.ts create mode 100644 src/application/EvidenceBundleCommands.ts create mode 100644 src/application/EvidenceBundleFiles.ts create mode 100644 src/application/FilesystemSnapshot.ts create mode 100644 src/application/ProcessNormalization.ts create mode 100644 src/application/ProcessOwnership.ts create mode 100644 src/application/ProcessSampling.ts create mode 100644 src/application/RealHopperAssertions.ts create mode 100644 src/application/ReferenceSourceImport.ts create mode 100644 src/application/ReferenceSourceImportEntries.ts create mode 100644 src/application/ReferenceSourceImportPolicy.ts create mode 100644 src/application/ReferenceSourceImportTypes.ts create mode 100644 src/application/ReferenceSourceVcsAdapter.ts create mode 100644 src/artifacts/ArtifactPaths.ts create mode 100644 src/artifacts/ArtifactProvider.ts create mode 100644 src/artifacts/ArtifactReader.ts create mode 100644 src/artifacts/AsarArtifactReader.ts create mode 100644 src/artifacts/DirectoryArtifactReader.ts create mode 100644 src/artifacts/MachOSliceArtifactReader.ts create mode 100644 src/artifacts/SafeOutputTree.ts create mode 100644 src/artifacts/StreamBytes.ts create mode 100644 src/artifacts/ZipArtifactReader.ts create mode 100644 src/contracts/artifactComparisonExample.ts create mode 100644 src/contracts/artifactToolContracts.ts create mode 100644 src/contracts/functionComparisonExample.ts create mode 100644 src/contracts/investigationExamples.ts create mode 100644 src/contracts/nativeToolContracts.ts create mode 100644 src/contracts/processCaptureExample.ts create mode 100644 src/contracts/toolContractExamples.ts create mode 100644 src/contracts/unknownContractExamples.ts create mode 100644 src/domain/artifactComparison.ts create mode 100644 src/domain/artifactGraph.ts create mode 100644 src/domain/artifactInventoryEvidence.ts create mode 100644 src/domain/bundleComparison.ts create mode 100644 src/domain/callPath.ts create mode 100644 src/domain/changedBehavior.ts create mode 100644 src/domain/functionComparison.ts create mode 100644 src/domain/functionComparisonNormalization.ts create mode 100644 src/domain/functionComparisonResults.ts create mode 100644 src/domain/functionComparisonSchemas.ts create mode 100644 src/domain/functionDossierEvidence.ts create mode 100644 src/domain/nativeInspection.ts create mode 100644 src/domain/processComparison.ts create mode 100644 src/domain/reconstructionUnknowns.ts create mode 100644 src/domain/reconstructionVerification.ts create mode 100644 src/domain/reconstructionVerificationSchemas.ts create mode 100644 src/domain/referenceSourceClassification.ts create mode 100644 src/domain/referenceSourceGraph.ts create mode 100644 src/domain/referenceSourceImportParsing.ts create mode 100644 src/domain/referenceSourcePolicy.ts create mode 100644 src/domain/residualUnknown.ts create mode 100644 src/domain/staticRuntimeCorrelation.ts create mode 100644 src/native/CommandRunner.ts create mode 100644 src/native/NativeMacOSProvider.ts create mode 100644 src/native/NativeMachoInspection.ts create mode 100644 src/native/parsers/codesign.ts create mode 100644 src/native/parsers/demangle.ts create mode 100644 src/native/parsers/dyldInfo.ts create mode 100644 src/native/parsers/lipo.ts create mode 100644 src/native/parsers/otool.ts create mode 100644 src/native/parsers/plist.ts create mode 100644 src/reference/ReferenceSourceReader.ts create mode 100644 src/reference/ReferenceSourceReaderEntries.ts create mode 100644 src/reference/ReferenceSourceReaderErrors.ts create mode 100644 src/reference/ReferenceSourceReaderFile.ts create mode 100644 src/reference/ReferenceSourceReaderPaths.ts create mode 100644 src/reference/ReferenceSourceReaderTypes.ts create mode 100644 src/reference/ReferenceSourceReaderValidate.ts create mode 100644 src/server/recordDerivedEvidence.ts create mode 100644 src/server/registerArtifactComparisonTool.ts create mode 100644 src/server/registerArtifactTools.ts create mode 100644 src/server/registerBundleComparisonTool.ts create mode 100644 src/server/registerEvidenceTools.ts create mode 100644 src/server/registerFunctionComparisonTool.ts create mode 100644 src/server/registerInvestigationTools.ts create mode 100644 src/server/registerNativeTools.ts create mode 100644 src/server/registerProcessComparisonTool.ts create mode 100644 src/server/sessionToolPolicies.ts create mode 100644 tests/artifactComparison.test.ts create mode 100644 tests/artifactMcp.test.ts create mode 100644 tests/artifactProvider.test.ts create mode 100644 tests/bundleComparison.test.ts create mode 100644 tests/bundleComparisonMcp.test.ts create mode 100644 tests/callPath.test.ts create mode 100644 tests/changedBehavior.test.ts create mode 100644 tests/completionManifest.test.ts create mode 100644 tests/compositeProvider.test.ts create mode 100644 tests/errors.test.ts create mode 100644 tests/evidenceBundleFiles.test.ts create mode 100644 tests/fixtures/analysisExecution.ts create mode 100644 tests/fixtures/native-macos/codesign.txt create mode 100644 tests/fixtures/native-macos/demangle.txt create mode 100644 tests/fixtures/native-macos/dyld-exports.txt create mode 100644 tests/fixtures/native-macos/dyld-imports.txt create mode 100644 tests/fixtures/native-macos/entitlements.xml create mode 100644 tests/fixtures/native-macos/lipo-fat.txt create mode 100644 tests/fixtures/native-macos/lipo-thin.txt create mode 100644 tests/fixtures/native-macos/otool-load.txt create mode 100644 tests/fixtures/native-macos/plist.json create mode 100644 tests/fixtures/processFidelity.mjs create mode 100644 tests/functionComparison.test.ts create mode 100644 tests/functionComparisonMcp.test.ts create mode 100644 tests/investigationMcp.test.ts create mode 100644 tests/nativeProvider.test.ts create mode 100644 tests/processOwnership.test.ts create mode 100644 tests/realHopperAssertions.test.ts create mode 100644 tests/reconstructionVerification.test.ts create mode 100644 tests/referenceSourceGraph.test.ts create mode 100644 tests/referenceSourceImport.test.ts create mode 100644 tests/referenceSourceReader.test.ts create mode 100644 tests/residualUnknown.test.ts create mode 100644 tests/safeOutputTree.test.ts create mode 100644 tests/setupSkill.test.ts create mode 100644 tests/staticRuntimeCorrelation.test.ts create mode 100644 tests/toolResult.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f8f83813..abf73b862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,18 @@ jobs: - run: npm ci - run: npm run scan:todos + completion-metadata: + name: Completion Metadata Drift + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run completion:check + test: name: Test (coverage + timing) runs-on: ubuntu-latest diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 6287de4fe..de9d3297b 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -9,11 +9,15 @@ import json import hmac import os import re +import sre_parse import socket MAX_LINE_BYTES = 10 * 1024 * 1024 BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None) _selected_document = None +_search_inventory_cache = {} +MAX_SEARCH_PATTERN_LENGTH = 256 +MAX_SEARCH_VALUE_LENGTH = 4096 def _hex(value): @@ -89,6 +93,17 @@ def _procedure_identity(procedure): return {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure)} +def _procedure_locals(procedure): + """Project opaque Hopper local-variable objects into an exact public shape.""" + return [ + { + "description": str(local), + "provenance": "hopper-public-python-api", + } + for local in procedure.getLocalVariableList() + ] + + def _containing_procedure(document, address): segment = document.getSegmentAtAddress(address) if segment is None: @@ -182,6 +197,105 @@ def _strings(document): return result +def _invalidate_search_inventory(document): + """Discard derived names after analysis metadata changes.""" + document_id = id(document) + for key in list(_search_inventory_cache): + if key[0] == document_id: + del _search_inventory_cache[key] + + +def _search_inventory(document, kind): + """Cache an immutable, address-sorted inventory for an unchanged document.""" + key = (id(document), kind) + inventory = _search_inventory_cache.get(key) + if inventory is None: + values = _procedure_map(document) if kind == "procedure" else _strings(document) + inventory = tuple(sorted(values.items(), key=lambda item: int(item[0], 16))) + _search_inventory_cache[key] = inventory + return inventory + + +def _validate_regex_node(node, inside_repeat=False): + """Reject regex structures with disproportionate or non-local evaluation cost.""" + forbidden = { + sre_parse.ASSERT, + sre_parse.ASSERT_NOT, + sre_parse.GROUPREF, + sre_parse.GROUPREF_EXISTS, + } + repeat_tokens = {sre_parse.MAX_REPEAT, sre_parse.MIN_REPEAT} + possessive = getattr(sre_parse, "POSSESSIVE_REPEAT", None) + if possessive is not None: + repeat_tokens.add(possessive) + for operation, argument in node: + if operation in forbidden: + raise ValueError("Regex lookarounds and backreferences are not supported") + if operation in repeat_tokens: + if inside_repeat: + raise ValueError("Nested regex repetitions are not supported") + minimum, maximum, child = argument + if maximum == sre_parse.MAXREPEAT or maximum > 1000: + raise ValueError("Unbounded or excessive regex repetitions are not supported") + _validate_regex_node(child, True) + elif operation == sre_parse.SUBPATTERN: + _validate_regex_node(argument[-1], inside_repeat) + elif operation == sre_parse.BRANCH: + for branch in argument[1]: + _validate_regex_node(branch, inside_repeat) + + +def _search_page(document, kind, params): + pattern = params.get("pattern") + if not isinstance(pattern, str) or not pattern or len(pattern) > MAX_SEARCH_PATTERN_LENGTH: + raise ValueError("pattern must contain between 1 and 256 characters") + mode = params.get("mode", "literal") + if mode not in ("literal", "regex"): + raise ValueError("mode must be literal or regex") + case_sensitive = params.get("case_sensitive", False) + if not isinstance(case_sensitive, bool): + raise ValueError("case_sensitive must be a boolean") + offset = params.get("offset", 0) + limit = params.get("limit", 100) + if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: + raise ValueError("offset must be a non-negative integer") + if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100: + raise ValueError("limit must be an integer between 1 and 100") + + if mode == "literal": + needle = pattern if case_sensitive else pattern.casefold() + matches = lambda value: needle in (value if case_sensitive else value.casefold()) + else: + try: + parsed = sre_parse.parse(pattern) + _validate_regex_node(parsed) + expression = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE) + except re.error as error: + raise ValueError("Invalid regex pattern") from error + matches = lambda value: expression.search(value) is not None + + matching = [item for item in _search_inventory(document, kind) if matches(item[1])] + selected = matching[offset:offset + limit] + total = len(matching) + next_offset = offset + len(selected) + has_more = next_offset < total + return { + "items": [ + { + "address": address, + "value": value[:MAX_SEARCH_VALUE_LENGTH], + "value_truncated": len(value) > MAX_SEARCH_VALUE_LENGTH, + } + for address, value in selected + ], + "offset": offset, + "limit": limit, + "total": total, + "next_offset": next_offset if has_more else None, + "has_more": has_more, + } + + def _page(values, offset, limit): """Return one deterministically address-sorted page without crossing an unbounded map.""" if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0: @@ -354,7 +468,7 @@ def _analyze_function(document, params): def collection(name, items, scan_limited=False): return _bounded(items, _collection_offset(params, name), limit, None, scan_limited) return { - "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())}, + "procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _procedure_locals(procedure)}, "pseudocode": {"text": pseudo_text, "total_chars": len(pseudo), "returned_chars": len(pseudo_text), "truncated": pseudo_next < len(pseudo), "next_offset": pseudo_next if pseudo_next < len(pseudo) else None}, "assembly": _bounded(assembly_lines, assembly_offset, max_instructions), "comments": collection("comments", comments, instruction_scan_truncated), @@ -372,7 +486,7 @@ def _dispatch(method, params): """Dispatch only the closed operation set implemented by REA's public tools.""" global _selected_document if method == "health": - return {"name": "REA Hopper bridge", "version": "1.0.0"} + return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID} if method == "shutdown": return {"shutdown": True} if method == "list_documents": @@ -426,18 +540,30 @@ def _dispatch(method, params): return _hex(result) if method == "list_segments": result = [] + permission_limitation = _unavailable( + "Hopper's public Python API does not expose segment or section permissions" + ) for segment in document.getSegmentsList(): start = segment.getStartingAddress() - sections = [{"name": section.getName(), "start": _hex(section.getStartingAddress()), "end": _hex(section.getStartingAddress() + section.getLength())} for section in segment.getSectionsList()] + sections = [{ + "name": section.getName(), + "start": _hex(section.getStartingAddress()), + "end": _hex(section.getStartingAddress() + section.getLength()), + "readable": None, + "writable": None, + "executable": None, + "permissions": permission_limitation, + "provenance": "hopper-public-python-api", + } for section in segment.getSectionsList()] result.append({ "name": segment.getName(), "start": _hex(start), "end": _hex(start + segment.getLength()), + "readable": None, "writable": None, "executable": None, - "permissions": _unavailable( - "Hopper's public Python API does not expose segment permissions" - ), + "permissions": permission_limitation, + "provenance": "hopper-public-python-api", "sections": sections, }) return result @@ -461,10 +587,8 @@ def _dispatch(method, params): result = {key: result[key]} if key in result else {} return _page(result, params.get("offset", 0), params.get("limit", 100)) if method in ("search_procedures", "search_strings"): - flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE - expression = re.compile(params["pattern"], flags) - values = _procedure_map(document) if method == "search_procedures" else _strings(document) - return {key: value for key, value in values.items() if expression.search(value)} + kind = "procedure" if method == "search_procedures" else "string" + return _search_page(document, kind, params) if method.startswith("procedure_"): procedure = _procedure(document, params.get("procedure")) if method == "procedure_address": @@ -480,12 +604,16 @@ def _dispatch(method, params): if method == "procedure_info": blocks = list(procedure.basicBlockIterator()) length = sum(max(0, block.getEndingAddress() - block.getStartingAddress()) for block in blocks) - return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": procedure.getLocalVariableList()} + return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": _procedure_locals(procedure)} if method == "set_address_name": address = _address(document, params.get("address")) - return document.setNameAtAddress(address, params["name"]) + result = document.setNameAtAddress(address, params["name"]) + _invalidate_search_inventory(document) + return result if method == "set_addresses_names": - return {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()} + result = {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()} + _invalidate_search_inventory(document) + return result if method in ("set_comment", "set_inline_comment"): address = _address(document, params.get("address")) segment = _segment(document, address) diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json new file mode 100644 index 000000000..cc237cdb1 --- /dev/null +++ b/metadata/completion-manifest.json @@ -0,0 +1,454 @@ +{ + "schema_version": 1, + "inputs": { + "artifacts": [ + { + "path": "bridge/hopper_bridge.py", + "sha256": "ca3a5029485b5ded221d4c0e3ccde91a06cb48246ca44d9cac35be298b1ace8d" + }, + { + "path": "package-lock.json", + "sha256": "29e6cf54c4e58f0fefc98f8454715cb89de51bbc8ddded7b2b3906e7d7ba6d7f" + }, + { + "path": "scripts/verify-conformance-fixtures.mjs", + "sha256": "7d0a3cb47332076c28a2214833459f78b2809d69b624b7e22d53bd130531a545" + }, + { + "path": "scripts/verify-package.mjs", + "sha256": "ffad65be3d5dde87c1e2d7b8886949066531e5b82933596b947507aab5f45b58" + }, + { + "path": "scripts/verify-real-hopper.mjs", + "sha256": "dfcb025fc12e2b6b6bc1e7984d797471b9239f3abf4361a9e1118cac0261fe1d" + }, + { + "path": "skills/rea-analysis/SKILL.md", + "sha256": "d5a3a5329e7db590a06f5b0b1af6551704579b78711d9c6a229715b5a832906e" + }, + { + "path": "src/application/AnalysisProvider.ts", + "sha256": "0871b9a0bcb1d29c4442d904ee4c1932a6a598cf296811529de4bdebf69f729f" + }, + { + "path": "src/application/ArtifactExtraction.ts", + "sha256": "6683c35a361ac680b1f9ec557376b80acbde65a1418315f5359afe4e0e0ff7ff" + }, + { + "path": "src/application/ArtifactGraphConstruction.ts", + "sha256": "151651a3df972591f556e3546a536a5cc87fdb1d3f38d52ea58ad8476e1cf9ec" + }, + { + "path": "src/application/ArtifactInventory.ts", + "sha256": "cc8109ff0b4b64d379211d997be50c936854e9aa1bba27f5180d586d709512b7" + }, + { + "path": "src/application/CompositeProvider.ts", + "sha256": "af2d8565d442665a3e697886d880998336e1a79043152858d83ce4aed51eb28c" + }, + { + "path": "src/application/EvidenceLedger.ts", + "sha256": "8650bc0965bcd4e66cca78465d9387d5f436ab9b9064fb50b012d48048862875" + }, + { + "path": "src/application/DirectAnalysis.ts", + "sha256": "0d986965684d024bb51446d64998b996c16aa26a919e4903ce09070c533be5f1" + }, + { + "path": "src/application/ProcessSampling.ts", + "sha256": "032c65bb3ee269fc865ba5da8aed651a4edd4b677644ba7893da2e486d3d8a7c" + }, + { + "path": "src/application/ReferenceSourceImport.ts", + "sha256": "9303a27e47d0e20f66c14b3586638b811146a44d0dc40d852c440c398e323b51" + }, + { + "path": "src/application/ReferenceSourceImportEntries.ts", + "sha256": "078272e9cdbd86166c89bffca03e3088b0fdc524e2a907c3d21814dca0229377" + }, + { + "path": "src/application/ReferenceSourceImportPolicy.ts", + "sha256": "70d2c4805dc4e1d7b2527a55a13c11da61374daebe203f3b94656fe9f9d614dd" + }, + { + "path": "src/application/ReferenceSourceImportTypes.ts", + "sha256": "cbb3ebd4d6a8e624fe8c5ef7b92fee19b60a9abe5a284a9e18c0f2a04bc4d8cd" + }, + { + "path": "src/application/ReferenceSourceVcsAdapter.ts", + "sha256": "cc0cb9333a77c17da55c4f55f81426819686722b5ccf3be3a48208f7eb0ac530" + }, + { + "path": "src/application/RealHopperAssertions.ts", + "sha256": "3ac4c9a6432c7bfc1f9f7733424edb087d8703f3b46a8cc9e2063bd7c9026e7c" + }, + { + "path": "src/application/runtime.ts", + "sha256": "af814fb413b083c07340939d49bb7db090656ff26fb1aebace53d4ee422d1652" + }, + { + "path": "src/contracts/nativeToolContracts.ts", + "sha256": "6c2a6120f842693430b200ec0ff1a384132d0f810d99b9e451a5fbb4fa48da8e" + }, + { + "path": "src/contracts/artifactToolContracts.ts", + "sha256": "92c6b74125c8312e7cffe9c8927bd9092a9a16696c5e57e21b3ab11a789664b6" + }, + { + "path": "src/contracts/artifactComparisonExample.ts", + "sha256": "77b9eaa5deda38e11af2dca7e79ed43397b3ed57407169dc2799e300d6a9dc4f" + }, + { + "path": "src/contracts/functionComparisonExample.ts", + "sha256": "c64d9166b9377e5f305dfdc5468a66fceb143919e11fd8145e4878ab591d28bd" + }, + { + "path": "src/contracts/investigationExamples.ts", + "sha256": "d4abbe096c1463918fb3ced25420b81f9d06333094b2d4563f777cfa3adefbd4" + }, + { + "path": "src/contracts/toolContractExamples.ts", + "sha256": "b03fa4ea9bbc5f5fcace06b4863d3121c28e6ef9e3c3b2e304402f837d328c9d" + }, + { + "path": "src/contracts/processCaptureExample.ts", + "sha256": "68e52c7378b9b7da34cb31a93eebd44f6425ea40971b589f0399afefbc5709b3" + }, + { + "path": "src/contracts/referenceSourceContracts.ts", + "sha256": "5f6a75bb335267a788b9977b253f788a42c3a5b65947f06b532096b1cfda1c27" + }, + { + "path": "src/contracts/enhancedInputs.ts", + "sha256": "76bc9bc963a7a65800fe4ecbfec17c2f57410a42c116b6f5fe65a3b033fdb29e" + }, + { + "path": "src/contracts/toolContracts.ts", + "sha256": "de3252f67aae3514b478b2d5a3e8dffa3dcc1cb96eaa0f4ba3cad1267fdc92e3" + }, + { + "path": "src/contracts/toolOutputSchemas.ts", + "sha256": "40e69c1e2aad9708eee9a456f764c4125a6ffd8d031e5e501a7329734cdb9d99" + }, + { + "path": "src/contracts/unknownContractExamples.ts", + "sha256": "11a777f711d8876391ea7a22f3e29c3b461b0be048e9ddc3c6cf506324c6844c" + }, + { + "path": "src/domain/evidence.ts", + "sha256": "4603f33b7b20c24ee36440b66ed89fa57f5ffeb7aaf9a8a1be0dc5bcfc04ad27" + }, + { + "path": "src/domain/artifactGraph.ts", + "sha256": "7ffb5692fa22ecda416b5662788d1f530a320f0ec82706bc960d77213d1f7cbd" + }, + { + "path": "src/domain/artifactComparison.ts", + "sha256": "35e652cf283e5c58584dfc6e2d63d6c0f18e01e9bbea45404cfbbdd820519732" + }, + { + "path": "src/domain/artifactInventoryEvidence.ts", + "sha256": "c605faf52d674ed18568606cdc98fd9cbbd48228779d6afd1979b672b4ed237a" + }, + { + "path": "src/domain/functionComparison.ts", + "sha256": "b55bdfd7ad8581d793ff1816d6b9142efa45a00ec1161e9c88ab093c0cbbe76a" + }, + { + "path": "src/domain/functionComparisonNormalization.ts", + "sha256": "8ec6893102e1f3326415934d801f33ceac92dc0f04b01afe5b6527fa2866de16" + }, + { + "path": "src/domain/functionComparisonResults.ts", + "sha256": "01941b2174c7b206255058387bba847c6c313ccec09870e331c8594aca8d2cb5" + }, + { + "path": "src/domain/functionComparisonSchemas.ts", + "sha256": "ca40e147bd4a754978766f88d8bb3ad51eff22bce1a6194dc483ae3765d80ba8" + }, + { + "path": "src/domain/bundleComparison.ts", + "sha256": "83f6a56479eda9253e726c88c85fc4eef466f1c5eee6dd6cc94b54164c5e1c3e" + }, + { + "path": "src/domain/changedBehavior.ts", + "sha256": "8beda9f951f497a3947c234fc4f756cd6f908fba49c8cd3f1ff0986faa8ac912" + }, + { + "path": "src/domain/callPath.ts", + "sha256": "f08c5bf656efc0f569ca3895171f9473cb1c62e5381dc890209002d0f379b940" + }, + { + "path": "src/domain/staticRuntimeCorrelation.ts", + "sha256": "c38cc12c468ab1b9899e1f431969dc0936d6273ad6ef585f56a63efd172f32b5" + }, + { + "path": "src/domain/reconstructionVerification.ts", + "sha256": "4ed0b8b06d5c8b3a10d56acef222fbe2a615b0277eed7997f158e16d8e108e8d" + }, + { + "path": "src/domain/reconstructionVerificationSchemas.ts", + "sha256": "0c35560e358ff0b00f3dbcacc40a198b6a561eaaf23fb5c8a50eac35d4031207" + }, + { + "path": "src/domain/reconstructionUnknowns.ts", + "sha256": "5a174aa799877e4aa171fbfbfb3d19e5e4ee1737d2049d0ce7c1297b651f6b64" + }, + { + "path": "src/domain/referenceSourceGraph.ts", + "sha256": "c736fd9d85c5ab145ea56d3b20442ec19f2477eb09563b9b6c3b287e88cd40a0" + }, + { + "path": "src/domain/referenceSourceClassification.ts", + "sha256": "9ad269b9ba0a0abe13c157d40d219c7cc461aa2c8b99a71baad62b42f03e5741" + }, + { + "path": "src/domain/referenceSourceImportParsing.ts", + "sha256": "ef2a7246ecc75672324f745cba4dbe7f941d5186f7936d8724611590c9e014e9" + }, + { + "path": "src/domain/referenceSourcePolicy.ts", + "sha256": "f7e49b8ffdb231b2f4fd18a4efdc50d87187a45915a02fa3ee6eb4d9d0dd55d4" + }, + { + "path": "src/domain/functionDossierEvidence.ts", + "sha256": "74310ca13cd3c3b53f658899608482e15e32853ae2f0dd12f8093e3ce1d06ea3" + }, + { + "path": "src/domain/evidenceBundle.ts", + "sha256": "6f75b6f6b2ca583a33c003a7fd3575015021fbf3290284f86474c0af2ebfbd3a" + }, + { + "path": "src/domain/nativeInspection.ts", + "sha256": "b800c075d10033555cd5279cd8e019bdd935fe7389b2919e148bebdfc6f6b52e" + }, + { + "path": "src/domain/processCapture.ts", + "sha256": "ef003dfd187a7963e7e9a3362125a75b44cea3bbc2ba3fdc69ea23d4b46b05dd" + }, + { + "path": "src/domain/residualUnknown.ts", + "sha256": "ab99fb3d8129655531e7791c2f69f4283ad5d5cdbd0f36ffcb7f2c7441428ac8" + }, + { + "path": "src/server/sessionToolPolicies.ts", + "sha256": "c972c399d40aef70510ad8fe33ec30eac046095195c1fb2185636f32fd062067" + }, + { + "path": "src/server/registerEnhancedTools.ts", + "sha256": "5e3b912090d77a6a03dbd4824163bbc2140b6f091fb302161c3ddb12f2d9cd7f" + }, + { + "path": "src/server/registerEvidenceTools.ts", + "sha256": "b295f94cacc911e416240c9bd46ccf3438ebc28169286e08e14937f02b8403cf" + }, + { + "path": "src/server/registerArtifactTools.ts", + "sha256": "67a9245471a1c5506a861925265dffc0b6cafa034ad2cb55996050b33497c9ed" + }, + { + "path": "src/server/registerArtifactComparisonTool.ts", + "sha256": "1cac756ded85945b31abafa956e01ed8732f472537687690fe6126a4ee884e32" + }, + { + "path": "src/server/registerFunctionComparisonTool.ts", + "sha256": "79ba262147e695ff0f84eb438807e574decb9cffd6e37189c7512b1ef0e7ac77" + }, + { + "path": "src/server/registerBundleComparisonTool.ts", + "sha256": "8ca624ddeb0d53ffa8a776954cb02e5a7612d5bfd435d11f07f19af708cca889" + }, + { + "path": "src/server/registerInvestigationTools.ts", + "sha256": "406d128beb86b1587f1eb4cd8292ab4b5eeb0e388cb1ad9ca10e7da67f36769b" + }, + { + "path": "src/server/recordDerivedEvidence.ts", + "sha256": "d5bcd67234b62483d4761520f1e1e8189940c81d6059816cc179cd5da5ff1cfe" + }, + { + "path": "src/server/registerNativeTools.ts", + "sha256": "96fe0e62b3d60ad3c74d3abcadc746ff6359a71cd98e6b922b5b6dbeb3b4423f" + }, + { + "path": "src/server/registerProcessComparisonTool.ts", + "sha256": "0771f98bf838bf5005c47534473d5eaa80b89d00b95d003c8ffa6e44764cf763" + }, + { + "path": "src/server/registerSessionTools.ts", + "sha256": "31decb6704f75cfc1e1a60a9ac1987a20a8530817ec919a679547d93606a94cd" + }, + { + "path": "src/native/CommandRunner.ts", + "sha256": "161ce2fea8b0dda8595b878889f78925e3a157c008ad0baaec7fafea584d8f39" + }, + { + "path": "src/native/NativeMacOSProvider.ts", + "sha256": "ed5fe6f58bbeda1e1811583ed943b948a70f784f39170d35b466efd22353a088" + }, + { + "path": "src/native/NativeMachoInspection.ts", + "sha256": "515bf5cbdf686b282058d2f38a54dacfb6f753d2401100fbc3ada5b21a61978c" + }, + { + "path": "src/native/parsers/codesign.ts", + "sha256": "6b14a03a65fe9597d680e4ed228523cd36bf7b314816be21b59483bfcfacb638" + }, + { + "path": "src/native/parsers/demangle.ts", + "sha256": "73d45dcb695a39cb047db02ae7f811db770fa0df5281b6eadc7f364be32c9985" + }, + { + "path": "src/native/parsers/dyldInfo.ts", + "sha256": "76c8359906a119e14bbc916c6167aa5a83ddc275fa02881616af374a7e6cfbd2" + }, + { + "path": "src/native/parsers/lipo.ts", + "sha256": "54c6963d0a58c55a677ee7ef8896248dc99a437cad672290df8ec2c52a88badb" + }, + { + "path": "src/native/parsers/otool.ts", + "sha256": "201588a480e4cd4522e01fbedfa783245b1de0d573b17ad9a1e71816cc104a9d" + }, + { + "path": "src/native/parsers/plist.ts", + "sha256": "ef3c59896c438f801db10de82418a72b35786ceca3ec2ee2556bc54e48559fe5" + }, + { + "path": "src/artifacts/ArtifactPaths.ts", + "sha256": "4338bae33e495e6065d7cd9f021209ca72b9172f22084a00745d2abf6075d69f" + }, + { + "path": "src/artifacts/ArtifactProvider.ts", + "sha256": "8230a07b52378e545dd3988b984c6ed0c80ee0e81d538981ef3a70bb8a3a19e3" + }, + { + "path": "src/artifacts/ArtifactReader.ts", + "sha256": "8a792031af0f689fc3978e696f591fb67d0e17a454726d8d3aaef700ba256b45" + }, + { + "path": "src/artifacts/AsarArtifactReader.ts", + "sha256": "1363467183c4ac264bdaaccf55a7b6e48243ce85f138feffe901e34042d1b520" + }, + { + "path": "src/artifacts/DirectoryArtifactReader.ts", + "sha256": "7643b591cb21f6c11119625816e7a863c40fbc5c9925e20b9bda85d7d11c3973" + }, + { + "path": "src/artifacts/MachOSliceArtifactReader.ts", + "sha256": "8d8b03250dd3fc9f83fd3a155a561335acd6a4e88274e95974144b89030f8c63" + }, + { + "path": "src/artifacts/SafeOutputTree.ts", + "sha256": "0cc8db23d75cc19edc6072fc6b17f63074afe52d65f51a4599c268c2d233d8f6" + }, + { + "path": "src/artifacts/StreamBytes.ts", + "sha256": "af717ba28133a48c0bb95c415aab7432c221a3e1fbf73277d981d49059cae32a" + }, + { + "path": "src/artifacts/ZipArtifactReader.ts", + "sha256": "4d8581522d795578b3446ede27642b35c49879505cb019fe9b929073f7261139" + }, + { + "path": "src/reference/ReferenceSourceReaderEntries.ts", + "sha256": "90ebecd4847bfa335fa806d957067a9885f572b5392bbf6148ca9a7c0b068ca4" + }, + { + "path": "src/reference/ReferenceSourceReaderErrors.ts", + "sha256": "c168294b8e4279d2cfee2e5a2692df7a0af8cd819f2757683328fc553a9bd402" + }, + { + "path": "src/reference/ReferenceSourceReaderFile.ts", + "sha256": "3b95030cb842505d7742f7c225eb7141e0de42611a0d7b7cbec88394a27210e5" + }, + { + "path": "src/reference/ReferenceSourceReaderPaths.ts", + "sha256": "7b4b903f6115573b4219615133bd87d15a2a6a07a336d056145bcfbae1e3e87f" + }, + { + "path": "src/reference/ReferenceSourceReaderTypes.ts", + "sha256": "dbf56e376ea4106ae87153248a0207e6e7940d56f1009b59d536addc29c331a2" + }, + { + "path": "src/reference/ReferenceSourceReaderValidate.ts", + "sha256": "2deb2a5ea85a440c6aec5dd6b647086d9c5cdb98cf6628e8aefe214227ca1643" + }, + { + "path": "src/reference/ReferenceSourceReader.ts", + "sha256": "fad544b78324dcbb66dd9cd67bfd0050c8466f958ae4569377a74132bab5fdd3" + } + ], + "package": { + "name": "rea-agents", + "version": "0.3.0" + }, + "providers": [ + { + "id": "hopper", + "version": null, + "authority": "shipped-artifact" + }, + { + "id": "native-macos", + "version": null, + "authority": "shipped-artifact" + }, + { + "id": "rea-artifact-graph", + "version": "1", + "authority": "shipped-artifact" + } + ], + "environments": [ + { + "id": "package-isolation", + "isolation": "process" + }, + { + "id": "real-hopper-macos", + "isolation": "process" + } + ], + "scenarios": [ + { + "id": "conformance-fixtures", + "version": 1 + }, + { + "id": "package-install", + "version": 1 + }, + { + "id": "real-hopper-two-binary", + "version": 2 + } + ], + "schemas": { + "evidence": 2, + "evidence_bundle": 2, + "completion": 1 + }, + "tool_count": 68 + }, + "evidence_index": [], + "outcomes": { + "pass": [], + "fail": [], + "unsupported": [ + { + "claim_id": "real-hopper-verification", + "reason": "Generated repository metadata contains no checked-in real-Hopper capture.", + "evidence_ids": [] + } + ], + "truncated": [], + "unknown": [ + { + "claim_id": "semantic-conformance-complete", + "reason": "Completion requires external verifier evidence and cannot be inferred from test counts.", + "evidence_ids": [] + } + ] + } +} diff --git a/package-lock.json b/package-lock.json index 48216e4ed..795959ffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,22 @@ "version": "0.3.0", "license": "MIT", "dependencies": { + "@babel/parser": "8.0.4", + "@babel/types": "8.0.4", + "@electron/asar": "4.2.0", "@modelcontextprotocol/server": "2.0.0-beta.3", + "@zip.js/zip.js": "2.8.26", "canonicalize": "3.0.0", + "diff": "9.0.0", + "graphology": "0.26.0", + "graphology-shortest-path": "2.1.0", + "ignore": "7.0.6", "incur": "0.4.13", + "isomorphic-git": "1.38.7", "node-pty": "1.1.0", "pino": "9.14.0", + "plist": "5.0.0", + "write-file-atomic": "^8.0.0", "ws": "8.21.0", "zod": "4.4.3" }, @@ -21,8 +32,10 @@ "rea": "scripts/rea.mjs" }, "devDependencies": { + "@fast-check/vitest": "^0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.3", "@types/node": "24.10.1", + "@types/write-file-atomic": "^4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", "husky": "9.1.7", @@ -41,53 +54,49 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^8.0.4" }, "bin": { "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.0.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@bcoe/v8-coverage": { @@ -106,6 +115,32 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, + "node_modules/@electron/asar": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.0.tgz", + "integrity": "sha512-npW1NW5yy8EB9XY/vEw9sUdgmq0sJEhmSBb6bqyFOAw1CSkrhvAvO6QWlW8CdIMo8VN1lkdF345l/MeW0LrY0Q==", + "license": "MIT", + "dependencies": { + "commander": "^13.1.0", + "glob": "^13.0.2", + "minimatch": "^10.0.1" + }, + "bin": { + "asar": "bin/asar.mjs" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", @@ -142,6 +177,29 @@ "tslib": "^2.4.0" } }, + "node_modules/@fast-check/vitest": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@fast-check/vitest/-/vitest-0.4.1.tgz", + "integrity": "sha512-OX6TecB+ZzRfPc49jcPChcV1cf5aDu77CdDU8liecWVam7eD7mPvJNP50b9bBSTxVoGN5wzxrX9PIcCnqgC8bg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "fast-check": "^3.0.0 || ^4.0.0" + }, + "peerDependencies": { + "vitest": "^4.1.0" + } + }, "node_modules/@gerrit0/mini-shiki": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", @@ -1652,6 +1710,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/write-file-atomic": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/write-file-atomic/-/write-file-atomic-4.0.3.tgz", + "integrity": "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1806,6 +1874,44 @@ "url": "https://opencollective.com/vitest" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/@yomguithereal/helpers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@yomguithereal/helpers/-/helpers-1.1.1.tgz", + "integrity": "sha512-UYvAq/XCA7xoh1juWDYsq3W0WywOB+pz8cgVnE1b45ZfdMhBvHDrgmSFG3jXeZSr2tMTYLGHFHON+ekG05Jebg==", + "license": "MIT" + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1877,6 +1983,12 @@ "js-tokens": "^10.0.0" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1886,6 +1998,133 @@ "node": ">=8.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/canonicalize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", @@ -1908,6 +2147,12 @@ "node": ">=18" } }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "license": "Apache-2.0" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1965,6 +2210,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1980,6 +2237,38 @@ "node": ">= 8" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1990,6 +2279,35 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -2023,6 +2341,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", @@ -2030,6 +2366,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2040,6 +2388,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2047,6 +2404,15 @@ "dev": true, "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -2080,6 +2446,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fd-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", @@ -2108,6 +2497,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -2139,6 +2543,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -2152,6 +2565,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -2165,6 +2615,91 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-shortest-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/graphology-shortest-path/-/graphology-shortest-path-2.1.0.tgz", + "integrity": "sha512-KbT9CTkP/u72vGEJzyRr24xFC7usI9Es3LMmCPHGwQ1KTsoZjxwA9lMKxfU0syvT/w+7fZUdB/Hu2wWYcJBm6Q==", + "license": "MIT", + "dependencies": { + "@yomguithereal/helpers": "^1.1.1", + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.3", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2175,6 +2710,57 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2198,6 +2784,35 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/incur": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/incur/-/incur-0.4.13.tgz", @@ -2220,6 +2835,24 @@ "node": ">=22" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -2236,6 +2869,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2243,6 +2897,40 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-git": { + "version": "1.38.7", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.7.tgz", + "integrity": "sha512-2J2rCV7gTgcpscEDT7PjU5shbsKKI10E4RqBlPDI3LmMXDjSKsN2w1jTIKiay14v87sPDfXjJkgp/Rrxc0zlSg==", + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2813,6 +3501,15 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -2842,6 +3539,56 @@ "source-map-js": "^1.2.1" } }, + "node_modules/magicast/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/magicast/node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -2886,6 +3633,15 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -2906,6 +3662,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -2941,6 +3760,12 @@ "node-addon-api": "^7.1.0" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -2964,6 +3789,15 @@ "node": ">=14.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -3108,6 +3942,12 @@ } } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3118,6 +3958,22 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3145,6 +4001,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -3192,6 +4057,28 @@ "node": ">=16.20.0" } }, + "node_modules/plist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-5.0.0.tgz", + "integrity": "sha512-20N+g1DvMm/DFRbsvER7tT4wDryq0WunK7VMkDaiJcKNapAnUMkTsAnacFYf8n420F4Hf6/hefgmJRkMb1M0fg==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -3237,6 +4124,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -3263,12 +4159,45 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -3346,6 +4275,26 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -3368,6 +4317,43 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3402,7 +4388,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -3411,6 +4396,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -3483,6 +4513,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3605,6 +4644,20 @@ "node": ">=14.0.0" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/tokenx": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz", @@ -3619,6 +4672,20 @@ "license": "0BSD", "optional": true }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typedoc": { "version": "0.28.20", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", @@ -3643,45 +4710,6 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" } }, - "node_modules/typedoc/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typedoc/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typedoc/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3914,6 +4942,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3967,6 +5016,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-8.0.0.tgz", + "integrity": "sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg==", + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -3988,6 +5055,15 @@ } } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 58c371aa0..a95109026 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "build:fixtures": "node scripts/build-conformance-fixtures.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", "verify:package": "npm run build && node scripts/verify-package.mjs", + "completion:generate": "node scripts/generate-completion-manifest.mjs", + "completion:check": "node scripts/generate-completion-manifest.mjs --check", "config:print": "node scripts/print-mcp-config.mjs", "docs:generate": "typedoc", "prepack": "npm run build", @@ -80,17 +82,30 @@ "*.ts": "oxlint --fix" }, "dependencies": { + "@babel/parser": "8.0.4", + "@babel/types": "8.0.4", + "@electron/asar": "4.2.0", "@modelcontextprotocol/server": "2.0.0-beta.3", + "@zip.js/zip.js": "2.8.26", "canonicalize": "3.0.0", + "diff": "9.0.0", + "graphology": "0.26.0", + "graphology-shortest-path": "2.1.0", + "ignore": "7.0.6", "incur": "0.4.13", + "isomorphic-git": "1.38.7", "node-pty": "1.1.0", "pino": "9.14.0", + "plist": "5.0.0", + "write-file-atomic": "^8.0.0", "ws": "8.21.0", "zod": "4.4.3" }, "devDependencies": { + "@fast-check/vitest": "^0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.3", "@types/node": "24.10.1", + "@types/write-file-atomic": "^4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", "husky": "9.1.7", diff --git a/scripts/generate-completion-manifest.mjs b/scripts/generate-completion-manifest.mjs new file mode 100644 index 000000000..5ca72288c --- /dev/null +++ b/scripts/generate-completion-manifest.mjs @@ -0,0 +1,283 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import writeFileAtomic from "write-file-atomic"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputPath = resolve(root, "metadata/completion-manifest.json"); +const check = process.argv.slice(2).includes("--check"); +const unexpected = process.argv + .slice(2) + .filter((argument) => argument !== "--check"); +if (unexpected.length > 0) + throw new Error(`Unknown argument: ${unexpected.join(" ")}`); + +const artifactPaths = [ + "bridge/hopper_bridge.py", + "package-lock.json", + "scripts/verify-conformance-fixtures.mjs", + "scripts/verify-package.mjs", + "scripts/verify-real-hopper.mjs", + "skills/rea-analysis/SKILL.md", + "src/application/AnalysisProvider.ts", + "src/application/ArtifactExtraction.ts", + "src/application/ArtifactGraphConstruction.ts", + "src/application/ArtifactInventory.ts", + "src/application/CompositeProvider.ts", + "src/application/EvidenceLedger.ts", + "src/application/DirectAnalysis.ts", + "src/application/ProcessSampling.ts", + "src/application/ReferenceSourceImport.ts", + "src/application/ReferenceSourceImportEntries.ts", + "src/application/ReferenceSourceImportPolicy.ts", + "src/application/ReferenceSourceImportTypes.ts", + "src/application/ReferenceSourceVcsAdapter.ts", + "src/application/RealHopperAssertions.ts", + "src/application/runtime.ts", + "src/contracts/nativeToolContracts.ts", + "src/contracts/artifactToolContracts.ts", + "src/contracts/artifactComparisonExample.ts", + "src/contracts/functionComparisonExample.ts", + "src/contracts/investigationExamples.ts", + "src/contracts/toolContractExamples.ts", + "src/contracts/processCaptureExample.ts", + "src/contracts/enhancedInputs.ts", + "src/contracts/toolContracts.ts", + "src/contracts/toolOutputSchemas.ts", + "src/contracts/unknownContractExamples.ts", + "src/domain/evidence.ts", + "src/domain/artifactGraph.ts", + "src/domain/artifactComparison.ts", + "src/domain/artifactInventoryEvidence.ts", + "src/domain/functionComparison.ts", + "src/domain/functionComparisonNormalization.ts", + "src/domain/functionComparisonResults.ts", + "src/domain/functionComparisonSchemas.ts", + "src/domain/bundleComparison.ts", + "src/domain/changedBehavior.ts", + "src/domain/callPath.ts", + "src/domain/staticRuntimeCorrelation.ts", + "src/domain/reconstructionVerification.ts", + "src/domain/reconstructionVerificationSchemas.ts", + "src/domain/reconstructionUnknowns.ts", + "src/domain/referenceSourceGraph.ts", + "src/domain/referenceSourceClassification.ts", + "src/domain/referenceSourceImportParsing.ts", + "src/domain/referenceSourcePolicy.ts", + "src/domain/functionDossierEvidence.ts", + "src/domain/evidenceBundle.ts", + "src/domain/nativeInspection.ts", + "src/domain/processCapture.ts", + "src/domain/residualUnknown.ts", + "src/server/sessionToolPolicies.ts", + "src/server/registerEnhancedTools.ts", + "src/server/registerEvidenceTools.ts", + "src/server/registerArtifactTools.ts", + "src/server/registerArtifactComparisonTool.ts", + "src/server/registerFunctionComparisonTool.ts", + "src/server/registerBundleComparisonTool.ts", + "src/server/registerInvestigationTools.ts", + "src/server/recordDerivedEvidence.ts", + "src/server/registerNativeTools.ts", + "src/server/registerProcessComparisonTool.ts", + "src/server/registerSessionTools.ts", + "src/native/CommandRunner.ts", + "src/native/NativeMacOSProvider.ts", + "src/native/NativeMachoInspection.ts", + "src/native/parsers/codesign.ts", + "src/native/parsers/demangle.ts", + "src/native/parsers/dyldInfo.ts", + "src/native/parsers/lipo.ts", + "src/native/parsers/otool.ts", + "src/native/parsers/plist.ts", + "src/artifacts/ArtifactPaths.ts", + "src/artifacts/ArtifactProvider.ts", + "src/artifacts/ArtifactReader.ts", + "src/artifacts/AsarArtifactReader.ts", + "src/artifacts/DirectoryArtifactReader.ts", + "src/artifacts/MachOSliceArtifactReader.ts", + "src/artifacts/SafeOutputTree.ts", + "src/artifacts/StreamBytes.ts", + "src/artifacts/ZipArtifactReader.ts", + "src/reference/ReferenceSourceReaderEntries.ts", + "src/reference/ReferenceSourceReaderErrors.ts", + "src/reference/ReferenceSourceReaderFile.ts", + "src/reference/ReferenceSourceReaderPaths.ts", + "src/reference/ReferenceSourceReaderTypes.ts", + "src/reference/ReferenceSourceReaderValidate.ts", + "src/reference/ReferenceSourceReader.ts", +]; + +const artifacts = await Promise.all( + artifactPaths.map(async (path) => ({ + path, + sha256: sha256(await readFile(resolve(root, path))), + })), +); +const packageJson = JSON.parse( + await readFile(resolve(root, "package.json"), "utf8"), +); +const toolSource = await readFile( + resolve(root, "src/contracts/toolContracts.ts"), + "utf8", +); +const nativeToolSource = await readFile( + resolve(root, "src/contracts/nativeToolContracts.ts"), + "utf8", +); +const artifactToolSource = await readFile( + resolve(root, "src/contracts/artifactToolContracts.ts"), + "utf8", +); +const skillSource = await readFile( + resolve(root, "skills/rea-analysis/SKILL.md"), + "utf8", +); +const toolCount = + countContracts(toolSource) + + countProviderContracts(nativeToolSource, "NATIVE", "native") + + countProviderContracts(artifactToolSource, "ARTIFACT", "artifact"); +const skillToolCount = Number(/tool_count:\s*(\d+)/u.exec(skillSource)?.[1]); +if (!Number.isSafeInteger(skillToolCount) || skillToolCount !== toolCount) + throw new Error( + `Skill tool_count drift: skill=${String(skillToolCount)} contracts=${toolCount}`, + ); + +const manifest = { + schema_version: 1, + inputs: { + artifacts, + package: { name: packageJson.name, version: packageJson.version }, + providers: [ + { + id: "hopper", + version: null, + authority: "shipped-artifact", + }, + { + id: "native-macos", + version: null, + authority: "shipped-artifact", + }, + { + id: "rea-artifact-graph", + version: "1", + authority: "shipped-artifact", + }, + ], + environments: [ + { id: "package-isolation", isolation: "process" }, + { id: "real-hopper-macos", isolation: "process" }, + ], + scenarios: [ + { id: "conformance-fixtures", version: 1 }, + { id: "package-install", version: 1 }, + { id: "real-hopper-two-binary", version: 2 }, + ], + schemas: { evidence: 2, evidence_bundle: 2, completion: 1 }, + tool_count: toolCount, + }, + evidence_index: [], + outcomes: { + pass: [], + fail: [], + unsupported: [ + { + claim_id: "real-hopper-verification", + reason: + "Generated repository metadata contains no checked-in real-Hopper capture.", + evidence_ids: [], + }, + ], + truncated: [], + unknown: [ + { + claim_id: "semantic-conformance-complete", + reason: + "Completion requires external verifier evidence and cannot be inferred from test counts.", + evidence_ids: [], + }, + ], + }, +}; +validateOutcomeEvidence(manifest); +const serialized = `${JSON.stringify(manifest, null, 2)}\n`; + +if (check) { + let existing; + try { + existing = await readFile(outputPath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") + throw new Error( + `Missing generated manifest: ${relative(root, outputPath)}`, + ); + throw error; + } + if (existing !== serialized) + throw new Error( + `Generated completion metadata is stale or tampered: run npm run completion:generate`, + ); +} else { + await writeFileAtomic(outputPath, serialized, { encoding: "utf8" }); +} + +process.stdout.write( + `${JSON.stringify({ mode: check ? "check" : "generate", path: relative(root, outputPath), toolCount })}\n`, +); + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function countContracts(source) { + const sections = [ + ["OFFICIAL", "official"], + ["ENHANCED", "enhanced"], + ["SESSION", "session"], + ]; + return sections.reduce((total, [section, constructor]) => { + const match = new RegExp( + `export const ${section}_TOOL_CONTRACTS = \\[([\\s\\S]*?)\\n\\] as const satisfies`, + "u", + ).exec(source); + if (match?.[1] === undefined) + throw new Error(`Cannot derive ${section.toLowerCase()} tool contracts`); + return ( + total + + [...match[1].matchAll(new RegExp(`^ ${constructor}\\($`, "gmu"))].length + ); + }, 0); +} + +function countProviderContracts(source, section, constructor) { + const match = new RegExp( + `export const ${section}_TOOL_CONTRACTS = \\[([\\s\\S]*?)\\n\\] as const satisfies`, + "u", + ).exec(source); + if (match?.[1] === undefined) + throw new Error(`Cannot derive ${section.toLowerCase()} tool contracts`); + return [...match[1].matchAll(new RegExp(`^ ${constructor}\\($`, "gmu"))] + .length; +} + +function validateOutcomeEvidence(value) { + const evidenceIds = new Set( + value.evidence_index.map(({ evidence_id }) => evidence_id), + ); + for (const status of ["pass", "fail"]) { + for (const outcome of value.outcomes[status]) { + if (outcome.evidence_ids.length === 0) + throw new Error( + `${status} outcome ${outcome.claim_id} has no evidence`, + ); + for (const evidenceId of outcome.evidence_ids) + if (!evidenceIds.has(evidenceId)) + throw new Error( + `${status} outcome ${outcome.claim_id} references missing evidence ${evidenceId}`, + ); + } + } +} diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 0eca8aecb..d89bd084c 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // Route production MCP before importing Incur. Incur owns registration helpers -// such as `mcp add`, while only dist/main.js may serve the 50-tool stdio server. +// such as `mcp add`, while only dist/main.js may serve the 68-tool stdio server. const args = process.argv.slice(2); const isMcpMode = args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp"); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index b51c26a2c..7ae784565 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -13,12 +13,17 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; + +const expectedToolCount = TOOL_CONTRACTS.length; // Exercise the packed artifact in isolated HOME and prefix directories so the // verifier cannot mutate real MCP registrations or rely on checkout-only files. const exec = promisify(execFile); const root = process.cwd(); const workspace = await mkdtemp(join(tmpdir(), "rea-package-")); +const evidenceRoot = join(workspace, "evidence"); let tarball; try { @@ -39,6 +44,7 @@ try { const home = join(workspace, "home"); const fakeBin = join(workspace, "bin"); await mkdir(fakeBin, { recursive: true }); + await mkdir(evidenceRoot, { recursive: true }); const brew = join(fakeBin, "brew"); const swVers = join(fakeBin, "sw_vers"); const npx = join(fakeBin, "npx"); @@ -77,6 +83,7 @@ try { PATH: `${fakeBin}:${process.env.PATH ?? ""}`, HOPPER_LAUNCHER_PATH: hopper, REA_NPX_LOG: npxLog, + REA_EVIDENCE_ROOTS_JSON: JSON.stringify([evidenceRoot]), }; await exec( "npm", @@ -87,66 +94,150 @@ try { const help = await run(cli, ["--help"], environment); const llms = await run(cli, ["--llms"], environment); const doctor = json(await run(cli, ["doctor", "--json"], environment)); + const expectedDoctorHealth = process.platform === "darwin"; if ( !help.includes("setup") || + !help.includes("inventory-artifact") || + !help.includes("extract-artifact") || !llms.includes("decompile") || - doctor.healthy !== true + !llms.includes("inventory-artifact") || + doctor.healthy !== expectedDoctorHealth || + doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true ) - throw new Error("packaged CLI discovery or doctor failed"); + throw new Error( + `packaged CLI discovery or doctor failed: ${JSON.stringify({ helpSetup: help.includes("setup"), llmsDecompile: llms.includes("decompile"), doctor })}`, + ); + const artifactArchive = join(workspace, "artifact.zip"); + const artifactWriter = new ZipWriter(new Uint8ArrayWriter()); + await artifactWriter.add("app/main.js", new TextReader("main();")); + await writeFile(artifactArchive, await artifactWriter.close()); + const artifactInventory = json( + await run( + cli, + ["inventory-artifact", artifactArchive, "--limit", "500", "--json"], + environment, + ), + ); + if ( + artifactInventory.operation !== "inventory_artifact" || + artifactInventory.provider?.id !== "rea-artifact-graph" || + artifactInventory.normalized_result?.manifest?.root_format !== "zip" + ) + throw new Error("packaged artifact inventory CLI failed"); + const { createEvidence } = await import( + new URL("../dist/domain/evidence.js", import.meta.url) + ); + const { createEvidenceBundle, serializeEvidenceBundle } = await import( + new URL("../dist/domain/evidenceBundle.js", import.meta.url) + ); + const sourceBundle = createEvidenceBundle([ + createEvidence( + undefined, + { id: "package", name: "Package verifier", version: "1" }, + { operation: "health", parameters: {}, result: true }, + ), + ]); + const sourceBundlePath = join(evidenceRoot, "source.json"); + const canonicalBundlePath = join(evidenceRoot, "canonical.json"); + await writeFile(sourceBundlePath, serializeEvidenceBundle(sourceBundle)); + const importedBundle = json( + await run( + cli, + ["evidence-import", sourceBundlePath, "--json"], + environment, + ), + ); + const exportedBundle = json( + await run( + cli, + ["evidence-export", sourceBundlePath, canonicalBundlePath, "--json"], + environment, + ), + ); + if ( + importedBundle.imported !== 1 || + importedBundle.total !== 1 || + exportedBundle.records !== 1 || + (await readFile(canonicalBundlePath, "utf8")) !== + serializeEvidenceBundle(sourceBundle) + ) + throw new Error("packaged CLI evidence bundle round trip failed"); await run(cli, ["mcp", "add"], environment); const mcpRegistration = await readFile(npxLog, "utf8"); if (!mcpRegistration.includes("add-mcp npx -y rea-agents mcp --name rea")) throw new Error("Incur mcp add did not register the floating npx command"); + const skillPath = join(home, ".agents/skills/rea-analysis/SKILL.md"); + const siblingSkillPath = join(home, ".agents/skills/unrelated/SKILL.md"); + if (process.platform === "darwin") { + await mkdir(join(home, ".agents/skills/rea-analysis"), { + recursive: true, + }); + await mkdir(join(home, ".agents/skills/unrelated"), { recursive: true }); + await writeFile(skillPath, "stale managed skill\n"); + await writeFile(siblingSkillPath, "unrelated skill\n"); + } const first = json(await run(cli, ["setup", "--yes", "--json"], environment)); const second = json( await run(cli, ["setup", "--yes", "--json"], environment), ); - if ( - first.status !== "ready" || - second.status !== "ready" || - second.actions.length !== 0 - ) - throw new Error("packaged setup was not ready and idempotent"); - for (const configPath of [claudeConfig, cursorConfig]) { - const config = json(await readFile(configPath, "utf8")); + if (process.platform === "darwin") { if ( - config.existing !== true || - config.mcpServers?.rea?.command !== "npx" || - JSON.stringify(config.mcpServers?.rea?.args) !== - JSON.stringify(["-y", "rea-agents", "mcp"]) + first.status !== "ready" || + second.status !== "ready" || + second.actions.length !== 0 ) - throw new Error("packaged client readback failed"); + throw new Error("packaged setup was not ready and idempotent"); + for (const configPath of [claudeConfig, cursorConfig]) { + const config = json(await readFile(configPath, "utf8")); + if ( + config.existing !== true || + config.mcpServers?.rea?.command !== "npx" || + JSON.stringify(config.mcpServers?.rea?.args) !== + JSON.stringify(["-y", "rea-agents", "mcp"]) + ) + throw new Error("packaged client readback failed"); + if ( + !(await readFile(`${configPath}.rea.backup`, "utf8")).includes( + "existing", + ) + ) + throw new Error("packaged client backup failed"); + } + const skill = await readFile(skillPath, "utf8"); + const canonicalSkill = await readFile( + join(root, "skills/rea-analysis/SKILL.md"), + "utf8", + ); + if (skill !== canonicalSkill) + throw new Error("packaged skill did not match its canonical source"); if ( - !(await readFile(`${configPath}.rea.backup`, "utf8")).includes("existing") + (await readFile(`${skillPath}.rea.backup`, "utf8")) !== + "stale managed skill\n" || + (await readFile(siblingSkillPath, "utf8")) !== "unrelated skill\n" ) - throw new Error("packaged client backup failed"); - } - const skill = await readFile( - join(home, ".agents/skills/rea-analysis/SKILL.md"), - "utf8", - ); - const canonicalSkill = await readFile( - join(root, "skills/rea-analysis/SKILL.md"), - "utf8", - ); - if (skill !== canonicalSkill) - throw new Error("packaged skill did not match its canonical source"); + throw new Error("packaged stale-skill upgrade was not isolated"); - await writeFile(cursorConfig, "malformed"); - const failed = json( - await run(cli, ["setup", "--yes", "--json"], environment), - ); - if ( - failed.status !== "needs_human" || - (await readFile(cursorConfig, "utf8")) !== "malformed" - ) - throw new Error("packaged setup failure recovery did not preserve input"); - await writeFile(cursorConfig, "{}\n"); - const recovered = json( - await run(cli, ["setup", "--yes", "--json"], environment), - ); - if (recovered.status !== "ready") - throw new Error("packaged setup did not recover"); + await writeFile(cursorConfig, "malformed"); + const failed = json( + await run(cli, ["setup", "--yes", "--json"], environment), + ); + if ( + failed.status !== "needs_human" || + (await readFile(cursorConfig, "utf8")) !== "malformed" + ) + throw new Error("packaged setup failure recovery did not preserve input"); + await writeFile(cursorConfig, "{}\n"); + const recovered = json( + await run(cli, ["setup", "--yes", "--json"], environment), + ); + if (recovered.status !== "ready") + throw new Error("packaged setup did not recover"); + } else if ( + first.status !== "needs_human" || + second.status !== "needs_human" + ) { + throw new Error("packaged setup did not reject an unsupported host"); + } const transport = new StdioClientTransport({ command: cli, @@ -161,14 +252,27 @@ try { const client = new Client({ name: "package-smoke", version: "1.0.0" }); try { await client.connect(transport); - if ((await client.listTools()).tools.length !== 50) - throw new Error("packaged MCP did not expose 50 tools"); + if ((await client.listTools()).tools.length !== expectedToolCount) + throw new Error("packaged MCP tool inventory diverged from contracts"); const result = await client.callTool({ name: "current_document", arguments: {}, }); if (result.isError !== true) throw new Error("packaged target-free MCP omitted no-target error"); + const mcpBundlePath = join(evidenceRoot, "mcp.json"); + const mcpExport = await client.callTool({ + name: "export_evidence_bundle", + arguments: { path: mcpBundlePath }, + }); + if (mcpExport.isError === true) + throw new Error("packaged MCP evidence export failed"); + const mcpImport = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path: mcpBundlePath }, + }); + if (mcpImport.isError === true) + throw new Error("packaged MCP evidence import failed"); } catch (cause) { throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause }); } finally { @@ -177,7 +281,7 @@ try { } process.stdout.write( - `${JSON.stringify({ cli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 50, targetFree: true })}\n`, + `${JSON.stringify({ cli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: process.platform === "darwin" ? "idempotent" : "unsupported-host-rejected", clients: process.platform === "darwin" ? 2 : 0, backupReadback: process.platform === "darwin", failureRecovery: process.platform === "darwin", skill: process.platform === "darwin", mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true })}\n`, ); } finally { if (tarball) await rm(join(root, tarball), { force: true }); diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index 5ef7f6888..ff18bf4c7 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -6,6 +6,12 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; +import { + firstProcedureAddress, + requireAddressArray, + requireFunctionDossier, + requirePseudocode, +} from "../dist/application/RealHopperAssertions.js"; const execFileAsync = promisify(execFile); const timeout = 180_000; @@ -26,90 +32,176 @@ const requireSuccessfulTool = (result, operation) => { throw new Error(`${operation} failed: ${textValue(result)}`); } const value = jsonValue(result); - if (value === null || typeof value !== "object" || !("result" in value)) { + if ( + value === null || + typeof value !== "object" || + !("normalized_result" in value) + ) { throw new Error(`${operation} omitted its evidence result`); } - return value.result; + return value.normalized_result; }; -const firstProcedureAddress = (result) => { - const items = result.structuredContent?.result?.items; - if (!Array.isArray(items) || items.length === 0) { - throw new Error("The verifier target contains no analyzed procedure"); - } - const address = items[0]?.address; - if (typeof address !== "string" || !/^0x[0-9a-f]+$/iu.test(address)) { - throw new Error("list_procedures returned an invalid procedure address"); - } - return address; -}; - -const requirePseudocode = (value, operation) => { - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error(`${operation} returned empty pseudocode`); - } - if (value === "No output" || value.startsWith("Error:")) { - throw new Error(`${operation} returned an embedded failure: ${value}`); +const requireOverview = (value, operation) => { + if ( + value === null || + typeof value !== "object" || + typeof value.document !== "string" || + value.document.length === 0 || + !Number.isInteger(value.procedure_count) || + value.procedure_count < 1 || + !Number.isInteger(value.segment_count) || + value.segment_count < 1 || + !Array.isArray(value.segments) + ) { + throw new Error(`${operation} returned no analyzed binary overview`); } return value; }; -const requireFunctionDossier = (value, expectedAddress) => { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new Error("analyze_function returned no function dossier"); +const requireTruthfulMemoryRegions = (segments) => { + if (!Array.isArray(segments) || segments.length === 0) { + throw new Error("list_segments returned no real memory regions"); } - if (value.procedure?.address !== expectedAddress) { - throw new Error("analyze_function returned the wrong procedure"); - } - if ( - typeof value.procedure?.name !== "string" || - value.procedure.name.trim().length === 0 - ) { - throw new Error("analyze_function omitted the procedure name"); - } - requirePseudocode(value.pseudocode?.text, "analyze_function"); - for (const field of [ - "comments", - "callers", - "callees", - "incoming_references", - "outgoing_references", - "referenced_strings", - "referenced_names", - "basic_blocks", - ]) { - const collection = value[field]; - if ( - collection === null || - typeof collection !== "object" || - !Array.isArray(collection.items) || - !Number.isInteger(collection.total) - ) { - throw new Error(`analyze_function returned an invalid ${field} result`); - } - } - for (const field of ["callers", "callees"]) { - for (const identity of value[field].items) { - if ( - typeof identity?.address !== "string" || - typeof identity?.name !== "string" - ) { - throw new Error(`analyze_function returned an untyped ${field} item`); + const regions = segments.flatMap((segment) => [ + segment, + ...(Array.isArray(segment.sections) ? segment.sections : []), + ]); + for (const region of regions) { + for (const permission of ["readable", "writable", "executable"]) { + if (![true, false, null].includes(region?.[permission])) { + throw new Error(`list_segments omitted tri-state ${permission}`); } } - } - for (const block of value.basic_blocks.items) { - if (!Array.isArray(block?.successors)) { - throw new Error("analyze_function omitted CFG successor evidence"); + if ( + region.provenance !== "hopper-public-python-api" || + region.permissions?.available !== false || + typeof region.permissions.reason !== "string" + ) { + throw new Error("list_segments omitted permission provenance"); } } - if ( - typeof value.instruction_scan?.scanned !== "number" || - typeof value.instruction_scan?.truncated !== "boolean" - ) { - throw new Error("analyze_function omitted instruction scan limitations"); +}; + +const verifyRelationships = async (client, options, procedure) => { + const related = {}; + for (const operation of ["procedure_callers", "procedure_callees"]) { + related[operation] = requireAddressArray( + requireSuccessfulTool( + await client.callTool( + { name: operation, arguments: { procedure } }, + options, + ), + operation, + ), + operation, + ); } - return value; + related.xrefs = requireAddressArray( + requireSuccessfulTool( + await client.callTool( + { name: "xrefs", arguments: { address: procedure } }, + options, + ), + "xrefs", + ), + "xrefs", + ); + return related; +}; + +const verifyCurrentTarget = async (client, options) => { + const procedures = requireSuccessfulTool( + await client.callTool({ name: "list_procedures", arguments: {} }, options), + "list_procedures", + ); + const firstAddress = firstProcedureAddress(procedures); + const containment = requireSuccessfulTool( + await client.callTool( + { + name: "resolve_containing_procedure", + arguments: { address: firstAddress }, + }, + options, + ), + "resolve_containing_procedure", + ); + if ( + containment?.found !== true || + containment.procedure?.address !== firstAddress + ) { + throw new Error( + "resolve_containing_procedure returned the wrong procedure", + ); + } + const references = requireSuccessfulTool( + await client.callTool( + { + name: "procedure_references", + arguments: { + procedure: firstAddress, + direction: "outgoing", + limit: 10, + max_instructions: 100, + }, + }, + options, + ), + "procedure_references", + ); + if (!Array.isArray(references?.references?.items)) { + throw new Error("procedure_references returned an invalid bounded result"); + } + const relationships = await verifyRelationships( + client, + options, + firstAddress, + ); + const boundedResult = requireSuccessfulTool( + await client.callTool( + { + name: "batch_decompile", + arguments: { addresses: [firstAddress] }, + }, + options, + ), + "batch_decompile", + ); + if ( + boundedResult === null || + typeof boundedResult !== "object" || + Array.isArray(boundedResult) || + Object.keys(boundedResult).length !== 1 + ) { + throw new Error("batch_decompile returned an invalid result"); + } + const boundedPseudocode = requirePseudocode( + boundedResult[firstAddress], + "batch_decompile", + ); + const dossier = requireFunctionDossier( + requireSuccessfulTool( + await client.callTool( + { + name: "analyze_function", + arguments: { procedure: firstAddress }, + }, + options, + ), + "analyze_function", + ), + firstAddress, + ); + return { + procedure: dossier.procedure, + procedureCount: procedures.total, + boundedPseudocodeChars: boundedPseudocode.length, + analyzedPseudocodeChars: [...dossier.pseudocode.text].length, + callerCount: relationships.procedure_callers.length, + calleeCount: relationships.procedure_callees.length, + xrefCount: relationships.xrefs.length, + outgoingReferenceCount: references.references.items.length, + }; }; const requireSafeDiagnostics = (chunks) => { @@ -176,7 +268,7 @@ try { const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort(); const actualNames = listed.tools.map(({ name }) => name).sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) { - throw new Error("The real server did not expose the intended 50 tools"); + throw new Error("The real server did not expose the intended 68 tools"); } const options = { timeout }; @@ -203,77 +295,17 @@ try { { name: "list_segments", arguments: {} }, options, ); - const procedures = await client.callTool( - { name: "list_procedures", arguments: {} }, - options, + requireTruthfulMemoryRegions( + requireSuccessfulTool(segments, "list_segments"), ); - requireSuccessfulTool(procedures, "list_procedures"); - const firstAddress = firstProcedureAddress(procedures); - const containment = requireSuccessfulTool( - await client.callTool( - { - name: "resolve_containing_procedure", - arguments: { address: firstAddress }, - }, - options, - ), - "resolve_containing_procedure", - ); - if ( - containment?.found !== true || - containment.procedure?.address !== firstAddress - ) { - throw new Error( - "resolve_containing_procedure returned the wrong procedure", - ); - } - const references = requireSuccessfulTool( - await client.callTool( - { - name: "procedure_references", - arguments: { - procedure: firstAddress, - direction: "outgoing", - limit: 10, - max_instructions: 100, - }, - }, - options, - ), - "procedure_references", - ); - if (!Array.isArray(references?.references?.items)) { - throw new Error("procedure_references returned an invalid bounded result"); - } - const bounded = await client.callTool( - { name: "batch_decompile", arguments: { addresses: [firstAddress] } }, - options, - ); - const boundedResult = requireSuccessfulTool(bounded, "batch_decompile"); - if ( - boundedResult === null || - typeof boundedResult !== "object" || - Array.isArray(boundedResult) - ) { - throw new Error("batch_decompile returned an invalid result"); - } - const boundedPseudocode = requirePseudocode( - boundedResult[firstAddress], - "batch_decompile", - ); - const dossier = requireFunctionDossier( - requireSuccessfulTool( - await client.callTool( - { - name: "analyze_function", - arguments: { procedure: firstAddress }, - }, - options, - ), - "analyze_function", - ), - firstAddress, + const firstDocuments = requireSuccessfulTool(documents, "list_documents"); + if (!Array.isArray(firstDocuments) || firstDocuments.length === 0) + throw new Error("list_documents returned no real Hopper document"); + const firstOverview = requireOverview( + requireSuccessfulTool(overview, "binary_overview"), + "binary_overview", ); + const firstAnalysis = await verifyCurrentTarget(client, options); const switched = await client.callTool( { name: "open_binary", arguments: { path: targetB } }, options, @@ -288,8 +320,11 @@ try { { name: "binary_overview", arguments: {} }, options, ); - if (secondOverview.isError === true) - throw new Error(textValue(secondOverview)); + const verifiedSecondOverview = requireOverview( + requireSuccessfulTool(secondOverview, "binary_overview"), + "binary_overview after target switch", + ); + const secondAnalysis = await verifyCurrentTarget(client, options); const processList = await execFileAsync("ps", ["-ax", "-o", "command="]); const bundledMcpRunning = processList.stdout @@ -307,25 +342,25 @@ try { options, ); if (closed.isError === true) throw new Error(textValue(closed)); + const closedSession = jsonValue( + await client.callTool({ name: "binary_session", arguments: {} }, options), + ); + if (closedSession.open !== false) + throw new Error("The real session remained open after close_binary"); summary = { toolCount: actualNames.length, - documentCount: jsonValue(documents).length, - overview: jsonValue(overview), - segmentCount: jsonValue(segments).length, - boundedTool: "batch_decompile", - boundedInputCount: 1, - boundedResultKeys: Object.keys(boundedResult).length, - boundedPseudocodeChars: boundedPseudocode.length, - analyzedProcedure: dossier.procedure, - analyzedPseudocodeChars: dossier.pseudocode.text.length, + documentCount: firstDocuments.length, + overview: firstOverview, + segmentCount: firstOverview.segment_count, + analyses: [firstAnalysis, secondAnalysis], bundledMcpRunning, stderrBytes, diagnosticCount, dynamicSession: true, targets: [targetA, targetB], switched: true, - secondOverview: jsonValue(secondOverview), + secondOverview: verifiedSecondOverview, }; } finally { const keepAlive = setInterval(() => undefined, 100); diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 1d9e031be..72414cdab 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -1,19 +1,66 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { OfficialToolName } from "../contracts/toolContracts.js"; +import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; +import type { NativeToolName } from "../contracts/nativeToolContracts.js"; +import type { ArtifactToolName } from "../contracts/artifactToolContracts.js"; import type { AnalysisError } from "../domain/errors.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; +import type { EvidenceLocation } from "../domain/evidence.js"; +import type { EvidenceSubjectTarget } from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; interface ExecutionOptions { readonly signal?: AbortSignal; } +/** Closed operations accepted by provider-backed analysis sessions. */ +export type AnalysisOperation = + | OfficialToolName + | EnhancedToolName + | NativeToolName + | ArtifactToolName + | "health"; + +/** Atomic successful provider observation with exact producing provenance. */ +export interface AnalysisExecution { + readonly result: JsonValue; + readonly rawResult: JsonValue | null; + readonly provider: ProviderIdentity; + readonly limitations: readonly string[]; + readonly locations: readonly EvidenceLocation[]; + readonly subject: EvidenceSubjectTarget | null; +} + +/** Build a validated atomic provider observation at an adapter boundary. */ +export const createAnalysisExecution = ( + result: unknown, + provider: ProviderIdentity, + options: { + readonly rawResult?: unknown; + readonly limitations?: readonly string[]; + readonly locations?: readonly EvidenceLocation[]; + readonly subject?: EvidenceSubjectTarget; + } = {}, +): AnalysisExecution => ({ + result: jsonValueSchema.parse(result), + rawResult: + options.rawResult === undefined + ? jsonValueSchema.parse(result) + : jsonValueSchema.parse(options.rawResult), + provider, + limitations: [...(options.limitations ?? [])], + locations: [...(options.locations ?? [])], + subject: options.subject ?? null, +}); + /** Provider-neutral application capability for one analysis operation. */ export interface AnalysisOperationPort { execute( - operation: string, + operation: AnalysisOperation, parameters: Readonly>, options?: ExecutionOptions, - ): Promise>; + ): Promise>; } /** Provider session bound to exactly one parsed artifact. */ @@ -29,26 +76,37 @@ export interface ProviderIdentity { readonly version: string | null; } -type AnalysisOperation = string; - interface CapabilityEffects { readonly mutatesArtifact: boolean; readonly launchesProcess: boolean; readonly mayShowUi: boolean; readonly mayAccessNetwork: boolean; readonly mayWriteFilesystem: boolean; - readonly requiresPrivileges: boolean; + readonly changesPermissions: boolean; + readonly requiresRoot: boolean; } -export interface CapabilityDescriptor { - readonly operation: AnalysisOperation; - readonly version: number; - readonly available: boolean; +type CapabilityAvailability = + | { readonly available: true; readonly reason: null } + | { readonly available: false; readonly reason: string }; + +interface CapabilityLimits { + readonly maxResults: number | null; + readonly maxPayloadBytes: number | null; + readonly timeoutMs: number | null; +} + +export type CapabilityDescriptor = CapabilityAvailability & { + readonly provider: ProviderIdentity; + readonly operation: Exclude; + readonly inputContractVersion: number; + readonly outputContractVersion: number; readonly pagination: "none" | "offset" | "cursor"; readonly exhaustive: boolean; readonly effects: CapabilityEffects; + readonly limits: CapabilityLimits; readonly limitations: readonly string[]; -} +}; /** Factory and capability declaration for an analysis implementation. */ export interface AnalysisProvider { diff --git a/src/application/ArtifactExtraction.ts b/src/application/ArtifactExtraction.ts new file mode 100644 index 000000000..0d0a13018 --- /dev/null +++ b/src/application/ArtifactExtraction.ts @@ -0,0 +1,376 @@ +import { lstat, realpath } from "node:fs/promises"; + +import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js"; +import { + ArtifactPathRegistry, + normalizeArtifactPath, +} from "../artifacts/ArtifactPaths.js"; +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactLimits, + type ArtifactReader, +} from "../artifacts/ArtifactReader.js"; +import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js"; +import { SafeOutputTree } from "../artifacts/SafeOutputTree.js"; +import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; +import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js"; +import { + artifactExtractionResultSchema, + type ArtifactExtractionResult, + type ArtifactGraphManifest, + type ArtifactNode, + type ArtifactOccurrence, +} from "../domain/artifactGraph.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import { inventoryArtifact } from "./ArtifactInventory.js"; +import { + digestCanonical, + pageOf, + toOutputLimits, +} from "./ArtifactGraphConstruction.js"; + +const PAGE_SIZE = 500; + +/** Already-approved extraction request from a caller-owned policy boundary. */ +export interface ArtifactExtractionInput { + readonly inputPath: string; + readonly inputFormat: BinaryTarget["format"]; + readonly outputRoot: string; + readonly occurrenceIds: readonly string[]; + readonly offset: number; + readonly limit: number; + readonly limits: ArtifactLimits; +} + +/** Extract only explicitly selected inventory occurrences into an absent root. */ +export const extractArtifact = async ( + input: ArtifactExtractionInput, + signal?: AbortSignal, +): Promise => { + validateSelection(input.occurrenceIds); + const sourcePath = await realpath(input.inputPath); + const selectedIds = new Set(input.occurrenceIds); + const inventory = await loadInventory( + sourcePath, + input.limits, + selectedIds, + signal, + ); + const selected = input.occurrenceIds.map((id) => { + const occurrence = inventory.occurrences.get(id); + if (occurrence === undefined) + throw new ArtifactReaderFailure( + "unavailable", + `Selected artifact occurrence was not found: ${id}`, + ); + if ( + (occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") || + occurrence.artifact_id === null || + occurrence.encrypted || + occurrence.logical_path === "." + ) + throw new ArtifactReaderFailure( + "format", + `Selected occurrence is not an extractable regular child file: ${id}`, + ); + const node = inventory.nodes.get(occurrence.artifact_id); + if (node === undefined) + throw new ArtifactReaderFailure( + "integrity", + `Selected occurrence has no inventory node: ${id}`, + ); + return { occurrence, node }; + }); + return materializeSelection({ + input, + sourcePath, + inventory, + selected, + signal, + }); +}; + +interface SelectedOccurrence { + readonly occurrence: ArtifactOccurrence; + readonly node: ArtifactNode; +} + +interface ExtractedOccurrence { + readonly artifact_id: string; + readonly relative_path: string; + readonly sha256: string; + readonly bytes_written: number; + readonly created: true; +} + +const materializeSelection = async ({ + input, + sourcePath, + inventory, + selected, + signal, +}: { + readonly input: ArtifactExtractionInput; + readonly sourcePath: string; + readonly inventory: LoadedInventory; + readonly selected: readonly SelectedOccurrence[]; + readonly signal: AbortSignal | undefined; +}): Promise => { + const byPath = new Map( + selected.map((item) => [item.occurrence.logical_path, item]), + ); + const reader = await createReader(sourcePath, input.inputFormat); + const output = await SafeOutputTree.create(input.outputRoot, input.limits); + let readerClosed = false; + const extracted: ExtractedOccurrence[] = []; + try { + const found = new Set(); + const registry = new ArtifactPathRegistry(); + let entryCount = 0; + for await (const entry of reader.entries(signal)) { + entryCount += 1; + if (entryCount > input.limits.maxEntries) + throw new ArtifactReaderFailure( + "limit", + "Artifact entry limit exceeded during extraction", + ); + const path = normalizeArtifactPath(entry.path, input.limits); + registry.add(path, entry.kind); + const selectedItem = byPath.get(path); + if (selectedItem === undefined) continue; + preflight(entry, input.limits); + const stream = await reader.open(entry, signal); + const written = await output.write( + path, + stream, + selectedItem.node.sha256, + signal, + ); + extracted.push({ + artifact_id: selectedItem.node.artifact_id, + relative_path: written.relativePath, + sha256: written.sha256, + bytes_written: written.bytesWritten, + created: true, + }); + found.add(path); + } + if (found.size !== selected.length) + throw new ArtifactReaderFailure( + "integrity", + "Selected inventory occurrence disappeared before extraction", + ); + await reader.close(); + readerClosed = true; + extracted.sort((left, right) => + left.relative_path.localeCompare(right.relative_path, "en"), + ); + const result = createExtractionResult( + input, + inventory, + selected, + extracted, + ); + const verifiedSource = await inventoryArtifact( + sourcePath, + input.limits, + pageInput(0, 0), + signal, + ); + assertSameManifest(inventory.manifest, verifiedSource.manifest); + await output.commit(); + return result; + } catch (cause: unknown) { + if (!readerClosed) await reader.close().catch(() => undefined); + await output.rollback(); + throw cause; + } +}; + +const createExtractionResult = ( + input: ArtifactExtractionInput, + inventory: LoadedInventory, + selected: readonly SelectedOccurrence[], + extracted: readonly ExtractedOccurrence[], +): ArtifactExtractionResult => { + const extractionSemantic = { + schema_version: 1 as const, + source_manifest_id: inventory.manifest.manifest_id, + selected_occurrence_ids: selected + .map(({ occurrence }) => occurrence.occurrence_id) + .sort((left, right) => left.localeCompare(right)), + files_sha256: digestCanonical(extracted), + output_root_alias: "$OUTPUT_ROOT" as const, + }; + return artifactExtractionResultSchema.parse({ + manifest: inventory.manifest, + extraction_manifest: { + ...extractionSemantic, + extraction_id: `aex_${digestCanonical(extractionSemantic)}`, + }, + output_root: "$OUTPUT_ROOT", + artifacts: pageOf(extracted, input.offset, input.limit), + containment_verified: true, + cleanup: { attempted: false, verified: true, residual_paths: [] }, + limits: toOutputLimits(input.limits), + provenance: [], + limitations: [ + "Only caller-selected regular file occurrences were materialized.", + ], + }); +}; + +interface LoadedInventory { + readonly manifest: ArtifactGraphManifest; + readonly occurrences: ReadonlyMap; + readonly nodes: ReadonlyMap; +} + +const loadInventory = async ( + path: string, + limits: ArtifactLimits, + selectedIds: ReadonlySet, + signal?: AbortSignal, +): Promise => { + const first = await inventoryArtifact(path, limits, pageInput(0, 0), signal); + const occurrences = new Map(); + const neededNodes = new Set(); + collectOccurrences( + first.occurrences.items, + selectedIds, + occurrences, + neededNodes, + ); + for ( + let offset = first.occurrences.next_offset; + offset !== null && occurrences.size < selectedIds.size; + offset += PAGE_SIZE + ) { + const page = await inventoryArtifact( + path, + limits, + pageInput(0, offset), + signal, + ); + assertSameManifest(first.manifest, page.manifest); + collectOccurrences( + page.occurrences.items, + selectedIds, + occurrences, + neededNodes, + ); + } + const nodes = new Map(); + collectNodes(first.nodes.items, neededNodes, nodes); + for ( + let offset = first.nodes.next_offset; + offset !== null && nodes.size < neededNodes.size; + offset += PAGE_SIZE + ) { + const page = await inventoryArtifact( + path, + limits, + pageInput(offset, 0), + signal, + ); + assertSameManifest(first.manifest, page.manifest); + collectNodes(page.nodes.items, neededNodes, nodes); + } + return { manifest: first.manifest, occurrences, nodes }; +}; + +const pageInput = (nodeOffset: number, occurrenceOffset: number) => ({ + nodeOffset, + nodeLimit: PAGE_SIZE, + occurrenceOffset, + occurrenceLimit: PAGE_SIZE, + edgeOffset: 0, + edgeLimit: 1, +}); + +const collectOccurrences = ( + items: readonly ArtifactOccurrence[], + selected: ReadonlySet, + output: Map, + neededNodes: Set, +): void => { + for (const item of items) { + if (!selected.has(item.occurrence_id)) continue; + output.set(item.occurrence_id, item); + if (item.artifact_id !== null) neededNodes.add(item.artifact_id); + } +}; + +const collectNodes = ( + items: readonly ArtifactNode[], + selected: ReadonlySet, + output: Map, +): void => { + for (const item of items) + if (selected.has(item.artifact_id)) output.set(item.artifact_id, item); +}; + +const createReader = async ( + path: string, + format: BinaryTarget["format"], +): Promise => { + if ((await lstat(path)).isDirectory()) + return new DirectoryArtifactReader(path); + if (format === "asar") return new AsarArtifactReader(path); + if (format === "ipa" || format === "apk" || format === "zip") + return new ZipArtifactReader(path, format); + if (format === "mach-o") return new MachOSliceArtifactReader(path); + throw new ArtifactReaderFailure( + "unavailable", + `Artifact format has no extraction reader: ${format}`, + ); +}; + +const validateSelection = (ids: readonly string[]): void => { + if (ids.length === 0 || ids.length > PAGE_SIZE) + throw new ArtifactReaderFailure( + "limit", + "Extraction requires 1 to 500 explicitly selected occurrences", + ); + if (new Set(ids).size !== ids.length) + throw new ArtifactReaderFailure( + "path", + "Extraction occurrence selection contains duplicates", + ); +}; + +const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => { + if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted) + throw new ArtifactReaderFailure( + "format", + `Selected artifact entry cannot be read: ${entry.path}`, + ); + if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes) + throw new ArtifactReaderFailure( + "limit", + `Selected artifact exceeds byte limit: ${entry.path}`, + ); + if ( + entry.declaredSize !== null && + entry.compressedSize !== null && + (entry.compressedSize === 0 + ? entry.declaredSize > 0 + : entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio) + ) + throw new ArtifactReaderFailure( + "limit", + `Selected artifact exceeds compression ratio limit: ${entry.path}`, + ); +}; + +const assertSameManifest = ( + expected: ArtifactGraphManifest, + observed: ArtifactGraphManifest, +): void => { + if (expected.manifest_id !== observed.manifest_id) + throw new ArtifactReaderFailure( + "integrity", + "Artifact changed while resolving extraction selection", + ); +}; diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts new file mode 100644 index 000000000..3f09dfb0f --- /dev/null +++ b/src/application/ArtifactGraphConstruction.ts @@ -0,0 +1,359 @@ +import { createHash } from "node:crypto"; +import { extname } from "node:path"; + +import canonicalize from "canonicalize"; + +import type { + ArtifactEntry, + ArtifactLimits, +} from "../artifacts/ArtifactReader.js"; +import type { + ArtifactCommand, + ArtifactEdge, + ArtifactNode, + ArtifactOccurrence, +} from "../domain/artifactGraph.js"; + +/** Mutable internal occurrence used until root-bound IDs are known. */ +export interface MutableOccurrence { + occurrence_id: string; + artifact_id: string | null; + parent_occurrence_id: string | null; + logical_path: string; + entry_kind: ArtifactOccurrence["entry_kind"]; + declared_size: number | null; + compressed_size: number | null; + executable: boolean; + encrypted: boolean; + hash_status: ArtifactOccurrence["hash_status"]; + source_location: ArtifactOccurrence["source_location"]; + limitations: string[]; +} + +export const createOccurrence = ( + entry: ArtifactEntry, + path: string, + parent: string | null, +): MutableOccurrence => ({ + occurrence_id: `occ_${digestCanonical({ path, kind: entry.kind })}`, + artifact_id: null, + parent_occurrence_id: parent, + logical_path: path, + entry_kind: entry.kind, + declared_size: entry.declaredSize, + compressed_size: entry.compressedSize, + executable: entry.executable, + encrypted: entry.encrypted, + hash_status: entry.encrypted ? "unavailable" : "not-hashed-limit", + source_location: + entry.byteOffset === null || entry.declaredSize === null + ? null + : { offset: entry.byteOffset, length: entry.declaredSize }, + limitations: [...entry.limitations], +}); + +export const materializeDirectoryNodes = ( + occurrences: MutableOccurrence[], + nodes: Map, +): void => { + const directories = occurrences + .filter(({ entry_kind: kind }) => kind === "directory") + .sort( + (left, right) => depth(right.logical_path) - depth(left.logical_path), + ); + const childrenByParent = new Map(); + for (const occurrence of occurrences) { + if (occurrence.parent_occurrence_id === null) continue; + const children = childrenByParent.get(occurrence.parent_occurrence_id); + if (children === undefined) + childrenByParent.set(occurrence.parent_occurrence_id, [occurrence]); + else children.push(occurrence); + } + for (const directory of directories) { + const children = (childrenByParent.get(directory.occurrence_id) ?? []) + .map(({ logical_path, artifact_id, entry_kind }) => ({ + name: logical_path.split("/").at(-1), + artifact_id, + entry_kind, + })) + .sort((left, right) => + String(left.name).localeCompare(String(right.name)), + ); + const node = createArtifactNode({ + sha256: digestCanonical({ kind: "directory", children }), + size: 0, + kind: directory.logical_path.toLowerCase().endsWith(".framework") + ? "framework" + : "container", + format: "directory", + executable: false, + contentState: "virtual", + }); + const existing = nodes.get(node.artifact_id); + nodes.set( + node.artifact_id, + existing?.kind === "framework" ? existing : node, + ); + directory.artifact_id = node.artifact_id; + directory.hash_status = "verified"; + } +}; + +export const createRootNode = (input: { + readonly path: string; + readonly format: ArtifactNode["format"]; + readonly directory: boolean; + readonly digest: { readonly sha256: string; readonly bytes: number } | null; + readonly occurrences: readonly MutableOccurrence[]; +}): ArtifactNode => + createArtifactNode({ + sha256: + input.digest?.sha256 ?? + digestCanonical({ + kind: "directory-root", + children: input.occurrences.map(({ logical_path, artifact_id }) => ({ + logical_path, + artifact_id, + })), + }), + size: input.digest?.bytes ?? 0, + kind: + input.directory || + ["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(input.format) + ? "container" + : classifyArtifactPath(input.path).kind, + format: input.format, + executable: false, + contentState: "materialized", + }); + +export const createArtifactNode = (input: { + readonly sha256: string; + readonly size: number; + readonly kind: ArtifactNode["kind"]; + readonly format: ArtifactNode["format"]; + readonly executable: boolean; + readonly contentState: ArtifactNode["content_state"]; +}): ArtifactNode => ({ + artifact_id: `art_${digestCanonical({ schema_version: 1, sha256: input.sha256 })}`, + kind: input.kind, + format: input.format, + sha256: input.sha256, + size: input.size, + media_type: null, + architecture: null, + executable: input.executable, + content_state: input.contentState, + limitations: [], +}); + +export const rootOccurrenceFor = ( + node: ArtifactNode, + declaredSize: number, +): MutableOccurrence => ({ + occurrence_id: `occ_${digestCanonical({ root: node.artifact_id })}`, + artifact_id: node.artifact_id, + parent_occurrence_id: null, + logical_path: ".", + entry_kind: node.format === "directory" ? "directory" : "file", + declared_size: declaredSize, + compressed_size: null, + executable: node.executable, + encrypted: false, + hash_status: "verified", + source_location: null, + limitations: [], +}); + +export const rekeyOccurrences = ( + rootArtifactId: string, + occurrences: MutableOccurrence[], +): void => { + const replacements = new Map(); + for (const occurrence of occurrences) + replacements.set( + occurrence.occurrence_id, + `occ_${digestCanonical({ + root_artifact_id: rootArtifactId, + logical_path: occurrence.logical_path, + entry_kind: occurrence.entry_kind, + })}`, + ); + for (const occurrence of occurrences) { + occurrence.occurrence_id = + replacements.get(occurrence.occurrence_id) ?? occurrence.occurrence_id; + if (occurrence.parent_occurrence_id !== null) + occurrence.parent_occurrence_id = + replacements.get(occurrence.parent_occurrence_id) ?? + occurrence.parent_occurrence_id; + } +}; + +export const createArtifactEdges = ( + rootArtifactId: string, + occurrences: MutableOccurrence[], + producer?: ArtifactCommand, +): ArtifactEdge[] => { + const byId = new Map(occurrences.map((item) => [item.occurrence_id, item])); + const edges: ArtifactEdge[] = []; + for (const occurrence of occurrences) { + if ( + occurrence.parent_occurrence_id === null || + occurrence.artifact_id === null + ) + continue; + const mappedSource = occurrence.logical_path.endsWith(".map") + ? occurrences.find( + ({ logical_path: path }) => + path === occurrence.logical_path.slice(0, -".map".length), + ) + : undefined; + const parentArtifactId = + mappedSource?.artifact_id ?? + byId.get(occurrence.parent_occurrence_id)?.artifact_id ?? + rootArtifactId; + const semantic = { + parent_artifact_id: parentArtifactId, + child_artifact_id: occurrence.artifact_id, + relation: + occurrence.entry_kind === "slice" + ? ("slice-of" as const) + : relationFor(occurrence.logical_path), + occurrence_id: occurrence.occurrence_id, + logical_path: occurrence.logical_path, + }; + edges.push({ + edge_id: `edge_${digestCanonical(semantic)}`, + ...semantic, + producer: occurrence.entry_kind === "slice" ? (producer ?? null) : null, + ordinal: edges.length, + }); + } + return edges; +}; + +export const nearestParent = ( + path: string, + occurrences: ReadonlyMap, +): MutableOccurrence | undefined => { + const parts = path.split("/"); + while (parts.length > 1) { + parts.pop(); + const candidate = occurrences.get(parts.join("/")); + if (candidate?.entry_kind === "directory") return candidate; + } + return undefined; +}; + +export const classifyArtifactPath = ( + path: string, +): Pick => { + const lower = path.toLowerCase(); + if (lower.endsWith(".map")) + return { kind: "source-map", format: "source-map" }; + if (/\.(?:m?js|cjs)$/u.test(lower)) + return { kind: "javascript", format: "javascript-bundle" }; + if (lower.endsWith(".asar")) return { kind: "container", format: "asar" }; + const archiveFormat = archiveFormatForExtension(extname(lower)); + if (archiveFormat !== undefined) + return { kind: "container", format: archiveFormat }; + if (/\.framework(?:\/|$)/u.test(lower)) + return { kind: "framework", format: "file" }; + if (/\.(?:node|dylib|so)$/u.test(lower)) + return { + kind: lower.endsWith(".node") ? "native-addon" : "dynamic-library", + format: "file", + }; + if (lower.endsWith(".plist")) return { kind: "plist", format: "plist" }; + if (lower.endsWith(".entitlements")) + return { kind: "entitlements", format: "entitlements" }; + return { kind: "resource", format: "file" }; +}; + +/** Classify executable formats from bytes while retaining path-specific roles. */ +export const classifyArtifactContent = ( + path: string, + prefix: Buffer, +): Pick => { + const byPath = classifyArtifactPath(path); + if (prefix.length >= 4) { + const magic = prefix.readUInt32BE(0); + if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) + return { + kind: byPath.kind === "native-addon" ? "native-addon" : "executable", + format: "mach-o-universal", + }; + if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) + return { + kind: ["native-addon", "dynamic-library"].includes(byPath.kind) + ? byPath.kind + : "executable", + format: "mach-o", + }; + if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) + return { + kind: ["native-addon", "dynamic-library"].includes(byPath.kind) + ? byPath.kind + : "executable", + format: "elf", + }; + } + if (prefix.length >= 2 && prefix[0] === 0x4d && prefix[1] === 0x5a) + return { + kind: byPath.kind === "native-addon" ? "native-addon" : "executable", + format: "pe", + }; + return byPath; +}; + +export const pageOf = ( + items: readonly Value[], + offset: number, + limit: number, +) => ({ + items: items.slice(offset, offset + limit), + offset, + limit, + total: items.length, + next_offset: offset + limit < items.length ? offset + limit : null, +}); + +export const toOutputLimits = (limits: ArtifactLimits) => ({ + max_entries: limits.maxEntries, + max_total_bytes: limits.maxTotalBytes, + max_entry_bytes: limits.maxEntryBytes, + max_compression_ratio: limits.maxCompressionRatio, + max_depth: limits.maxDepth, + max_path_bytes: limits.maxPathBytes, +}); + +export const digestCanonical = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Artifact value is not canonical JSON"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const relationFor = (path: string): ArtifactEdge["relation"] => { + const lower = path.toLowerCase(); + if (lower.endsWith(".map")) return "maps-source"; + if (lower.includes(".framework/")) return "embeds"; + return "contains"; +}; + +const archiveFormatForExtension = ( + extension: string, +): "zip" | "ipa" | "apk" | undefined => { + switch (extension) { + case ".zip": + return "zip"; + case ".ipa": + return "ipa"; + case ".apk": + return "apk"; + default: + return undefined; + } +}; + +const depth = (path: string): number => path.split("/").length; diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts new file mode 100644 index 000000000..56bf13751 --- /dev/null +++ b/src/application/ArtifactInventory.ts @@ -0,0 +1,380 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import type { Readable } from "node:stream"; + +import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js"; +import { + ArtifactPathRegistry, + normalizeArtifactPath, +} from "../artifacts/ArtifactPaths.js"; +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactLimits, + type ArtifactReader, +} from "../artifacts/ArtifactReader.js"; +import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js"; +import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; +import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js"; +import { streamChunkToBuffer } from "../artifacts/StreamBytes.js"; +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, + type ArtifactNode, +} from "../domain/artifactGraph.js"; +import { + classifyArtifactPath, + classifyArtifactContent, + createArtifactEdges, + createArtifactNode, + createOccurrence, + createRootNode, + digestCanonical, + materializeDirectoryNodes, + nearestParent, + pageOf, + rekeyOccurrences, + rootOccurrenceFor, + toOutputLimits, + type MutableOccurrence, +} from "./ArtifactGraphConstruction.js"; + +interface InventoryPageInput { + readonly nodeOffset: number; + readonly nodeLimit: number; + readonly occurrenceOffset: number; + readonly occurrenceLimit: number; + readonly edgeOffset: number; + readonly edgeLimit: number; +} + +/** Inventory one local artifact without extracting or mounting it. */ +export const inventoryArtifact = async ( + inputPath: string, + limits: ArtifactLimits, + page: InventoryPageInput, + signal?: AbortSignal, +): Promise => { + abortIfNeeded(signal); + const path = await realpath(inputPath); + const metadata = await lstat(path); + const rootFormat = await classifyRoot(path, metadata.isDirectory()); + const rootDigest = metadata.isDirectory() + ? null + : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal); + const reader = await createReader(path, rootFormat); + + try { + const { nodes, occurrences } = await scanReader(reader, limits, signal); + + materializeDirectoryNodes(occurrences, nodes); + const rootNode = createRootNode({ + path, + format: rootFormat, + directory: metadata.isDirectory(), + digest: rootDigest, + occurrences, + }); + nodes.set(rootNode.artifact_id, rootNode); + rekeyOccurrences(rootNode.artifact_id, occurrences); + const rootOccurrence = rootOccurrenceFor(rootNode, metadata.size); + for (const occurrence of occurrences) + if (occurrence.parent_occurrence_id === null) + occurrence.parent_occurrence_id = rootOccurrence.occurrence_id; + occurrences.unshift(rootOccurrence); + const edges = createArtifactEdges( + rootNode.artifact_id, + occurrences, + reader?.provenance()[0], + ); + const orderedNodes = [...nodes.values()].sort((left, right) => + left.artifact_id.localeCompare(right.artifact_id), + ); + const orderedOccurrences = occurrences.sort((left, right) => + left.logical_path.localeCompare(right.logical_path, "en"), + ); + const orderedEdges = edges.sort((left, right) => + left.edge_id.localeCompare(right.edge_id), + ); + if (rootDigest !== null) { + const verified = await hashReadable( + createReadStream(path), + limits.maxTotalBytes, + signal, + ); + if ( + verified.sha256 !== rootDigest.sha256 || + verified.bytes !== rootDigest.bytes + ) + throw new ArtifactReaderFailure( + "integrity", + "Root artifact changed during inventory", + ); + } + const graphSha256 = digestCanonical({ + nodes: orderedNodes, + occurrences: orderedOccurrences, + edges: orderedEdges, + }); + const manifestId = `agm_${digestCanonical({ + schema_version: 1, + root_artifact_id: rootNode.artifact_id, + graph_sha256: graphSha256, + })}`; + return artifactInventoryResultSchema.parse({ + manifest: { + schema_version: 1, + manifest_id: manifestId, + root_artifact_id: rootNode.artifact_id, + root_sha256: rootNode.sha256, + root_format: rootNode.format, + graph_sha256: graphSha256, + node_count: orderedNodes.length, + occurrence_count: orderedOccurrences.length, + edge_count: orderedEdges.length, + }, + nodes: pageOf(orderedNodes, page.nodeOffset, page.nodeLimit), + occurrences: pageOf( + orderedOccurrences, + page.occurrenceOffset, + page.occurrenceLimit, + ), + edges: pageOf(orderedEdges, page.edgeOffset, page.edgeLimit), + limits: toOutputLimits(limits), + provenance: reader?.provenance() ?? [], + limitations: inventoryLimitations(rootFormat, reader), + }); + } finally { + await reader?.close(); + } +}; + +const inventoryLimitations = ( + format: ArtifactNode["format"], + reader: ArtifactReader | undefined, +): string[] => { + if (reader !== undefined) return []; + if (format === "dmg" || format === "pkg") + return [ + `${format.toUpperCase()} root hash is observed; child inventory requires an approved native macOS adapter.`, + ]; + if (format === "mach-o-universal" && process.platform !== "darwin") + return ["Universal Mach-O slices require the native macOS lipo adapter."]; + return ["Artifact has no child container entries."]; +}; + +const createReader = async ( + path: string, + format: ArtifactNode["format"], +): Promise => { + switch (format) { + case "directory": + return new DirectoryArtifactReader(path); + case "zip": + case "ipa": + case "apk": + return new ZipArtifactReader(path, format); + case "asar": + return new AsarArtifactReader(path); + case "mach-o-universal": + return process.platform === "darwin" + ? new MachOSliceArtifactReader(path) + : undefined; + default: + return undefined; + } +}; + +const scanReader = async ( + reader: ArtifactReader | undefined, + limits: ArtifactLimits, + signal?: AbortSignal, +): Promise<{ + readonly nodes: Map; + readonly occurrences: MutableOccurrence[]; +}> => { + const nodes = new Map(); + const occurrences: MutableOccurrence[] = []; + if (reader === undefined) return { nodes, occurrences }; + const occurrenceByPath = new Map(); + const registry = new ArtifactPathRegistry(); + let totalBytes = 0; + for await (const entry of reader.entries(signal)) { + if (occurrences.length >= limits.maxEntries) + throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded"); + const logicalPath = normalizeArtifactPath(entry.path, limits); + registry.add(logicalPath, entry.kind); + preflightEntry(entry, limits); + const parent = nearestParent(logicalPath, occurrenceByPath); + const occurrence = createOccurrence( + entry, + logicalPath, + parent?.occurrence_id ?? null, + ); + if ((entry.kind === "file" || entry.kind === "slice") && !entry.encrypted) { + const remainingBytes = limits.maxTotalBytes - totalBytes; + if (remainingBytes <= 0) + throw new ArtifactReaderFailure( + "limit", + "Artifact total byte limit exceeded", + ); + if (entry.declaredSize !== null && entry.declaredSize > remainingBytes) + throw new ArtifactReaderFailure( + "limit", + "Declared artifact bytes exceed remaining cumulative limit", + ); + const digest = await hashReadable( + await reader.open(entry, signal), + Math.min(limits.maxEntryBytes, remainingBytes), + signal, + ); + totalBytes += digest.bytes; + if ( + entry.declaredSha256 !== null && + entry.declaredSha256 !== digest.sha256 + ) + throw new ArtifactReaderFailure( + "integrity", + `Artifact integrity metadata disagrees with content: ${logicalPath}`, + ); + if (totalBytes > limits.maxTotalBytes) + throw new ArtifactReaderFailure( + "limit", + "Artifact total byte limit exceeded", + ); + const classified = + entry.kind === "slice" + ? ({ kind: "universal-slice", format: "mach-o" } as const) + : classifyArtifactContent(logicalPath, digest.prefix); + const node = createArtifactNode({ + sha256: digest.sha256, + size: digest.bytes, + kind: classified.kind, + format: classified.format, + executable: entry.executable, + contentState: "embedded", + }); + nodes.set(node.artifact_id, node); + occurrence.artifact_id = node.artifact_id; + occurrence.hash_status = "verified"; + } + occurrences.push(occurrence); + occurrenceByPath.set(logicalPath, occurrence); + } + return { nodes, occurrences }; +}; + +const classifyRoot = async ( + path: string, + directory: boolean, +): Promise => { + if (directory) return "directory"; + const extensionFormat = classifyContainerExtension(path); + if (extensionFormat !== undefined) return extensionFormat; + const handle = await open(path, "r"); + try { + const magic = Buffer.alloc(4); + const observed = await handle.read(magic, 0, magic.length, 0); + if ( + observed.bytesRead === 4 && + magic[0] === 0x50 && + magic[1] === 0x4b && + [0x03, 0x05, 0x07].includes(magic[2] ?? -1) && + [0x04, 0x06, 0x08].includes(magic[3] ?? -1) + ) + return "zip"; + if (observed.bytesRead === 4) { + const header = magic.readUInt32BE(0); + if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(header)) + return "mach-o-universal"; + if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(header)) + return "mach-o"; + if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return "elf"; + } + if (observed.bytesRead >= 2 && magic[0] === 0x4d && magic[1] === 0x5a) + return "pe"; + } finally { + await handle.close(); + } + return classifyArtifactPath(path).format; +}; + +const classifyContainerExtension = ( + path: string, +): ArtifactNode["format"] | undefined => { + const lower = path.toLowerCase(); + for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"] as const) + if (lower.endsWith(`.${format}`)) return format; + return undefined; +}; + +const preflightEntry = (entry: ArtifactEntry, limits: ArtifactLimits): void => { + if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes) + throw new ArtifactReaderFailure( + "limit", + `Entry exceeds byte limit: ${entry.path}`, + ); + if ( + entry.declaredSize !== null && + entry.compressedSize !== null && + entry.compressedSize === 0 && + entry.declaredSize > 0 + ) + throw new ArtifactReaderFailure( + "limit", + `Invalid compression ratio: ${entry.path}`, + ); + if ( + entry.declaredSize !== null && + entry.compressedSize !== null && + entry.compressedSize > 0 && + entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio + ) + throw new ArtifactReaderFailure( + "limit", + `Compression ratio exceeds limit: ${entry.path}`, + ); +}; + +const hashReadable = async ( + stream: Readable, + maximum: number, + signal?: AbortSignal, +): Promise<{ + readonly sha256: string; + readonly bytes: number; + readonly prefix: Buffer; +}> => { + const hash = createHash("sha256"); + const prefixes: Buffer[] = []; + let prefixBytes = 0; + let bytes = 0; + for await (const raw of stream) { + abortIfNeeded(signal); + const chunk = streamChunkToBuffer(raw); + bytes += chunk.length; + if (bytes > maximum) { + stream.destroy(); + throw new ArtifactReaderFailure( + "limit", + "Observed entry bytes exceed limit", + ); + } + hash.update(chunk); + if (prefixBytes < 16) { + const selected = chunk.subarray(0, 16 - prefixBytes); + prefixes.push(selected); + prefixBytes += selected.length; + } + } + return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) }; +}; + +const abortIfNeeded = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "Artifact inventory cancelled", + ); +}; diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index cd0d7c3e1..ebf44969a 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -1,22 +1,47 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; import { parseBinaryTarget } from "../domain/binaryTarget.js"; import { - HopperCancelledError, + AnalysisCapabilityUnavailableError, + EvidenceIntegrityError, + EvidenceLimitError, + AnalysisCancelledError, NoBinaryOpenError, type AnalysisError, } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Evidence } from "../domain/evidence.js"; +import { createEvidence } from "../domain/evidence.js"; import type { EvidenceBundle } from "../domain/evidenceBundle.js"; import type { AnalysisClient, + AnalysisExecution, AnalysisClientFactory, AnalysisOperationPort, AnalysisProvider, + CapabilityDescriptor, ProviderIdentity, } from "./AnalysisProvider.js"; import { EvidenceLedger } from "./EvidenceLedger.js"; +import type { + RecordUnknownInput, + ResidualUnknown, + UnknownStatus, + UpdateUnknownInput, +} from "../domain/residualUnknown.js"; +import { UnknownRegistryError } from "../domain/errors.js"; +import type { AnalysisOperation } from "./AnalysisProvider.js"; +import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; + +const REGISTRY_PROVIDER: ProviderIdentity = { + id: "rea-unknown-registry", + name: "REA residual unknown registry", + version: "1", +}; +const OFFICIAL_OPERATIONS: ReadonlySet = new Set( + OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), +); /** Target lifecycle used by CLI and MCP without exposing a concrete provider. */ export interface BinarySessionPort extends AnalysisOperationPort { @@ -30,10 +55,39 @@ export interface BinarySessionPort extends AnalysisOperationPort { close(): Promise>; status(): JsonValue; activeTarget(): BinaryTarget | undefined; - recordEvidence(evidence: Evidence): void; + recordEvidence( + evidence: Evidence, + ): Result<"added" | "duplicate", EvidenceIntegrityError | EvidenceLimitError>; + hasEvidence(evidenceId: string): boolean; + evidenceById(evidenceId: string): Evidence | undefined; exportEvidenceBundle(): EvidenceBundle; - importEvidenceBundle(bundle: unknown): number; - providerIdentity(): ProviderIdentity; + importEvidenceBundle( + bundle: unknown, + ): Result; + recordUnknown( + input: RecordUnknownInput, + ): Result; + recordEvidenceWithUnknown( + evidence: Evidence, + input: RecordUnknownInput, + ): Result; + updateUnknown( + input: UpdateUnknownInput, + ): Result; + listUnknowns(filters?: { + readonly status?: UnknownStatus; + readonly severity?: ResidualUnknown["severity"]; + readonly domain?: string; + }): ResidualUnknown[]; + verifyUnknownResolution(unknownId: string): Result< + { + readonly valid: boolean; + readonly truthVerified: boolean; + readonly unknown: ResidualUnknown; + }, + UnknownRegistryError + >; + providerIdentity(operation?: AnalysisOperation): ProviderIdentity; } /** @@ -52,7 +106,11 @@ export class BinarySession implements BinarySessionPort { readonly #calls = new Set>(); readonly #createClient: AnalysisClientFactory; readonly #providerIdentity: ProviderIdentity; - readonly #evidence = new EvidenceLedger({ maxRecords: 10_000 }); + readonly #capabilities: ReadonlyMap | undefined; + readonly #evidence = new EvidenceLedger({ + maxRecords: 10_000, + maxBytes: 64 * 1024 * 1024, + }); constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) { this.#createClient = @@ -63,16 +121,56 @@ export class BinarySession implements BinarySessionPort { typeof provider === "function" ? { id: "unidentified", name: "Unidentified provider", version: null } : provider.identity(); + this.#capabilities = + typeof provider === "function" + ? undefined + : new Map( + provider + .capabilities() + .map((descriptor) => [descriptor.operation, descriptor]), + ); } /** Identify the provider producing evidence for this session. */ - providerIdentity(): ProviderIdentity { + providerIdentity(operation?: AnalysisOperation): ProviderIdentity { + if (operation !== undefined) { + const exact = this.#capabilities?.get(operation)?.provider; + if (exact !== undefined) return exact; + if (enhancedToolNameSchema.safeParse(operation).success) { + const providers = new Map(); + for (const descriptor of this.#capabilities?.values() ?? []) + if ( + descriptor.available && + OFFICIAL_OPERATIONS.has(descriptor.operation) + ) + providers.set(descriptor.provider.id, descriptor.provider); + if (providers.size === 1) { + const provider = providers.values().next().value; + if (provider !== undefined) return provider; + } + } + } return this.#providerIdentity; } /** Add one successful public observation to the session ledger. */ - recordEvidence(evidence: Evidence): void { - this.#evidence.record(evidence); + recordEvidence( + evidence: Evidence, + ): Result< + "added" | "duplicate", + EvidenceIntegrityError | EvidenceLimitError + > { + return this.#evidence.record(evidence); + } + + /** Check that comparison input Evidence is already session-owned. */ + hasEvidence(evidenceId: string): boolean { + return this.#evidence.has(evidenceId); + } + + /** Read one detached session-owned Evidence record by semantic ID. */ + evidenceById(evidenceId: string): Evidence | undefined { + return this.#evidence.get(evidenceId); } /** Return a deterministic snapshot without clearing session evidence. */ @@ -81,10 +179,77 @@ export class BinarySession implements BinarySessionPort { } /** Atomically merge a validated evidence bundle into this session. */ - importEvidenceBundle(bundle: unknown): number { + importEvidenceBundle( + bundle: unknown, + ): Result { return this.#evidence.import(bundle); } + /** Create an approved residual unknown and immutable mutation evidence. */ + recordUnknown( + input: RecordUnknownInput, + ): Result { + const evidence = unknownMutationEvidence(this.#active?.target, input); + return this.#evidence.recordUnknown(input, evidence); + } + + /** Atomically record derived Evidence and its approved residual unknown. */ + recordEvidenceWithUnknown( + evidence: Evidence, + input: RecordUnknownInput, + ): Result { + return this.#evidence.recordWithUnknown( + evidence, + input, + unknownMutationEvidence(undefined, input), + ); + } + + /** Update one unknown using compare-and-swap revision semantics. */ + updateUnknown( + input: UpdateUnknownInput, + ): Result { + const evidence = createEvidence(this.#active?.target, REGISTRY_PROVIDER, { + predicateType: "rea.residual-unknown-mutation/v1", + operation: "update_unknown", + parameters: { + unknown_id: input.unknown_id, + expected_revision: input.expected_revision, + }, + result: { action: "update", status: input.status }, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: unknownEvidenceLinks(input), + limitations: [ + "Registry mutation evidence records analyst intent, not proof of the answer.", + ], + }); + return this.#evidence.updateUnknown(input, evidence); + } + + /** Query stable current residual-unknown heads. */ + listUnknowns( + filters: { + readonly status?: UnknownStatus; + readonly severity?: ResidualUnknown["severity"]; + readonly domain?: string; + } = {}, + ): ResidualUnknown[] { + return this.#evidence.listUnknowns(filters); + } + + /** Check whether current head is a bundle-valid resolved state. */ + verifyUnknownResolution(unknownId: string): Result< + { + readonly valid: boolean; + readonly truthVerified: boolean; + readonly unknown: ResidualUnknown; + }, + UnknownRegistryError + > { + return this.#evidence.verifyUnknownResolution(unknownId); + } + /** * Open or switch targets after draining calls against the current target. * Returns the switch failure even if best-effort restoration also fails. @@ -97,7 +262,8 @@ export class BinarySession implements BinarySessionPort { } = {}, ): Promise> { return this.#serialize(async () => { - if (isAborted(options.signal)) return err(new HopperCancelledError()); + if (isAborted(options.signal)) + return err(new AnalysisCancelledError("open_binary")); const parsed = await parseBinaryTarget( path, process.cwd(), @@ -105,7 +271,8 @@ export class BinarySession implements BinarySessionPort { options.targetKind, ); if (!parsed.ok) return parsed; - if (isAborted(options.signal)) return err(new HopperCancelledError()); + if (isAborted(options.signal)) + return err(new AnalysisCancelledError("open_binary")); if (this.#active?.target.path === parsed.value.path) return ok(parsed.value); await this.#drainCalls(); @@ -122,7 +289,7 @@ export class BinarySession implements BinarySessionPort { if (isAborted(options.signal)) { await client.close(); await this.#restore(previous); - return err(new HopperCancelledError()); + return err(new AnalysisCancelledError("open_binary")); } this.#active = { target: parsed.value, client }; return ok(parsed.value); @@ -144,10 +311,48 @@ export class BinarySession implements BinarySessionPort { /** Describe the active binary session. */ status(): JsonValue { const target = this.#active?.target; + const provider = { + id: this.#providerIdentity.id, + name: this.#providerIdentity.name, + version: this.#providerIdentity.version, + }; + const capabilities = + this.#capabilities === undefined + ? [] + : [...this.#capabilities.values()] + .sort((left, right) => + left.operation.localeCompare(right.operation), + ) + .map((descriptor) => ({ + operation: descriptor.operation, + available: descriptor.available, + reason: descriptor.reason, + input_contract_version: descriptor.inputContractVersion, + output_contract_version: descriptor.outputContractVersion, + pagination: descriptor.pagination, + exhaustive: descriptor.exhaustive, + effects: { + mutates_artifact: descriptor.effects.mutatesArtifact, + launches_process: descriptor.effects.launchesProcess, + may_show_ui: descriptor.effects.mayShowUi, + may_access_network: descriptor.effects.mayAccessNetwork, + may_write_filesystem: descriptor.effects.mayWriteFilesystem, + changes_permissions: descriptor.effects.changesPermissions, + requires_root: descriptor.effects.requiresRoot, + }, + limits: { + max_results: descriptor.limits.maxResults, + max_payload_bytes: descriptor.limits.maxPayloadBytes, + timeout_ms: descriptor.limits.timeoutMs, + }, + limitations: [...descriptor.limitations], + })); return target === undefined - ? { open: false } + ? { open: false, provider, capabilities } : { open: true, + provider, + capabilities, path: target.path, sha256: target.sha256, format: target.format, @@ -167,12 +372,21 @@ export class BinarySession implements BinarySessionPort { * entering until the transition has settled. */ async execute( - name: string, + name: Parameters[0], arguments_: Readonly>, options?: { readonly signal?: AbortSignal }, - ): Promise> { - const transitioned = await this.#waitForTransition(options?.signal); + ): Promise> { + const transitioned = await this.#waitForTransition(name, options?.signal); if (!transitioned.ok) return transitioned; + const capability = this.#capabilities?.get(name); + if (this.#capabilities !== undefined && capability?.available !== true) + return err( + new AnalysisCapabilityUnavailableError( + this.#providerIdentity.id, + name, + capability?.reason ?? "operation is not declared by this provider", + ), + ); const active = this.#active; if (active === undefined) return err(new NoBinaryOpenError()); const call = active.client.execute(name, arguments_, options); @@ -210,29 +424,35 @@ export class BinarySession implements BinarySessionPort { } async #waitForTransition( + operation: string, signal?: AbortSignal, - ): Promise> { - if (signal?.aborted === true) return err(new HopperCancelledError()); + ): Promise> { + if (signal?.aborted === true) + return err(new AnalysisCancelledError(operation)); if (signal === undefined) { await this.#transition; return ok(undefined); } return new Promise((resolve) => { const onAbort = (): void => { - resolve(err(new HopperCancelledError())); + resolve(err(new AnalysisCancelledError(operation))); }; signal.addEventListener("abort", onAbort, { once: true }); this.#transition.then( () => { signal.removeEventListener("abort", onAbort); resolve( - signal.aborted ? err(new HopperCancelledError()) : ok(undefined), + signal.aborted + ? err(new AnalysisCancelledError(operation)) + : ok(undefined), ); }, () => { signal.removeEventListener("abort", onAbort); resolve( - signal.aborted ? err(new HopperCancelledError()) : ok(undefined), + signal.aborted + ? err(new AnalysisCancelledError(operation)) + : ok(undefined), ); }, ); @@ -240,5 +460,41 @@ export class BinarySession implements BinarySessionPort { } } +const unknownMutationEvidence = ( + target: BinaryTarget | undefined, + input: RecordUnknownInput, +): Evidence => + createEvidence(target, REGISTRY_PROVIDER, { + predicateType: "rea.residual-unknown-mutation/v1", + operation: "record_unknown", + parameters: { + domain: input.domain, + severity: input.severity, + }, + result: { + action: "record", + question: input.question, + required_authority: input.required_authority, + required_confidence: input.required_confidence, + }, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: unknownEvidenceLinks(input), + limitations: [ + "Registry mutation evidence records analyst intent, not proof of the answer.", + ], + }); + +const unknownEvidenceLinks = ( + input: RecordUnknownInput | UpdateUnknownInput, +): string[] => + [ + ...input.supporting_evidence_ids, + ...input.contradicting_evidence_ids, + ...("resolution" in input && input.resolution !== null + ? input.resolution.evidence_ids + : []), + ].filter((id, index, values) => values.indexOf(id) === index); + const isAborted = (signal: AbortSignal | undefined): boolean => signal?.aborted === true; diff --git a/src/application/CompositeProvider.ts b/src/application/CompositeProvider.ts new file mode 100644 index 000000000..5eb3bc0d9 --- /dev/null +++ b/src/application/CompositeProvider.ts @@ -0,0 +1,96 @@ +import { ProviderSelectionError } from "../domain/errors.js"; +import { err, ok } from "../domain/result.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { + AnalysisClient, + AnalysisProvider, + CapabilityDescriptor, + ProviderIdentity, +} from "./AnalysisProvider.js"; + +const compositeIdentity = ( + providers: readonly AnalysisProvider[], +): ProviderIdentity => ({ + id: `composite:${providers + .map((provider) => provider.identity().id) + .sort() + .join("+")}`, + name: "REA composite analysis provider", + version: null, +}); + +/** Deterministically route disjoint operations without eagerly starting children. */ +export class CompositeProvider implements AnalysisProvider { + readonly #identity: ProviderIdentity; + readonly #capabilities: readonly CapabilityDescriptor[]; + readonly #providerByOperation: ReadonlyMap; + + constructor(readonly providers: readonly AnalysisProvider[]) { + if (providers.length === 0) + throw new RangeError("CompositeProvider requires at least one provider"); + this.#identity = Object.freeze(compositeIdentity(providers)); + const routes = new Map(); + const capabilities: CapabilityDescriptor[] = []; + for (const provider of providers) { + for (const descriptor of provider.capabilities()) { + if (routes.has(descriptor.operation)) + throw new TypeError( + `Multiple providers declare operation ${descriptor.operation}`, + ); + routes.set(descriptor.operation, provider); + capabilities.push(descriptor); + } + } + this.#providerByOperation = routes; + this.#capabilities = Object.freeze( + capabilities.sort( + (left, right) => + left.operation.localeCompare(right.operation) || + left.provider.id.localeCompare(right.provider.id), + ), + ); + } + + identity(): ProviderIdentity { + return this.#identity; + } + + capabilities(): readonly CapabilityDescriptor[] { + return this.#capabilities; + } + + createClient(target: BinaryTarget): AnalysisClient { + const clients = new Map(); + const clientFor = (provider: AnalysisProvider): AnalysisClient => { + const existing = clients.get(provider); + if (existing !== undefined) return existing; + const created = provider.createClient(target); + clients.set(provider, created); + return created; + }; + return { + execute: (operation, parameters, options) => { + if (operation === "health") + return Promise.resolve( + ok({ + result: null, + rawResult: null, + provider: this.#identity, + limitations: [], + locations: [], + subject: null, + }), + ); + const provider = this.#providerByOperation.get(operation); + return provider === undefined + ? Promise.resolve(err(new ProviderSelectionError(operation))) + : clientFor(provider).execute(operation, parameters, options); + }, + close: async () => { + await Promise.allSettled( + [...clients.values()].map(async (client) => client.close()), + ); + }, + }; + } +} diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 740c4d260..cc3c912a8 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -3,6 +3,15 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { createBinarySession } from "./runtime.js"; import { silentLogger, type Logger } from "../logger.js"; import { createEvidence } from "../domain/evidence.js"; +import { EnhancedTools } from "./EnhancedTools.js"; +import type { NativeToolName } from "../contracts/nativeToolContracts.js"; +import type { ArtifactToolName } from "../contracts/artifactToolContracts.js"; + +const WORKFLOW_PROVIDER = { + id: "rea-workflow", + name: "REA composed investigation workflow", + version: "1", +} as const; /** * Open one binary, execute one tool, and always release the bridge session. @@ -14,6 +23,25 @@ export const runDirectAnalysis = async ( tool: "binary_overview" | "procedure_pseudo_code", arguments_: Readonly>, logger: Logger = silentLogger, +): Promise => runAnalysis(path, tool, arguments_, logger); + +/** Execute one provider-native semantic operation with atomic provenance. */ +export const runProviderAnalysis = async ( + path: string, + tool: NativeToolName | ArtifactToolName, + arguments_: Readonly>, + logger: Logger = silentLogger, +): Promise => runAnalysis(path, tool, arguments_, logger); + +const runAnalysis = async ( + path: string, + tool: + | NativeToolName + | ArtifactToolName + | "binary_overview" + | "procedure_pseudo_code", + arguments_: Readonly>, + logger: Logger, ): Promise => { const config = parseConfig(process.env); if (!config.ok) @@ -23,13 +51,32 @@ export const runDirectAnalysis = async ( const opened = await session.open(path); if (!opened.ok) return { error: opened.error._tag, message: opened.error.message }; + if (tool === "binary_overview") { + const result = await new EnhancedTools(session).execute(tool, arguments_); + return result.ok + ? createEvidence(opened.value, WORKFLOW_PROVIDER, { + operation: tool, + parameters: arguments_, + result: result.value, + confidence: "derived", + limitations: ["Derived by an REA composed workflow."], + }) + : { error: result.error._tag, message: result.error.message }; + } const result = await session.execute(tool, arguments_); return result.ok - ? createEvidence(opened.value, session.providerIdentity(), { - operation: tool, - parameters: arguments_, - result: result.value, - }) + ? createEvidence( + result.value.subject ?? opened.value, + result.value.provider, + { + operation: tool, + parameters: arguments_, + result: result.value.result, + rawResult: result.value.rawResult, + limitations: result.value.limitations, + locations: result.value.locations, + }, + ) : { error: result.error._tag, message: result.error.message }; } finally { await session.close(); diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 3cda3e861..aa9cdf949 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -1,7 +1,14 @@ -import type { AnalysisOperationPort } from "./AnalysisProvider.js"; +import type { + AnalysisOperation, + AnalysisOperationPort, +} from "./AnalysisProvider.js"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; -import { AnalysisProtocolError, type AnalysisError } from "../domain/errors.js"; +import { + AnalysisInputError, + AnalysisOutputError, + type AnalysisError, +} from "../domain/errors.js"; import { parseDocuments, parseFunctionDossier, @@ -384,12 +391,18 @@ export class EnhancedTools { if (!xrefs.ok) return xrefs; if (!Array.isArray(xrefs.value)) return err( - new AnalysisProtocolError("xrefs returned a non-array result"), + new AnalysisOutputError( + "xrefs", + "provider returned a non-array result", + ), ); for (const source of xrefs.value) { if (typeof source !== "string") return err( - new AnalysisProtocolError("xrefs returned a non-address value"), + new AnalysisOutputError( + "xrefs", + "provider returned a non-address value", + ), ); if (operations >= operationBudget) { residual.add( @@ -444,8 +457,9 @@ export class EnhancedTools { } if (page.value.nextOffset <= offset) { return err( - new AnalysisProtocolError( - `${tool} returned a non-advancing pagination offset`, + new AnalysisOutputError( + tool, + "provider returned a non-advancing pagination offset", ), ); } @@ -453,23 +467,24 @@ export class EnhancedTools { } } - #call( - name: string, + async #call( + name: AnalysisOperation, arguments_: Readonly>, signal?: AbortSignal, ): Promise> { - return this.analysis.execute( + const execution = await this.analysis.execute( name, arguments_, signal === undefined ? {} : { signal }, ); + return execution.ok ? ok(execution.value.result) : execution; } } const invalidInput = (name: EnhancedToolName, cause: Error): EnhancedResult => Promise.resolve( err( - new AnalysisProtocolError(`Invalid ${name} input after MCP validation`, { + new AnalysisInputError(name, { cause, }), ), diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts new file mode 100644 index 000000000..76860677f --- /dev/null +++ b/src/application/EvidenceBundleCommands.ts @@ -0,0 +1,56 @@ +import type { AnalysisError } from "../domain/errors.js"; +import type { + EvidenceFilePolicy, + EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { EvidenceLedger } from "./EvidenceLedger.js"; +import { + readEvidenceBundle, + writeEvidenceBundle, +} from "./EvidenceBundleFiles.js"; + +/** Validate and merge one bundle using the same bounded ledger as MCP. */ +export const importEvidenceBundleCommand = async ( + path: string, + policy: EvidenceFilePolicy, +): Promise> => { + const loaded = await readEvidenceBundle(path, policy); + if (!loaded.ok) return loaded; + const ledger = createLedger(); + const imported = ledger.import(loaded.value); + return imported.ok + ? ok({ imported: imported.value, total: ledger.export().records.length }) + : imported; +}; + +/** Validate a source bundle and atomically export canonical bytes. */ +export const exportEvidenceBundleCommand = async ( + sourcePath: string, + outputPath: string, + overwrite: boolean, + policy: EvidenceFilePolicy, +): Promise> => { + const loaded = await readEvidenceBundle(sourcePath, policy); + if (!loaded.ok) return loaded; + return projectWrite( + loaded.value, + await writeEvidenceBundle(loaded.value, outputPath, overwrite, policy), + ); +}; + +const createLedger = (): EvidenceLedger => + new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024 }); + +const projectWrite = ( + bundle: EvidenceBundle, + written: Awaited>, +): Result => + written.ok + ? ok({ + path: written.value.path, + bytes: written.value.bytes, + records: bundle.records.length, + }) + : err(written.error); diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts new file mode 100644 index 000000000..be2cd2915 --- /dev/null +++ b/src/application/EvidenceBundleFiles.ts @@ -0,0 +1,159 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; + +import writeFileAtomic from "write-file-atomic"; + +import { + parseEvidenceBundle, + serializeEvidenceBundle, + type EvidenceBundle, + type EvidenceFilePolicy, +} from "../domain/evidenceBundle.js"; +import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError; +type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError; + +/** Read and validate a bounded evidence bundle inside an approved root. */ +export const readEvidenceBundle = async ( + path: string, + policy: EvidenceFilePolicy, +): Promise> => { + if (policy.roots.length === 0) + return err(new EvidenceFileError("read", "disabled")); + try { + const canonicalPath = await realpath(resolve(path)); + if (!(await isApproved(canonicalPath, policy.roots))) + return err(new EvidenceFileError("read", "outside-root")); + const stats = await lstat(canonicalPath); + if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file")); + if (stats.size > policy.maxBytes) + return err(new EvidenceFileError("read", "too-large")); + const encoded = await readFile(canonicalPath); + if (encoded.byteLength > policy.maxBytes) + return err(new EvidenceFileError("read", "too-large")); + let decoded: unknown; + try { + decoded = JSON.parse(encoded.toString("utf8")); + } catch (cause: unknown) { + return err(new EvidenceFileError("read", "invalid-json", { cause })); + } + const limits = validateJsonLimits(decoded, policy); + if (!limits.ok) return limits; + try { + return ok(parseEvidenceBundle(decoded)); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Evidence bundle validation failed", { + cause, + }), + ); + } + } catch (cause: unknown) { + return err(new EvidenceFileError("read", "io", { cause })); + } +}; + +/** Atomically write deterministic evidence JSON inside an approved root. */ +export const writeEvidenceBundle = async ( + bundle: EvidenceBundle, + path: string, + overwrite: boolean, + policy: EvidenceFilePolicy, +): Promise< + Result< + { readonly path: string; readonly bytes: number }, + EvidenceWriteFailure + > +> => { + if (policy.roots.length === 0) + return err(new EvidenceFileError("write", "disabled")); + let encoded: string; + try { + encoded = serializeEvidenceBundle(bundle); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Evidence bundle validation failed", { + cause, + }), + ); + } + const bytes = Buffer.byteLength(encoded, "utf8"); + if (bytes > policy.maxBytes) + return err(new EvidenceFileError("write", "too-large")); + try { + const canonicalParent = await realpath(dirname(resolve(path))); + const destination = resolve(canonicalParent, basename(resolve(path))); + if (!(await isApproved(destination, policy.roots))) + return err(new EvidenceFileError("write", "outside-root")); + const existing = await lstat(destination).catch((cause: unknown) => { + if (isFileNotFound(cause)) return undefined; + throw cause; + }); + if (existing !== undefined) { + if (!overwrite) return err(new EvidenceFileError("write", "exists")); + if (!existing.isFile() || existing.isSymbolicLink()) + return err(new EvidenceFileError("write", "not-file")); + } + await writeFileAtomic(destination, encoded, { + encoding: "utf8", + mode: 0o600, + fsync: true, + }); + return ok({ path: destination, bytes }); + } catch (cause: unknown) { + return err(new EvidenceFileError("write", "io", { cause })); + } +}; + +const isApproved = async ( + candidate: string, + roots: readonly string[], +): Promise => { + for (const configuredRoot of roots) { + const root = await realpath(resolve(configuredRoot)); + const relation = relative(root, candidate); + if ( + relation === "" || + (!relation.startsWith("..") && !isAbsolute(relation)) + ) + return true; + } + return false; +}; + +const validateJsonLimits = ( + root: unknown, + policy: EvidenceFilePolicy, +): Result => { + const pending: Array<{ readonly value: unknown; readonly depth: number }> = [ + { value: root, depth: 0 }, + ]; + let nodes = 0; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + nodes += 1; + if (nodes > policy.maxNodes || current.depth > policy.maxDepth) + return err(new EvidenceFileError("read", "too-large")); + if ( + typeof current.value === "string" && + current.value.length > policy.maxStringLength + ) + return err(new EvidenceFileError("read", "too-large")); + if (typeof current.value !== "object" || current.value === null) continue; + for (const [key, value] of Object.entries(current.value)) { + if (key.length > policy.maxStringLength) + return err(new EvidenceFileError("read", "too-large")); + pending.push({ value, depth: current.depth + 1 }); + } + } + return ok(null); +}; + +const isFileNotFound = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT"; diff --git a/src/application/EvidenceLedger.ts b/src/application/EvidenceLedger.ts index d2007a8b0..942537d08 100644 --- a/src/application/EvidenceLedger.ts +++ b/src/application/EvidenceLedger.ts @@ -1,68 +1,392 @@ import { createEvidenceBundle, - evidenceBundleSchema, + parseEvidenceBundle, type EvidenceBundle, } from "../domain/evidenceBundle.js"; +import { + EvidenceIntegrityError, + EvidenceLimitError, + UnknownRegistryError, +} from "../domain/errors.js"; import { parseEvidence, type Evidence } from "../domain/evidence.js"; +import { + createResidualUnknown, + updateResidualUnknown, + type RecordUnknownInput, + type ResidualUnknown, + type UnknownStatus, + type UpdateUnknownInput, +} from "../domain/residualUnknown.js"; +import { err, ok, type Result } from "../domain/result.js"; export interface EvidenceLedgerLimits { readonly maxRecords: number; + readonly maxBytes: number; } +type EvidenceLedgerFailure = EvidenceIntegrityError | EvidenceLimitError; +type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>; +type ImportResult = Result; + /** Bounded, session-owned set of immutable evidence records. */ export class EvidenceLedger { readonly #records = new Map(); + readonly #unknownRevisions = new Map(); + readonly #unknownHeads = new Map(); + #bytes = 0; constructor(private readonly limits: EvidenceLedgerLimits) { if (!Number.isSafeInteger(limits.maxRecords) || limits.maxRecords < 1) throw new RangeError("maxRecords must be a positive safe integer"); + if (!Number.isSafeInteger(limits.maxBytes) || limits.maxBytes < 1) + throw new RangeError("maxBytes must be a positive safe integer"); } /** Record evidence idempotently; conflicting content is rejected. */ - record(input: Evidence): "added" | "duplicate" { - const evidence = parseEvidence(input); + record(input: Evidence): RecordResult { + let evidence: Evidence; + try { + evidence = parseEvidence(input); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Evidence record validation failed", { + cause, + }), + ); + } const existing = this.#records.get(evidence.evidence_id); if (existing !== undefined) { - if (!recordsAgree(existing, evidence)) - throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`); - return "duplicate"; + return recordsAgree(existing, evidence) + ? ok("duplicate") + : err(new EvidenceIntegrityError("Conflicting evidence record")); } if (this.#records.size >= this.limits.maxRecords) - throw new RangeError("Evidence ledger record limit exceeded"); + return err(new EvidenceLimitError("records", this.limits.maxRecords)); + const bytes = serializedBytes(evidence); + if (this.#bytes + bytes > this.limits.maxBytes) + return err(new EvidenceLimitError("bytes", this.limits.maxBytes)); this.#records.set(evidence.evidence_id, evidence); - return "added"; + this.#bytes += bytes; + return ok("added"); } /** Validate an entire bundle before atomically merging its records. */ - import(input: unknown): number { - const bundle = evidenceBundleSchema.parse(input); + import(input: unknown): ImportResult { + let bundle: EvidenceBundle; + try { + bundle = parseEvidenceBundle(input); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Evidence bundle validation failed", { + cause, + }), + ); + } const pending = new Map(this.#records); - for (const unparsed of bundle.records) { - const evidence = parseEvidence(unparsed); + for (const evidence of bundle.records) { const existing = pending.get(evidence.evidence_id); if (existing !== undefined && !recordsAgree(existing, evidence)) - throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`); + return err(new EvidenceIntegrityError("Conflicting evidence record")); pending.set(evidence.evidence_id, evidence); } - if (pending.size > this.limits.maxRecords) - throw new RangeError("Evidence ledger record limit exceeded"); + const pendingUnknowns = new Map(this.#unknownRevisions); + for (const unknown of bundle.unknowns) { + const key = unknownRevisionKey(unknown); + const existing = pendingUnknowns.get(key); + if ( + existing !== undefined && + existing.revision_digest !== unknown.revision_digest + ) + return err(new EvidenceIntegrityError("Conflicting unknown revision")); + pendingUnknowns.set(key, unknown); + } + const checked = this.#validateCandidate(pending, pendingUnknowns); + if (!checked.ok) { + if (checked.error instanceof UnknownRegistryError) + return err( + new EvidenceIntegrityError( + "Evidence bundle unknown registry failed", + { + cause: checked.error, + }, + ), + ); + return err(checked.error); + } const added = pending.size - this.#records.size; - this.#records.clear(); - for (const [id, evidence] of pending) this.#records.set(id, evidence); - return added; + this.#commit(pending, pendingUnknowns, checked.value); + return ok(added); } /** Export records in deterministic, semantically irrelevant ID order. */ export(): EvidenceBundle { - return createEvidenceBundle([...this.#records.values()]); + return createEvidenceBundle( + [...this.#records.values()], + [...this.#unknownRevisions.values()], + ); + } + + /** Check whether one immutable Evidence ID is present in this ledger. */ + has(evidenceId: string): boolean { + return this.#records.has(evidenceId); + } + + /** Return a detached immutable Evidence value by semantic ID. */ + get(evidenceId: string): Evidence | undefined { + const evidence = this.#records.get(evidenceId); + return evidence === undefined ? undefined : structuredClone(evidence); + } + + /** Create one approved unknown and atomically append its mutation evidence. */ + recordUnknown( + input: RecordUnknownInput, + mutationEvidence: Evidence, + ): Result< + ResidualUnknown, + EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError + > { + let parsedMutation: Evidence; + try { + parsedMutation = parseEvidence(mutationEvidence); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Unknown mutation evidence is invalid", { + cause, + }), + ); + } + let unknown: ResidualUnknown; + try { + unknown = createResidualUnknown( + input, + parsedMutation.evidence_id, + parsedMutation.subject?.digest.sha256 ?? null, + ); + } catch (cause: unknown) { + return err(new UnknownRegistryError("invalid-transition", { cause })); + } + if (this.#unknownHeads.has(unknown.unknown_id)) + return err(new UnknownRegistryError("already-exists")); + return this.#appendUnknown(unknown, parsedMutation); + } + + /** Atomically record derived Evidence and an approved residual unknown. */ + recordWithUnknown( + evidenceInput: Evidence, + input: RecordUnknownInput, + mutationInput: Evidence, + ): Result< + ResidualUnknown | null, + EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError + > { + let evidence: Evidence; + let mutation: Evidence; + try { + evidence = parseEvidence(evidenceInput); + mutation = parseEvidence(mutationInput); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Atomic Evidence validation failed", { + cause, + }), + ); + } + const existingEvidence = this.#records.get(evidence.evidence_id); + if ( + existingEvidence !== undefined && + !recordsAgree(existingEvidence, evidence) + ) + return err(new EvidenceIntegrityError("Conflicting evidence record")); + let unknown: ResidualUnknown; + try { + unknown = createResidualUnknown( + input, + mutation.evidence_id, + mutation.subject?.digest.sha256 ?? null, + ); + } catch (cause: unknown) { + return err(new UnknownRegistryError("invalid-transition", { cause })); + } + const pendingRecords = new Map(this.#records); + pendingRecords.set(evidence.evidence_id, evidence); + const existingUnknown = this.#unknownHeads.get(unknown.unknown_id); + if (existingUnknown !== undefined) { + if ( + existingUnknown.revision !== 1 || + existingUnknown.revision_digest !== unknown.revision_digest + ) + return err(new UnknownRegistryError("already-exists")); + const checked = this.#validateCandidate( + pendingRecords, + this.#unknownRevisions, + ); + if (!checked.ok) return checked; + this.#commit(pendingRecords, this.#unknownRevisions, checked.value); + return ok(null); + } + pendingRecords.set(mutation.evidence_id, mutation); + const pendingUnknowns = new Map(this.#unknownRevisions); + pendingUnknowns.set(unknownRevisionKey(unknown), unknown); + const checked = this.#validateCandidate(pendingRecords, pendingUnknowns); + if (!checked.ok) return checked; + this.#commit(pendingRecords, pendingUnknowns, checked.value); + return ok(unknown); + } + + /** Apply one approved full-state update using optimistic revision matching. */ + updateUnknown( + input: UpdateUnknownInput, + mutationEvidence: Evidence, + ): Result< + ResidualUnknown, + EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError + > { + const current = this.#unknownHeads.get(input.unknown_id); + if (current === undefined) + return err(new UnknownRegistryError("not-found")); + if (current.revision !== input.expected_revision) + return err(new UnknownRegistryError("revision-conflict")); + let unknown: ResidualUnknown; + try { + unknown = updateResidualUnknown( + current, + input, + mutationEvidence.evidence_id, + ); + } catch (cause: unknown) { + return err(new UnknownRegistryError("invalid-transition", { cause })); + } + return this.#appendUnknown(unknown, mutationEvidence); + } + + /** Query current heads in stable ID order. */ + listUnknowns( + filters: { + readonly status?: UnknownStatus; + readonly severity?: ResidualUnknown["severity"]; + readonly domain?: string; + } = {}, + ): ResidualUnknown[] { + return [...this.#unknownHeads.values()] + .filter( + (unknown) => + (filters.status === undefined || unknown.status === filters.status) && + (filters.severity === undefined || + unknown.severity === filters.severity) && + (filters.domain === undefined || unknown.domain === filters.domain), + ) + .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id)); + } + + /** Return current resolution validity; imported invalid states are rejected. */ + verifyUnknownResolution(unknownId: string): Result< + { + readonly valid: boolean; + readonly truthVerified: boolean; + readonly unknown: ResidualUnknown; + }, + UnknownRegistryError + > { + const unknown = this.#unknownHeads.get(unknownId); + if (unknown === undefined) + return err(new UnknownRegistryError("not-found")); + return ok({ + valid: unknown.status === "resolved", + truthVerified: unknown.resolution?.disposition === "verified", + unknown, + }); } /** Clear records when the owning session closes. */ clear(): void { this.#records.clear(); + this.#unknownRevisions.clear(); + this.#unknownHeads.clear(); + this.#bytes = 0; + } + + #appendUnknown( + unknown: ResidualUnknown, + mutationEvidenceInput: Evidence, + ): Result< + ResidualUnknown, + EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError + > { + let mutationEvidence: Evidence; + try { + mutationEvidence = parseEvidence(mutationEvidenceInput); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Unknown mutation evidence is invalid", { + cause, + }), + ); + } + const pendingRecords = new Map(this.#records); + pendingRecords.set(mutationEvidence.evidence_id, mutationEvidence); + const pendingUnknowns = new Map(this.#unknownRevisions); + pendingUnknowns.set(unknownRevisionKey(unknown), unknown); + const checked = this.#validateCandidate(pendingRecords, pendingUnknowns); + if (!checked.ok) return checked; + this.#commit(pendingRecords, pendingUnknowns, checked.value); + return ok(unknown); + } + + #validateCandidate( + records: ReadonlyMap, + unknowns: ReadonlyMap, + ): Result< + number, + EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError + > { + if (records.size + unknowns.size > this.limits.maxRecords) + return err(new EvidenceLimitError("records", this.limits.maxRecords)); + const bytes = + [...records.values()].reduce( + (total, evidence) => total + serializedBytes(evidence), + 0, + ) + + [...unknowns.values()].reduce( + (total, unknown) => total + serializedBytes(unknown), + 0, + ); + if (bytes > this.limits.maxBytes) + return err(new EvidenceLimitError("bytes", this.limits.maxBytes)); + try { + parseEvidenceBundle( + createEvidenceBundle([...records.values()], [...unknowns.values()]), + ); + } catch (cause: unknown) { + return err(new UnknownRegistryError("integrity", { cause })); + } + return ok(bytes); + } + + #commit( + records: ReadonlyMap, + unknowns: ReadonlyMap, + bytes: number, + ): void { + this.#records.clear(); + for (const [id, evidence] of records) this.#records.set(id, evidence); + this.#unknownRevisions.clear(); + this.#unknownHeads.clear(); + for (const [key, unknown] of unknowns) { + this.#unknownRevisions.set(key, unknown); + const head = this.#unknownHeads.get(unknown.unknown_id); + if (head === undefined || head.revision < unknown.revision) + this.#unknownHeads.set(unknown.unknown_id, unknown); + } + this.#bytes = bytes; } } +const serializedBytes = (value: Evidence | ResidualUnknown): number => + Buffer.byteLength(JSON.stringify(value), "utf8"); + +const unknownRevisionKey = (unknown: ResidualUnknown): string => + `${unknown.unknown_id}:${String(unknown.revision)}`; + const recordsAgree = (left: Evidence, right: Evidence): boolean => JSON.stringify(withoutLocalPath(left)) === JSON.stringify(withoutLocalPath(right)); diff --git a/src/application/FilesystemSnapshot.ts b/src/application/FilesystemSnapshot.ts new file mode 100644 index 000000000..8b1dd4866 --- /dev/null +++ b/src/application/FilesystemSnapshot.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { lstat, open, readdir, readlink } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import type { FileState, ProcessScenario } from "../domain/processCapture.js"; + +export interface SnapshotResult { + readonly files: readonly FileState[]; + readonly truncated: boolean; +} + +const isWithin = (candidate: string, root: string): boolean => + candidate === root || + candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`); + +const hashFile = async ( + path: string, + maxBytes: number, +): Promise => { + const handle = await open( + path, + fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0), + ); + try { + const stats = await handle.stat(); + if (!stats.isFile() || stats.size > maxBytes) return null; + return createHash("sha256") + .update(await handle.readFile()) + .digest("hex"); + } finally { + await handle.close(); + } +}; + +/** Capture bounded, root-aliased filesystem state without following symlinks. */ +export const snapshotRoots = async ( + scenario: ProcessScenario, +): Promise => { + const entries: FileState[] = []; + let remainingBytes = scenario.limits.file_bytes; + let truncated = false; + const visit = async ( + root: string, + rootAlias: string, + path: string, + depth: number, + ): Promise => { + if ( + entries.length >= scenario.limits.files || + depth > scenario.limits.filesystem_depth + ) { + truncated = true; + return; + } + let stats; + try { + stats = await lstat(path); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return; + throw error; + } + const relativePath = relative(root, path) || "."; + if (stats.isSymbolicLink()) { + const target = resolve(dirname(path), await readlink(path)); + const safeTarget = isWithin(target, root) + ? relative(root, target) || "." + : ""; + if (safeTarget === "") truncated = true; + entries.push({ + path: `${rootAlias}:${relativePath}`, + type: "symlink", + mode: stats.mode, + size: stats.size, + sha256: null, + symlink_target: safeTarget, + }); + return; + } + if (stats.isFile()) { + const sha256 = + remainingBytes >= stats.size + ? await hashFile(path, remainingBytes) + : null; + remainingBytes -= sha256 === null ? 0 : stats.size; + if (sha256 === null) truncated = true; + entries.push({ + path: `${rootAlias}:${relativePath}`, + type: "file", + mode: stats.mode, + size: stats.size, + sha256, + symlink_target: null, + }); + return; + } + const type = stats.isDirectory() ? "directory" : "other"; + entries.push({ + path: `${rootAlias}:${relativePath}`, + type, + mode: stats.mode, + size: stats.size, + sha256: null, + symlink_target: null, + }); + if (type !== "directory") return; + for (const child of (await readdir(path)).sort()) + await visit(root, rootAlias, join(path, child), depth + 1); + }; + for (const [index, root] of scenario.filesystem_roots.entries()) + await visit(root, `root_${String(index)}`, root, 0); + return { files: entries, truncated }; +}; diff --git a/src/application/LoopbackReplay.ts b/src/application/LoopbackReplay.ts index e34878ccc..f3e772d39 100644 --- a/src/application/LoopbackReplay.ts +++ b/src/application/LoopbackReplay.ts @@ -1,4 +1,9 @@ -import { createServer, type Server } from "node:http"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; import { WebSocketServer, type WebSocket } from "ws"; import type { ProcessScenario, @@ -10,46 +15,203 @@ export interface LoopbackReplay { readonly httpUrl: string; readonly websocketUrl: string; readonly events: ProtocolEvent[]; + readonly truncated: boolean; close(): Promise; } -const closeServer = async (server: Server): Promise => - new Promise((resolveClose, rejectClose) => { - server.close((error) => - error === undefined ? resolveClose() : rejectClose(error), - ); - }); +class ReplayRecorder { + readonly events: ProtocolEvent[] = []; + readonly started = Date.now(); + truncated = false; -/** Start bounded HTTP and WebSocket replay endpoints bound only to IPv4 loopback. */ -export const startLoopbackReplay = async ( - scenario: ProcessScenario, -): Promise => { - const events: ProtocolEvent[] = []; - const record = (event: Omit): void => { - if (events.length < 10_000) - events.push({ sequence: events.length, ...event }); - }; - const server = createServer((request, response) => { + constructor(readonly scenario: ProcessScenario) {} + + atMs(): number { + return ( + Math.floor( + (Date.now() - this.started) / + this.scenario.normalization.time_bucket_ms, + ) * this.scenario.normalization.time_bucket_ms + ); + } + + record(event: Omit): void { + if (this.events.length < this.scenario.limits.protocol_events) + this.events.push({ sequence: this.events.length, ...event }); + else this.truncated = true; + } +} + +const readRequestBody = async ( + request: IncomingMessage, + limit: number, +): Promise => { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += value.byteLength; + if (bytes > limit) return undefined; + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +}; + +const createHttpHandler = ( + recorder: ReplayRecorder, +): ((request: IncomingMessage, response: ServerResponse) => Promise) => { + const calls = new Map(); + return async (request, response) => { const method = request.method ?? "GET"; const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname; - record({ protocol: "http", direction: "request", method, path, data: "" }); - const route = scenario.replay.http.find( - (candidate) => candidate.method === method && candidate.path === path, + const body = await readRequestBody( + request, + recorder.scenario.limits.protocol_body_bytes, ); - response.statusCode = route?.status ?? 404; - const body = route?.body ?? ""; - response.end(body); - record({ + if (body === undefined) { + recorder.truncated = true; + response.statusCode = 413; + response.end(); + recorder.record({ + at_ms: recorder.atMs(), + protocol: "http", + direction: "request", + method, + path, + data: "", + outcome: "unmatched", + }); + return; + } + const index = recorder.scenario.replay.http.findIndex((route) => { + if (route.method !== method || route.path !== path) return false; + if (route.request_body !== undefined && route.request_body !== body) + return false; + return Object.entries(route.request_headers).every( + ([name, value]) => request.headers[name.toLowerCase()] === value, + ); + }); + const route = recorder.scenario.replay.http[index]; + const used = index < 0 ? 0 : (calls.get(index) ?? 0); + const outcome = + route === undefined + ? "unmatched" + : used >= route.max_calls + ? "script_exhausted" + : "matched"; + recorder.record({ + at_ms: recorder.atMs(), + protocol: "http", + direction: "request", + method, + path, + data: body.length === 0 ? "" : "", + outcome, + }); + if (route === undefined || outcome === "script_exhausted") { + response.statusCode = route === undefined ? 404 : 409; + response.end(); + return; + } + calls.set(index, used + 1); + if (route.delay_ms > 0) + await new Promise((resolveDelay) => + setTimeout(resolveDelay, route.delay_ms), + ); + if (route.disconnect) { + response.destroy(); + recorder.record({ + at_ms: recorder.atMs(), + protocol: "http", + direction: "response", + method, + path, + data: "", + outcome: "disconnected", + }); + return; + } + response.statusCode = route.status; + for (const [name, value] of Object.entries(route.response_headers)) + response.setHeader(name, value); + response.end(route.body); + recorder.record({ + at_ms: recorder.atMs(), protocol: "http", direction: "response", method, path, - data: body, + data: route.body, + outcome: "matched", }); - }); + }; +}; + +const sendWebSocketScript = async ( + client: WebSocket, + connection: number, + recorder: ReplayRecorder, +): Promise => { + const scripts = recorder.scenario.replay.websocket_connections; + const script = scripts[connection - 1]; + if (scripts.length > 0 && script === undefined) { + recorder.record({ + at_ms: recorder.atMs(), + protocol: "websocket", + direction: "sent", + method: null, + path: "/ws", + data: "", + outcome: "script_exhausted", + }); + client.terminate(); + return; + } + const messages = + script?.messages ?? + recorder.scenario.replay.websocket_messages.map((data) => ({ + data, + delay_ms: 0, + })); + for (const message of messages) { + if (message.delay_ms > 0) + await new Promise((resolveDelay) => + setTimeout(resolveDelay, message.delay_ms), + ); + if (client.readyState !== 1) return; + client.send(message.data); + recorder.record({ + at_ms: recorder.atMs(), + protocol: "websocket", + direction: "sent", + method: null, + path: "/ws", + data: message.data, + outcome: "matched", + }); + } + if (script?.disconnect_after === true) { + client.close(); + recorder.record({ + at_ms: recorder.atMs(), + protocol: "websocket", + direction: "sent", + method: null, + path: "/ws", + data: "", + outcome: "disconnected", + }); + } +}; + +const createWebSocketReplay = ( + server: Server, + recorder: ReplayRecorder, +): WebSocketServer => { + let connections = 0; const websocket = new WebSocketServer({ noServer: true, - maxPayload: 1_000_000, + maxPayload: recorder.scenario.limits.protocol_body_bytes, clientTracking: true, }); server.on("upgrade", (request, socket, head) => { @@ -62,27 +224,30 @@ export const startLoopbackReplay = async ( ); }); websocket.on("connection", (client: WebSocket) => { + connections += 1; + if (connections > recorder.scenario.limits.connections) { + recorder.truncated = true; + client.terminate(); + return; + } client.on("error", () => undefined); client.on("message", (value) => - record({ + recorder.record({ + at_ms: recorder.atMs(), protocol: "websocket", direction: "received", method: null, path: "/ws", data: value.toString(), + outcome: "matched", }), ); - for (const message of scenario.replay.websocket_messages) { - client.send(message); - record({ - protocol: "websocket", - direction: "sent", - method: null, - path: "/ws", - data: message, - }); - } + void sendWebSocketScript(client, connections, recorder); }); + return websocket; +}; + +const listen = async (server: Server): Promise => { await new Promise((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(0, "127.0.0.1", () => { @@ -91,14 +256,39 @@ export const startLoopbackReplay = async ( }); }); const address = server.address(); - if (address === null || typeof address === "string") { - await closeServer(server); + if (address === null || typeof address === "string") throw new Error("loopback replay did not acquire a TCP port"); + return address.port; +}; + +const closeServer = (server: Server): Promise => + new Promise((resolveClose, rejectClose) => { + server.close((error) => + error === undefined ? resolveClose() : rejectClose(error), + ); + }); + +/** Start bounded HTTP and WebSocket replay endpoints on IPv4 loopback only. */ +export const startLoopbackReplay = async ( + scenario: ProcessScenario, +): Promise => { + const recorder = new ReplayRecorder(scenario); + const server = createServer(createHttpHandler(recorder)); + const websocket = createWebSocketReplay(server, recorder); + let port: number; + try { + port = await listen(server); + } catch (cause: unknown) { + await closeServer(server).catch(() => undefined); + throw cause; } return { - httpUrl: `http://127.0.0.1:${String(address.port)}`, - websocketUrl: `ws://127.0.0.1:${String(address.port)}/ws`, - events, + httpUrl: `http://127.0.0.1:${String(port)}`, + websocketUrl: `ws://127.0.0.1:${String(port)}/ws`, + events: recorder.events, + get truncated() { + return recorder.truncated; + }, async close() { for (const client of websocket.clients) client.terminate(); await new Promise((resolveClose) => diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 1283817eb..21b4097f6 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -1,17 +1,7 @@ -import { createHash } from "node:crypto"; -import { - lstat, - mkdtemp, - readdir, - readFile, - readlink, - realpath, - rm, -} from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { join } from "node:path"; import { spawn, type IPty } from "node-pty"; import type { FileState, @@ -25,8 +15,14 @@ import { authorizeProcessScenario } from "../domain/processCapture.js"; import { err, ok, type Result } from "../domain/result.js"; import { AnalysisError } from "../domain/errors.js"; import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js"; - -const execFileAsync = promisify(execFile); +import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js"; +import { startProcessSampler } from "./ProcessSampling.js"; +import { snapshotRoots, type SnapshotResult } from "./FilesystemSnapshot.js"; +import { + normalizeProcessSamples, + normalizeProcessText, + redactProtocolEvents, +} from "./ProcessNormalization.js"; const isWithin = (candidate: string, root: string): boolean => candidate === root || @@ -66,6 +62,11 @@ export class ProcessCaptureError extends AnalysisError { readonly _tag = "ProcessCaptureError"; } +const assertNotCancelled = (signal: AbortSignal | undefined): void => { + if (signal?.aborted === true) + throw new ProcessCaptureError("process capture was cancelled"); +}; + /** Runtime availability of the native PTY adapter on this host. */ export type ProcessCaptureCapability = | { readonly available: true; readonly backend: "node-pty" } @@ -103,129 +104,17 @@ export const probeProcessCaptureCapability = } }; -interface SnapshotResult { - readonly files: readonly FileState[]; - readonly truncated: boolean; -} - -const hashFile = async ( - path: string, - maxBytes: number, -): Promise => { - const value = await readFile(path); - if (value.byteLength > maxBytes) return null; - return createHash("sha256").update(value).digest("hex"); -}; - -const snapshotRoots = async ( - scenario: ProcessScenario, -): Promise => { - const entries: FileState[] = []; - let remainingBytes = scenario.limits.file_bytes; - let truncated = false; - const visit = async (root: string, path: string): Promise => { - if (entries.length >= scenario.limits.files) { - truncated = true; - return; - } - let stats; - try { - stats = await lstat(path); - } catch (error: unknown) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") - return; - throw error; - } - const relativePath = relative(root, path) || "."; - if (stats.isSymbolicLink()) { - entries.push({ - path: `${root}:${relativePath}`, - type: "symlink", - mode: stats.mode, - size: stats.size, - sha256: null, - symlink_target: await readlink(path), - }); - return; - } - if (stats.isFile()) { - const allowedBytes = Math.max( - 0, - Math.min(remainingBytes, scenario.limits.file_bytes), - ); - const sha256 = - allowedBytes >= stats.size ? await hashFile(path, allowedBytes) : null; - remainingBytes -= sha256 === null ? 0 : stats.size; - if (sha256 === null) truncated = true; - entries.push({ - path: `${root}:${relativePath}`, - type: "file", - mode: stats.mode, - size: stats.size, - sha256, - symlink_target: null, - }); - return; - } - const type = stats.isDirectory() ? "directory" : "other"; - entries.push({ - path: `${root}:${relativePath}`, - type, - mode: stats.mode, - size: stats.size, - sha256: null, - symlink_target: null, - }); - if (type !== "directory") return; - for (const child of (await readdir(path)).sort()) - await visit(root, join(path, child)); - }; - for (const root of scenario.filesystem_roots) await visit(root, root); - return { files: entries, truncated }; -}; - -const sampleProcesses = async ( - rootPid: number, - elapsedMs: number, - limit: number, -): Promise => { - if (process.platform === "win32") return []; - const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="]); - const rows = stdout - .split("\n") - .map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/.exec(line)) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ - pid: Number(match[1]), - parent_pid: Number(match[2]), - command: match[3] ?? "", - })); - const owned = new Set([rootPid]); - let changed = true; - while (changed) { - changed = false; - for (const row of rows) { - if (owned.has(row.parent_pid) && !owned.has(row.pid)) { - owned.add(row.pid); - changed = true; - } - } - } - return rows - .filter((row) => owned.has(row.pid)) - .slice(0, limit) - .map((row) => ({ at_ms: elapsedMs, ...row })); -}; - const makeEnvironment = ( scenario: ProcessScenario, home: string, replay: LoopbackReplay, + runId: string, ): Record => { const environment: Record = { + ...scenario.environment, HOME: home, TERM: "xterm-256color", - ...scenario.environment, + REA_PROCESS_RUN_ID: runId, }; for (const name of scenario.inherit_environment) { const value = process.env[name]; @@ -236,29 +125,6 @@ const makeEnvironment = ( return environment; }; -const normalizeTerminalData = ( - value: string, - scenario: ProcessScenario, - temporaryRoot: string, - pid: number, -): string => { - let normalized = value; - for (const alias of scenario.secret_aliases) { - const secret = scenario.environment[alias]; - if (secret !== undefined && secret.length > 0) - normalized = normalized.replaceAll(secret, ""); - } - if (scenario.normalization.paths) - normalized = normalized.replaceAll(temporaryRoot, ""); - if (scenario.normalization.pids) - normalized = normalized.replaceAll(String(pid), ""); - if (scenario.normalization.ports) - normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, ""); - for (const pattern of scenario.normalization.patterns) - normalized = normalized.replaceAll(pattern.pattern, pattern.replacement); - return normalized; -}; - const scheduleScenarioEvents = ( scenario: ProcessScenario, getTerminal: () => IPty | undefined, @@ -276,6 +142,24 @@ const scheduleScenarioEvents = ( } }; +const startScenarioEvents = ( + scenario: ProcessScenario, + getTerminal: () => IPty | undefined, + timers: Set, +): (() => void) => { + let started = false; + return () => { + if (started) return; + started = true; + scheduleScenarioEvents(scenario, getTerminal, timers); + }; +}; + +const eventsRequireReadiness = (scenario: ProcessScenario): boolean => + scenario.events.some( + (event) => event.type === "input" || event.type === "resize", + ); + interface TerminalExitOptions { readonly terminal: IPty; readonly scenario: ProcessScenario; @@ -287,7 +171,11 @@ interface TerminalExitOptions { interface CaptureResultOptions { readonly frames: readonly TerminalFrame[]; - readonly exit: { readonly exitCode: number; readonly signal?: number }; + readonly exit: { + readonly exitCode: number; + readonly signal?: number; + readonly reason: "exited" | "timeout" | "idle_timeout"; + }; readonly samples: readonly ProcessSample[]; readonly replay: LoopbackReplay; readonly before: SnapshotResult; @@ -297,56 +185,40 @@ interface CaptureResultOptions { readonly rootPid: number; } -const normalizeSamples = ( - samples: readonly ProcessSample[], - scenario: ProcessScenario, - rootPid: number, -): readonly ProcessSample[] => { - const identifiers = [ - rootPid, - ...samples.flatMap((sample) => [sample.pid, sample.parent_pid]), - ]; - const mapping = new Map(); - for (const identifier of identifiers) - if (identifier > 0 && !mapping.has(identifier)) - mapping.set(identifier, mapping.size + 1); - return samples.map((sample) => ({ - at_ms: - Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) * - scenario.normalization.time_bucket_ms, - pid: mapping.get(sample.pid) ?? 1, - parent_pid: mapping.get(sample.parent_pid) ?? 0, - command: normalizeTerminalData( - sample.command, - scenario, - "", - rootPid, - ), - })); +const classifyFilesystemEffects = ( + before: readonly FileState[], + after: readonly FileState[], +): ProcessCapture["filesystem_effects"] => { + const beforeByPath = new Map(before.map((file) => [file.path, file])); + const afterByPath = new Map(after.map((file) => [file.path, file])); + const paths = [ + ...new Set([...beforeByPath.keys(), ...afterByPath.keys()]), + ].sort(); + return paths.map((path) => { + const beforeFile = beforeByPath.get(path) ?? null; + const afterFile = afterByPath.get(path) ?? null; + const status = + beforeFile === null + ? "created" + : afterFile === null + ? "deleted" + : JSON.stringify(beforeFile) === JSON.stringify(afterFile) + ? "unchanged" + : "modified"; + return { path, status, before: beforeFile, after: afterFile }; + }); }; -const redactProtocolEvents = ( - events: readonly ProcessCapture["protocol_events"][number][], - scenario: ProcessScenario, -): readonly ProcessCapture["protocol_events"][number][] => - events.map((event) => ({ - ...event, - data: normalizeTerminalData( - event.data, - scenario, - "", - -1, - ), - })); - const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ - schema_version: 1, + schema_version: 2, + normalization: options.scenario.normalization, frames: options.frames, exit: { code: options.exit.exitCode < 0 ? null : options.exit.exitCode, signal: options.exit.signal ?? null, + reason: options.exit.reason, }, - process_samples: normalizeSamples( + process_samples: normalizeProcessSamples( options.samples, options.scenario, options.rootPid, @@ -357,12 +229,30 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ ), files_before: options.before.files, files_after: options.after.files, + filesystem_effects: classifyFilesystemEffects( + options.before.files, + options.after.files, + ), truncated: options.truncated, limitations: [ "Process trees are sampled and may omit short-lived descendants.", "Filesystem observations are before/after snapshots, not syscall traces.", "The harness does not enforce external network isolation.", ], + residual_unknowns: [ + { + scope: "process", + reason: "Process trees are sampled and may omit short-lived descendants.", + }, + { + scope: "network", + reason: "External network isolation is not enforced by this adapter.", + }, + ], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, }); const awaitTerminalExit = async ({ @@ -372,21 +262,117 @@ const awaitTerminalExit = async ({ lastOutput, signal, timers, -}: TerminalExitOptions): Promise<{ exitCode: number; signal?: number }> => +}: TerminalExitOptions): Promise<{ + exitCode: number; + signal?: number; + reason: "exited" | "timeout" | "idle_timeout" | "cancelled"; +}> => new Promise((resolveExit) => { - terminal.onExit(resolveExit); + let reason: "exited" | "timeout" | "idle_timeout" | "cancelled" = "exited"; + terminal.onExit((exit) => resolveExit({ ...exit, reason })); const timeout = setInterval(() => { - if ( - Date.now() - started >= scenario.timeout_ms || - Date.now() - lastOutput() >= scenario.idle_timeout_ms || - signal?.aborted === true - ) { + if (signal?.aborted === true) { + reason = "cancelled"; + terminal.kill("SIGKILL"); + } else if (Date.now() - started >= scenario.timeout_ms) { + reason = "timeout"; + terminal.kill("SIGKILL"); + } else if (Date.now() - lastOutput() >= scenario.idle_timeout_ms) { + reason = "idle_timeout"; terminal.kill("SIGKILL"); } }, 20); timers.add(timeout); }); +const releaseProcessResources = async (options: { + readonly timers: ReadonlySet; + readonly replay: LoopbackReplay | undefined; + readonly terminal: IPty | undefined; + readonly runId: string; + readonly temporaryRoot: string; +}): Promise => { + for (const timer of options.timers) clearTimeout(timer); + let failure: string | undefined; + try { + await options.replay?.close(); + } catch { + failure = "loopback replay cleanup failed"; + } + if (options.terminal !== undefined && process.platform !== "win32") { + const cleaned = await cleanupOwnedProcessGroup({ + runId: options.runId, + leaderPid: options.terminal.pid, + processGroupId: options.terminal.pid, + }); + if (!cleaned.cleaned) failure ??= cleaned.reason; + } + try { + await rm(options.temporaryRoot, { recursive: true, force: true }); + } catch { + failure ??= "temporary process root cleanup failed"; + } + return failure; +}; + +const captureTerminalFrames = (options: { + readonly terminal: IPty; + readonly scenario: ProcessScenario; + readonly frames: TerminalFrame[]; + readonly started: number; + readonly temporaryRoot: string; + readonly onOutput: () => void; + readonly onFirstOutput: () => void; +}): (() => boolean) => { + let outputBytes = 0; + let truncated = false; + options.terminal.onData((data) => { + options.onFirstOutput(); + options.onOutput(); + const bytes = Buffer.byteLength(data); + if ( + options.frames.length >= options.scenario.limits.frames || + outputBytes + bytes > options.scenario.limits.output_bytes + ) { + truncated = true; + return; + } + outputBytes += bytes; + options.frames.push({ + sequence: options.frames.length, + at_ms: + Math.floor( + (Date.now() - options.started) / + options.scenario.normalization.time_bucket_ms, + ) * options.scenario.normalization.time_bucket_ms, + data: normalizeProcessText( + data, + options.scenario, + options.temporaryRoot, + options.terminal.pid, + ), + }); + }); + return () => truncated; +}; + +const resolveProcessResult = ( + capture: ProcessCapture | undefined, + executionFailure: unknown, + cleanupFailure: string | undefined, +): ProcessCapture => { + if (cleanupFailure !== undefined) + throw new ProcessCaptureError(cleanupFailure); + if (executionFailure instanceof ProcessCaptureError) throw executionFailure; + if (executionFailure !== undefined) + throw new ProcessCaptureError("process capture failed", { + cause: executionFailure, + }); + if (capture === undefined) + throw new ProcessCaptureError("process capture produced no result"); + return capture; +}; + /** Execute one authorized scenario and return bounded observations. */ const runProcessScenario = async ( scenario: ProcessScenario, @@ -396,68 +382,54 @@ const runProcessScenario = async ( const decision = authorizeProcessScenario(scenario, policy); if (!decision.allowed) throw new ProcessCaptureError(decision.reason); await assertRealPathAuthority(scenario, policy); - if (signal?.aborted === true) - throw new ProcessCaptureError("process capture was cancelled"); + assertNotCancelled(signal); const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-")); + const runId = randomUUID(); const home = join(temporaryRoot, "home"); await import("node:fs/promises").then(({ mkdir }) => mkdir(home)); const before = await snapshotRoots(scenario); const frames: TerminalFrame[] = []; const samples: ProcessSample[] = []; - let outputBytes = 0; let truncated = before.truncated; let terminal: IPty | undefined; let replay: LoopbackReplay | undefined; - const started = Date.now(); - let lastOutput = started; + let started = 0; + let lastOutput = 0; const timers = new Set(); + let capture: ProcessCapture | undefined; + let executionFailure: unknown; + let framesTruncated = (): boolean => false; + let stopSampler: () => Promise = async () => undefined; try { replay = await startLoopbackReplay(scenario); + started = Date.now(); + lastOutput = started; terminal = spawn(scenario.executable, [...scenario.arguments], { cwd: scenario.working_directory, - env: makeEnvironment(scenario, home, replay), + env: makeEnvironment(scenario, home, replay, runId), cols: 80, rows: 24, name: "xterm-256color", }); - terminal.onData((data) => { - lastOutput = Date.now(); - const bytes = Buffer.byteLength(data); - if ( - frames.length >= scenario.limits.frames || - outputBytes + bytes > scenario.limits.output_bytes - ) { - truncated = true; - return; - } - outputBytes += bytes; - frames.push({ - sequence: frames.length, - at_ms: - Math.floor( - (Date.now() - started) / scenario.normalization.time_bucket_ms, - ) * scenario.normalization.time_bucket_ms, - data: normalizeTerminalData( - data, - scenario, - temporaryRoot, - terminal?.pid ?? -1, - ), - }); + const startEvents = startScenarioEvents(scenario, () => terminal, timers); + if (!eventsRequireReadiness(scenario)) startEvents(); + framesTruncated = captureTerminalFrames({ + terminal, + scenario, + frames, + started, + temporaryRoot, + onOutput: () => (lastOutput = Date.now()), + onFirstOutput: startEvents, }); - scheduleScenarioEvents(scenario, () => terminal, timers); - const sampler = setInterval(() => { - void sampleProcesses( - terminal?.pid ?? -1, - Date.now() - started, - scenario.limits.processes - samples.length, - ) - .then((values) => samples.push(...values)) - .catch(() => undefined); - }, 50); - timers.add(sampler); + stopSampler = startProcessSampler( + terminal.pid, + started, + scenario.limits.processes, + samples, + ); const exit = await awaitTerminalExit({ terminal, scenario, @@ -466,15 +438,22 @@ const runProcessScenario = async ( signal, timers, }); + const exitReason = exit.reason; + await stopSampler(); + if (exitReason === "cancelled") + throw new ProcessCaptureError("process capture was cancelled"); await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms), ); const after = await snapshotRoots(scenario); truncated ||= - after.truncated || samples.length >= scenario.limits.processes; - return buildCaptureResult({ + after.truncated || + replay.truncated || + framesTruncated() || + samples.length >= scenario.limits.processes; + capture = buildCaptureResult({ frames, - exit, + exit: { ...exit, reason: exitReason }, samples, replay, before, @@ -485,12 +464,17 @@ const runProcessScenario = async ( }); } catch (cause: unknown) { terminal?.kill("SIGKILL"); - throw new ProcessCaptureError("process capture failed", { cause }); - } finally { - for (const timer of timers) clearTimeout(timer); - await replay?.close(); - await rm(temporaryRoot, { recursive: true, force: true }); + executionFailure = cause; } + await stopSampler(); + const cleanupFailure = await releaseProcessResources({ + timers, + replay, + terminal, + runId, + temporaryRoot, + }); + return resolveProcessResult(capture, executionFailure, cleanupFailure); }; /** Execute one scenario through a typed expected-failure channel. */ diff --git a/src/application/ProcessNormalization.ts b/src/application/ProcessNormalization.ts new file mode 100644 index 000000000..121edd50c --- /dev/null +++ b/src/application/ProcessNormalization.ts @@ -0,0 +1,77 @@ +import type { + ProcessCapture, + ProcessSample, + ProcessScenario, +} from "../domain/processCapture.js"; + +/** Normalize and redact one terminal/protocol payload under scenario rules. */ +export const normalizeProcessText = ( + value: string, + scenario: ProcessScenario, + temporaryRoot: string, + pid: number, +): string => { + let normalized = value; + for (const alias of scenario.secret_aliases) { + const secret = scenario.environment[alias]; + if (secret !== undefined && secret.length > 0) + normalized = normalized.replaceAll(secret, ""); + } + if (scenario.normalization.paths) { + normalized = normalized.replaceAll(temporaryRoot, ""); + normalized = normalized.replaceAll( + scenario.working_directory, + "", + ); + normalized = normalized.replaceAll(scenario.executable, ""); + for (const [index, root] of scenario.filesystem_roots.entries()) + normalized = normalized.replaceAll( + root, + ``, + ); + } + if (scenario.normalization.pids) + normalized = normalized.replaceAll(String(pid), ""); + if (scenario.normalization.ports) + normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, ""); + for (const pattern of scenario.normalization.patterns) + normalized = normalized.replaceAll(pattern.pattern, pattern.replacement); + return normalized; +}; + +export const normalizeProcessSamples = ( + samples: readonly ProcessSample[], + scenario: ProcessScenario, + rootPid: number, +): readonly ProcessSample[] => { + const identifiers = [ + rootPid, + ...samples.flatMap((sample) => [sample.pid, sample.parent_pid]), + ]; + const mapping = new Map(); + for (const identifier of identifiers) + if (identifier > 0 && !mapping.has(identifier)) + mapping.set(identifier, mapping.size + 1); + return samples.map((sample) => ({ + at_ms: + Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) * + scenario.normalization.time_bucket_ms, + pid: mapping.get(sample.pid) ?? 1, + parent_pid: mapping.get(sample.parent_pid) ?? 0, + command: normalizeProcessText( + sample.command, + scenario, + "", + rootPid, + ), + })); +}; + +export const redactProtocolEvents = ( + events: readonly ProcessCapture["protocol_events"][number][], + scenario: ProcessScenario, +): readonly ProcessCapture["protocol_events"][number][] => + events.map((event) => ({ + ...event, + data: normalizeProcessText(event.data, scenario, "", -1), + })); diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts new file mode 100644 index 000000000..dbd96fea0 --- /dev/null +++ b/src/application/ProcessOwnership.ts @@ -0,0 +1,115 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface OwnedProcessGroup { + readonly runId: string; + readonly leaderPid: number; + readonly processGroupId: number; +} + +interface ProcessGroupMember { + readonly pid: number; + readonly parentPid: number; + readonly processGroupId: number; + readonly command: string; +} + +export interface ProcessOwnershipHost { + listMembers(processGroupId: number): Promise; + environment(pid: number): Promise>>; + signalGroup(processGroupId: number, signal: NodeJS.Signals): void; +} + +export type ProcessCleanupResult = + | { readonly cleaned: true; readonly signaled: boolean } + | { readonly cleaned: false; readonly reason: string }; + +const parseEnvironment = (value: string): Readonly> => + Object.fromEntries( + value + .split("\0") + .filter((entry) => entry.includes("=")) + .map((entry) => { + const separator = entry.indexOf("="); + return [entry.slice(0, separator), entry.slice(separator + 1)]; + }), + ); + +const systemHost: ProcessOwnershipHost = { + async listMembers(processGroupId) { + if (process.platform === "win32") return []; + const { stdout } = await execFileAsync("ps", [ + "-axo", + "pid=,ppid=,pgid=,command=", + ]); + return stdout + .split("\n") + .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ + pid: Number(match[1]), + parentPid: Number(match[2]), + processGroupId: Number(match[3]), + command: match[4] ?? "", + })) + .filter((member) => member.processGroupId === processGroupId); + }, + async environment(pid) { + if (process.platform === "linux") { + return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8")); + } + const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]); + const environment: Record = {}; + for (const match of stdout.matchAll( + /(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu, + )) + environment[match[1]!] = match[2] ?? ""; + return environment; + }, + signalGroup(processGroupId, signal) { + process.kill(-processGroupId, signal); + }, +}; + +/** Verify run-token ownership before signaling a POSIX process group. */ +export const cleanupOwnedProcessGroup = async ( + ownership: OwnedProcessGroup, + host: ProcessOwnershipHost = systemHost, +): Promise => { + let members: readonly ProcessGroupMember[]; + try { + members = await host.listMembers(ownership.processGroupId); + } catch { + return { cleaned: false, reason: "process group could not be inspected" }; + } + if (members.length === 0) return { cleaned: true, signaled: false }; + for (const member of members) { + let environment: Readonly>; + try { + environment = await host.environment(member.pid); + } catch { + return { + cleaned: false, + reason: "process ownership could not be revalidated", + }; + } + if (environment.REA_PROCESS_RUN_ID !== ownership.runId) { + return { + cleaned: false, + reason: "process group contains an unowned or PID-reused process", + }; + } + } + try { + host.signalGroup(ownership.processGroupId, "SIGKILL"); + } catch (cause: unknown) { + const code = + cause instanceof Error && "code" in cause ? cause.code : undefined; + if (code !== "ESRCH") + return { cleaned: false, reason: "owned process group signal failed" }; + } + return { cleaned: true, signaled: true }; +}; diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts new file mode 100644 index 000000000..df307f4e8 --- /dev/null +++ b/src/application/ProcessSampling.ts @@ -0,0 +1,350 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import type { ProcessSample } from "../domain/processCapture.js"; + +const execFileAsync = promisify(execFile); +const PROC_READ_CONCURRENCY = 64; + +interface ProcessRow { + readonly pid: number; + readonly parent_pid: number; + readonly command: string; + readonly startTime: string | undefined; +} + +interface ProcessNode extends ProcessRow { + readonly children: readonly number[]; +} + +interface SampleProcessContext { + readonly rootPid: number; + readonly elapsedMs: number; + readonly limit: number; + readonly sampledPids: Set; + readonly identities: Map; + readonly signal: AbortSignal; +} + +const parseProcStat = ( + identifier: string, + stat: string, +): ProcessRow | undefined => { + const commandEnd = stat.lastIndexOf(")"); + if (commandEnd < 0) return undefined; + const fields = stat + .slice(commandEnd + 2) + .trim() + .split(/\s+/); + const pid = Number(identifier); + const parentPid = Number(fields[1]); + const startTime = fields[19]; + if ( + !Number.isSafeInteger(pid) || + pid <= 0 || + !Number.isSafeInteger(parentPid) || + parentPid < 0 || + startTime === undefined || + !/^\d+$/.test(startTime) + ) + return undefined; + return { + pid, + parent_pid: parentPid, + command: "", + startTime, + }; +}; + +const readProcessStat = async ( + pid: number, + signal: AbortSignal, +): Promise => { + try { + const stat = await readFile(`/proc/${String(pid)}/stat`, { + encoding: "utf8", + signal, + }); + return parseProcStat(String(pid), stat); + } catch { + return undefined; + } +}; + +const readProcessCommand = async ( + pid: number, + signal: AbortSignal, +): Promise => { + try { + return await readFile(`/proc/${String(pid)}/cmdline`, { signal }); + } catch { + return undefined; + } +}; + +const readLinuxChildren = async ( + pid: number, + signal: AbortSignal, +): Promise => { + let text: string; + try { + text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, { + encoding: "utf8", + signal, + }); + } catch { + return undefined; + } + return text + .trim() + .split(/\s+/) + .filter((value) => /^\d+$/.test(value)) + .map(Number) + .filter((child) => Number.isSafeInteger(child) && child > 0); +}; + +const inspectProcess = async ( + pid: number, + expectedParent: number | undefined, + signal: AbortSignal, + identities: Map, +): Promise => { + const before = await readProcessStat(pid, signal); + if (before === undefined || before.startTime === undefined) return undefined; + + const existing = identities.get(pid); + if (existing !== undefined && existing !== before.startTime) return undefined; + if (expectedParent !== undefined && before.parent_pid !== expectedParent) + return undefined; + + const [commandBuffer, children] = await Promise.all([ + readProcessCommand(pid, signal), + readLinuxChildren(pid, signal), + ]); + + const after = await readProcessStat(pid, signal); + if (after === undefined || after.startTime === undefined) return undefined; + if ( + before.startTime !== after.startTime || + before.parent_pid !== after.parent_pid + ) + return undefined; + if (expectedParent !== undefined && after.parent_pid !== expectedParent) + return undefined; + + const command = + commandBuffer === undefined + ? "" + : commandBuffer.toString().replaceAll("\0", " ").trim(); + + return { + pid: before.pid, + parent_pid: before.parent_pid, + startTime: before.startTime, + command, + children: children ?? [], + }; +}; + +const sampleLinux = async ( + context: SampleProcessContext, +): Promise => { + const { rootPid, limit, signal, sampledPids, identities } = context; + if (limit <= 0) return []; + + const rootStat = await readProcessStat(rootPid, signal); + if (rootStat === undefined) return []; + const rootChildren = await readLinuxChildren(rootPid, signal); + if (rootChildren === undefined) return samplePs(context); + + const rows: ProcessRow[] = []; + const visited = new Set([rootPid]); + let level: Array<{ + readonly pid: number; + readonly expectedParent: number | undefined; + }> = [{ pid: rootPid, expectedParent: undefined }]; + + while (level.length > 0 && rows.length < limit) { + const batchSize = Math.min(level.length, PROC_READ_CONCURRENCY); + const batch = level.slice(0, batchSize); + level = level.slice(batchSize); + + const batchResults = await Promise.all( + batch.map((item) => + inspectProcess(item.pid, item.expectedParent, signal, identities), + ), + ); + + const nextLevel: typeof batch = []; + for (const node of batchResults) { + if (node === undefined) continue; + + if (!sampledPids.has(node.pid)) { + if (rows.length >= limit) break; + rows.push(node); + } + if (rows.length >= limit) break; + + for (const child of node.children) { + if (visited.has(child)) continue; + visited.add(child); + nextLevel.push({ pid: child, expectedParent: node.pid }); + } + if (rows.length >= limit) break; + } + + level.push(...nextLevel); + } + + return rows; +}; + +const readPsRows = async ( + signal: AbortSignal, +): Promise => { + const { stdout } = await execFileAsync( + "ps", + ["-axo", "pid=,ppid=,command="], + { signal }, + ); + return stdout + .split("\n") + .map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/u.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ + pid: Number(match[1]), + parent_pid: Number(match[2]), + command: match[3] ?? "", + startTime: undefined, + })) + .filter( + (row) => + Number.isSafeInteger(row.pid) && + row.pid > 0 && + Number.isSafeInteger(row.parent_pid) && + row.parent_pid >= 0, + ) + .sort((left, right) => left.pid - right.pid); +}; + +const samplePs = async ( + context: SampleProcessContext, +): Promise => { + const { rootPid, limit, signal, sampledPids } = context; + if (limit <= 0) return []; + + const rows = await readPsRows(signal); + const rowByPid = new Map(rows.map((row) => [row.pid, row])); + const childrenByParent = new Map(); + for (const row of rows) { + const siblings = childrenByParent.get(row.parent_pid) ?? []; + siblings.push(row); + childrenByParent.set(row.parent_pid, siblings); + } + + const result: ProcessRow[] = []; + const visited = new Set(); + const queue: number[] = [rootPid]; + let index = 0; + + while (index < queue.length && result.length < limit) { + const pid = queue[index]; + if (pid === undefined) break; + index += 1; + if (visited.has(pid)) continue; + visited.add(pid); + + const row = rowByPid.get(pid); + if (row !== undefined && !sampledPids.has(row.pid)) { + result.push(row); + } + if (result.length >= limit) break; + + for (const child of childrenByParent.get(pid) ?? []) { + if (!visited.has(child.pid)) queue.push(child.pid); + } + } + + return result; +}; + +const sampleProcesses = async ( + context: SampleProcessContext, +): Promise => { + if (process.platform === "win32" || context.limit <= 0) return []; + + const rows = + process.platform === "linux" + ? await sampleLinux(context) + : await samplePs(context); + + const { elapsedMs, sampledPids, identities } = context; + const samples: ProcessSample[] = []; + for (const row of rows) { + if (sampledPids.has(row.pid)) continue; + if (row.startTime !== undefined) { + const existing = identities.get(row.pid); + if (existing !== undefined && existing !== row.startTime) continue; + identities.set(row.pid, row.startTime); + } + samples.push({ + at_ms: elapsedMs, + pid: row.pid, + parent_pid: row.parent_pid, + command: row.command, + }); + } + return samples; +}; + +/** Start bounded process-tree sampling and expose an awaited stop. */ +export const startProcessSampler = ( + rootPid: number, + started: number, + limit: number, + samples: ProcessSample[], +): (() => Promise) => { + const identities = new Map(); + const sampledPids = new Set(samples.map(({ pid }) => pid)); + let pending: Promise | undefined; + let stopped = false; + let abortCurrent: (() => void) | undefined; + + const sample = (): void => { + if (stopped || pending !== undefined || sampledPids.size >= limit) return; + + const controller = new AbortController(); + abortCurrent = () => controller.abort(); + pending = sampleProcesses({ + rootPid, + elapsedMs: Date.now() - started, + limit: limit - sampledPids.size, + sampledPids, + identities, + signal: controller.signal, + }) + .then((values) => { + for (const value of values) { + if (sampledPids.size >= limit) break; + if (sampledPids.has(value.pid)) continue; + sampledPids.add(value.pid); + samples.push(value); + } + }) + .catch(() => undefined) + .then(() => { + abortCurrent = undefined; + pending = undefined; + }); + }; + + const timer = setInterval(sample, 50); + sample(); + + return async () => { + stopped = true; + clearInterval(timer); + abortCurrent?.(); + await pending; + }; +}; diff --git a/src/application/RealHopperAssertions.ts b/src/application/RealHopperAssertions.ts new file mode 100644 index 000000000..1e815f865 --- /dev/null +++ b/src/application/RealHopperAssertions.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; + +const addressSchema = z.string().regex(/^0x[0-9a-f]+$/iu); +const identitySchema = z.object({ address: addressSchema, name: z.string() }); +const collectionSchema = z.object({ + items: z.array(z.unknown()), + total: z.number().int().min(0).nullable(), + returned: z.number().int().min(0), + truncated: z.boolean(), + next_offset: z.number().int().min(0).nullable(), +}); + +/** Extract a real address from the provider's exact procedure page. */ +export const firstProcedureAddress = (input: unknown): string => { + const page = z + .object({ + items: z.array(z.object({ address: addressSchema })).min(1), + offset: z.number().int().min(0), + limit: z.number().int().min(1), + total: z.number().int().min(1), + next_offset: z.number().int().min(0).nullable(), + has_more: z.boolean(), + }) + .parse(input); + const first = page.items[0]; + if (first === undefined) throw new TypeError("Procedure page was empty"); + return first.address; +}; + +/** Reject empty and success-shaped embedded decompilation failures. */ +export const requirePseudocode = ( + input: unknown, + operation: string, +): string => { + if (typeof input !== "string" || input.trim().length === 0) + throw new TypeError(`${operation} returned empty pseudocode`); + if (input === "No output" || input.startsWith("Error:")) + throw new TypeError(`${operation} returned an embedded failure: ${input}`); + return input; +}; + +/** Validate an exact bounded Hopper function dossier semantically. */ +export const requireFunctionDossier = ( + input: unknown, + expectedAddress: string, +): Record => { + const value = objectValue( + input, + "analyze_function returned no function dossier", + ); + const procedure = identitySchema.parse(value.procedure); + if (procedure.address !== expectedAddress) + throw new TypeError("analyze_function returned the wrong procedure"); + if (procedure.name.trim().length === 0) + throw new TypeError("analyze_function omitted the procedure name"); + validatePseudocodeBounds(value.pseudocode); + for (const field of COLLECTION_FIELDS) + validateCollection(value[field], field); + for (const field of ["callers", "callees"] as const) + z.array(identitySchema).parse(collectionSchema.parse(value[field]).items); + validateBlocks(collectionSchema.parse(value.basic_blocks).items); + validateInstructionScan(value.instruction_scan); + return value; +}; + +/** Require every relationship to be an actual hexadecimal address. */ +export const requireAddressArray = ( + input: unknown, + operation: string, +): string[] => { + const parsed = z.array(addressSchema).parse(input); + if (parsed.some((address) => address.length === 0)) + throw new TypeError(`${operation} returned an invalid address list`); + return parsed; +}; + +const COLLECTION_FIELDS = [ + "comments", + "callers", + "callees", + "incoming_references", + "outgoing_references", + "referenced_strings", + "referenced_names", + "basic_blocks", +] as const; + +const validatePseudocodeBounds = (input: unknown): void => { + const pseudocode = z + .object({ + text: z.string(), + returned_chars: z.number().int().min(0), + total_chars: z.number().int().min(0), + }) + .parse(input); + const text = requirePseudocode(pseudocode.text, "analyze_function"); + if ( + pseudocode.returned_chars !== [...text].length || + pseudocode.total_chars < pseudocode.returned_chars + ) + throw new TypeError( + "analyze_function returned inconsistent pseudocode bounds", + ); +}; + +const validateCollection = (input: unknown, field: string): void => { + const collection = collectionSchema.parse(input); + if (collection.returned !== collection.items.length) + throw new TypeError(`analyze_function returned an invalid ${field} result`); +}; + +const validateBlocks = (items: readonly unknown[]): void => { + for (const block of items) + if (!Array.isArray(objectValue(block, "Invalid basic block").successors)) + throw new TypeError("analyze_function omitted CFG successor evidence"); +}; + +const validateInstructionScan = (input: unknown): void => { + const scan = z + .object({ scanned: z.number().int().min(0), truncated: z.boolean() }) + .parse(input); + if (scan.scanned === 0 && !scan.truncated) + throw new TypeError( + "analyze_function omitted instruction scan limitations", + ); +}; + +const objectValue = ( + input: unknown, + message: string, +): Record => { + const parsed = z.record(z.string(), z.unknown()).safeParse(input); + if (!parsed.success) throw new TypeError(message); + return parsed.data; +}; diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts new file mode 100644 index 000000000..023c09d11 --- /dev/null +++ b/src/application/ReferenceSourceImport.ts @@ -0,0 +1,260 @@ +import { isSecretLikePath } from "../domain/referenceSourceClassification.js"; +import { + createHistoricalSourceGraph, + type HistoricalSourceGraph, + type HistoricalSourceGraphInput, +} from "../domain/referenceSourceGraph.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { readReferenceSource } from "../reference/ReferenceSourceReader.js"; +import { parseReferenceSourceEntries } from "./ReferenceSourceImportEntries.js"; +import { readReferenceSourceVcs } from "./ReferenceSourceVcsAdapter.js"; +import { + type ReferenceSourceImportError, + type ReferenceSourceImportOptions, +} from "./ReferenceSourceImportTypes.js"; +import { prepareReferenceSourceImport } from "./ReferenceSourceImportPolicy.js"; + +export type { + ReferenceSourceImportError, + ReferenceSourceImportOptions, +} from "./ReferenceSourceImportTypes.js"; + +const compareCodePoints = (left: string, right: string): number => { + const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0); + const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftPoints[index] ?? 0) - (rightPoints[index] ?? 0); + if (difference !== 0) return difference; + } + return leftPoints.length - rightPoints.length; +}; + +const byCodePoint = (left: string, right: string): number => + compareCodePoints(left, right); + +const failure = ( + code: ReferenceSourceImportError["code"], + message: string, +): ReferenceSourceImportError => ({ + tag: "reference-source-import", + code, + message, +}); + +const cancelled = (): ReferenceSourceImportError => + failure("cancelled", "Reference source import cancelled"); + +const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; + +const relationshipKey = ( + relationship: HistoricalSourceGraphInput["relationships"][number], +): string => + `${relationship.from_path}\u0000${relationship.to}\u0000${relationship.kind}\u0000${relationship.resolution}\u0000${relationship.parse_state}`; + +const deduplicateRelationships = ( + relationships: HistoricalSourceGraphInput["relationships"], +): HistoricalSourceGraphInput["relationships"] => { + const seen = new Set(); + const result: HistoricalSourceGraphInput["relationships"] = []; + for (const relationship of relationships) { + const key = relationshipKey(relationship); + if (seen.has(key)) continue; + seen.add(key); + result.push(relationship); + } + return result; +}; + +const buildProvenance = ( + options: ReferenceSourceImportOptions, +): HistoricalSourceGraphInput["provenance"] => ({ + importer: options.importer ?? "rea-reference-source-import", + importer_version: options.importerVersion ?? null, + caller: options.caller, +}); + +const deriveLanguages = ( + entries: HistoricalSourceGraphInput["entries"], +): string[] => + [ + ...new Set( + entries.flatMap((entry) => + entry.kind === "file" && entry.language !== null + ? [entry.language] + : [], + ), + ), + ].sort(byCodePoint); + +const deriveManifests = ( + entries: HistoricalSourceGraphInput["entries"], +): string[] => + entries + .flatMap((entry) => + entry.kind === "file" && entry.classifications.includes("manifest") + ? [entry.path] + : [], + ) + .sort(byCodePoint); + +const deriveInventoryState = ( + input: Pick< + HistoricalSourceGraphInput, + | "entries" + | "relationships" + | "parse_failures" + | "exclusions" + | "limitations" + >, +): "complete" | "partial" | "unknown" => { + if (input.limitations.length > 0) return "partial"; + if (input.exclusions.length > 0) return "partial"; + if (input.parse_failures.length > 0) return "partial"; + const partialEntry = input.entries.some( + (entry) => + entry.limitations.length > 0 || + (entry.kind === "file" && entry.content_state !== "hashed") || + (entry.kind === "directory" && entry.tree_state !== "enumerated") || + (entry.kind === "symlink" && entry.target_state !== "internal"), + ); + if (partialEntry) return "partial"; + const partialRelationship = input.relationships.some( + ({ parse_state, resolution }) => + parse_state !== "parsed" || + ["unresolved", "unknown"].includes(resolution), + ); + if (partialRelationship) return "partial"; + return "complete"; +}; + +const sortAndLimit = ( + exclusions: HistoricalSourceGraphInput["exclusions"], + maxExclusions: number, +): HistoricalSourceGraphInput["exclusions"] => { + const sorted = [...exclusions].sort((left, right) => { + const byPath = byCodePoint(left.path, right.path); + if (byPath !== 0) return byPath; + return byCodePoint(left.reason, right.reason); + }); + if (sorted.length <= maxExclusions) return sorted; + return sorted.slice(0, maxExclusions); +}; + +/** + * Import a reference source directory into a committed historical source graph. + * + * The import is deterministic, parallel-safe, and never executes source, hooks, + * git subprocesses, or network requests. Secret-like paths are redacted before + * they are committed to the graph. + */ +export const importReferenceSource = async ( + options: ReferenceSourceImportOptions, +): Promise> => { + if (isAborted(options.signal)) return err(cancelled()); + const prepared = await prepareReferenceSourceImport(options); + if (!prepared.ok) return prepared; + const { ignored, limits, root, secrets } = prepared.value; + if (isAborted(options.signal)) return err(cancelled()); + + const exclusions: HistoricalSourceGraphInput["exclusions"] = []; + const shouldExclude = (path: string): boolean => { + if (isSecretLikePath(path) || secrets.ignores(path)) { + exclusions.push({ path, reason: "configured-secret" }); + return true; + } + if (ignored.ignores(path)) { + exclusions.push({ path, reason: "caller-excluded" }); + return true; + } + return false; + }; + + const [readResult, vcs] = await Promise.all([ + readReferenceSource(root, limits, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + shouldExclude, + }), + readReferenceSourceVcs(root), + ]); + + if (!readResult.ok) { + const error = readResult.error; + if (error.code === "cancelled") return err(cancelled()); + return err( + failure( + error.code === "invalid-limits" ? "invalid-limits" : "io", + error.message, + ), + ); + } + + if (isAborted(options.signal)) return err(cancelled()); + + const read = readResult.value; + const filePaths = new Set( + read.entries + .filter((entry) => entry.status === "read" && entry.kind === "file") + .map((entry) => entry.path), + ); + + const { entries, relationships, parseFailures, limitations } = + parseReferenceSourceEntries(read, filePaths, options.signal); + + if (isAborted(options.signal)) return err(cancelled()); + + const uniqueRelationships = deduplicateRelationships(relationships); + const uniqueFailures = [...parseFailures] + .sort((left, right) => { + const byPath = byCodePoint(left.path, right.path); + if (byPath !== 0) return byPath; + return byCodePoint(left.parser, right.parser); + }) + .filter((value, index, array) => { + if (index === 0) return true; + const previous = array[index - 1]; + if (previous === undefined) return true; + return value.path !== previous.path || value.parser !== previous.parser; + }); + + const sortedEntries = [...entries].sort((left, right) => + byCodePoint(left.path, right.path), + ); + const sortedExclusions = sortAndLimit(exclusions, limits.maxEntries); + const sortedLimitations = [...limitations].sort(byCodePoint); + + const input: HistoricalSourceGraphInput = { + schema: "HistoricalSourceGraph/v1", + authority: "historical-reference", + root_alias: "$REFERENCE_ROOT", + inventory_state: deriveInventoryState({ + entries: sortedEntries, + relationships: uniqueRelationships, + parse_failures: uniqueFailures, + exclusions: sortedExclusions, + limitations: sortedLimitations, + }), + entries: sortedEntries, + relationships: uniqueRelationships.sort((left, right) => + byCodePoint(relationshipKey(left), relationshipKey(right)), + ), + parse_failures: uniqueFailures, + exclusions: sortedExclusions, + languages: deriveLanguages(sortedEntries), + manifests: deriveManifests(sortedEntries), + vcs, + provenance: buildProvenance(options), + limitations: sortedLimitations, + }; + + try { + return ok(createHistoricalSourceGraph(input)); + } catch (error) { + return err( + failure( + "parse", + error instanceof Error ? error.message : "Graph failed validation", + ), + ); + } +}; diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts new file mode 100644 index 000000000..2cf527cee --- /dev/null +++ b/src/application/ReferenceSourceImportEntries.ts @@ -0,0 +1,166 @@ +import { createHash } from "node:crypto"; +import { posix } from "node:path"; + +import { + classifyReferenceSourcePath, + detectReferenceSourceLanguage, +} from "../domain/referenceSourceClassification.js"; +import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js"; +import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js"; +import type { + ReferenceSourceEntry, + ReferenceSourceRead, +} from "../reference/ReferenceSourceReader.js"; +import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js"; + +export interface ParsedReferenceSourceEntries { + readonly entries: HistoricalSourceGraphInput["entries"]; + readonly relationships: HistoricalSourceGraphInput["relationships"]; + readonly parseFailures: HistoricalSourceGraphInput["parse_failures"]; + readonly limitations: string[]; +} + +const hashBytes = (bytes: Uint8Array): string => + createHash("sha256").update(bytes).digest("hex"); + +const failedEntry = ( + entry: Extract, +): HistoricalSourceGraphInput["entries"][number] => { + const classifications = classifyReferenceSourcePath(entry.path); + if (entry.kind === "directory") + return { + path: entry.path, + kind: "directory", + classifications, + tree_state: entry.code === "limit" ? "partial" : "unreadable", + limitations: [entry.message], + }; + if (entry.kind === "symlink") + return { + path: entry.path, + kind: "symlink", + target: "", + target_state: "unreadable", + classifications, + limitations: [entry.message], + }; + return { + path: entry.path, + kind: "file", + sha256: null, + size: entry.size ?? null, + language: null, + classifications: entry.kind === "file" ? classifications : ["unknown"], + content_state: entry.code === "limit" ? "too-large" : "unreadable", + limitations: [entry.message], + }; +}; + +const resolveInternalSpecifier = ( + fromPath: string, + specifier: string, + filePaths: ReadonlySet, +): { to: string; resolution: "internal" | "unresolved" } => { + const normalized = posix.normalize( + posix.join(posix.dirname(fromPath), specifier), + ); + if (normalized.startsWith("../") || normalized === "..") + return { to: normalized, resolution: "unresolved" }; + const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; + const candidates = suffixes.flatMap((suffix) => [ + `${normalized}${suffix}`, + `${normalized}/index${suffix}`, + ]); + const match = candidates.find((candidate) => filePaths.has(candidate)); + return match === undefined + ? { to: normalized, resolution: "unresolved" } + : { to: match, resolution: "internal" }; +}; + +const appendFile = ( + entry: Extract, + filePaths: ReadonlySet, + output: ParsedReferenceSourceEntries, +): void => { + const language = detectReferenceSourceLanguage(entry.path); + output.entries.push({ + path: entry.path, + kind: "file", + sha256: hashBytes(entry.bytes), + size: entry.size, + language, + classifications: classifyReferenceSourcePath(entry.path), + content_state: "hashed", + limitations: [], + }); + if (language === null || !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language)) + return; + const parsed = parseReferenceSourceImports(entry.path, entry.bytes, language); + for (const relationship of parsed.relationships) { + if (relationship.resolution !== "internal") { + output.relationships.push(relationship); + continue; + } + const resolved = resolveInternalSpecifier( + relationship.from_path, + relationship.to, + filePaths, + ); + output.relationships.push({ + ...relationship, + to: resolved.to, + resolution: resolved.resolution, + }); + } + output.parseFailures.push(...parsed.parse_failures); +}; + +const appendReadEntry = ( + entry: Extract, + filePaths: ReadonlySet, + output: ParsedReferenceSourceEntries, +): void => { + if (entry.kind === "file") { + appendFile(entry, filePaths, output); + return; + } + const classifications = classifyReferenceSourcePath(entry.path); + if (entry.kind === "directory") { + output.entries.push({ + path: entry.path, + kind: "directory", + classifications, + tree_state: "enumerated", + limitations: [], + }); + return; + } + output.entries.push({ + path: entry.path, + kind: "symlink", + target: entry.target, + target_state: entry.targetState, + classifications, + limitations: [], + }); +}; + +/** Convert bounded reader observations into graph entries and parsed edges. */ +export const parseReferenceSourceEntries = ( + read: ReferenceSourceRead, + filePaths: ReadonlySet, + signal?: AbortSignal, +): ParsedReferenceSourceEntries => { + const output: ParsedReferenceSourceEntries = { + entries: [], + relationships: [], + parseFailures: [], + limitations: [...read.limitations], + }; + for (const entry of read.entries) { + if (signal?.aborted === true) break; + if (entry.status === "failed") output.entries.push(failedEntry(entry)); + else appendReadEntry(entry, filePaths, output); + } + return output; +}; diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts new file mode 100644 index 000000000..d10a5282d --- /dev/null +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -0,0 +1,122 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import ignore from "ignore"; + +import { err, ok, type Result } from "../domain/result.js"; +import type { ReferenceSourceLimits } from "../reference/ReferenceSourceReader.js"; +import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js"; +import { + DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, + DEFAULT_REFERENCE_SOURCE_LIMITS, + type ReferenceSourceImportError, + type ReferenceSourceImportOptions, +} from "./ReferenceSourceImportTypes.js"; + +/** Validated, authorized inputs ready for filesystem traversal. */ +export interface PreparedReferenceSourceImport { + readonly root: string; + readonly limits: ReferenceSourceLimits; + readonly ignored: ReturnType; + readonly secrets: ReturnType; +} + +const failure = ( + code: ReferenceSourceImportError["code"], + message: string, +): ReferenceSourceImportError => ({ + tag: "reference-source-import", + code, + message, +}); + +const validLimits = (limits: ReferenceSourceLimits): boolean => + Object.values(limits).every( + (value) => Number.isSafeInteger(value) && value > 0, + ); + +const maximumLimits = ( + options: ReferenceSourceImportOptions, +): ReferenceSourceLimits => ({ + maxBytes: options.policy.maxBytes, + maxEntries: options.policy.maxEntries, + maxDepth: options.policy.maxDepth, + maxPathBytes: options.policy.maxPathBytes, +}); + +const clampLimits = ( + requested: ReferenceSourceLimits, + maximum: ReferenceSourceLimits, +): ReferenceSourceLimits => ({ + maxBytes: Math.min(requested.maxBytes, maximum.maxBytes), + maxEntries: Math.min(requested.maxEntries, maximum.maxEntries), + maxDepth: Math.min(requested.maxDepth, maximum.maxDepth), + maxPathBytes: Math.min(requested.maxPathBytes, maximum.maxPathBytes), +}); + +const authorizeRoot = async ( + requestedRoot: string, + approvedRoots: readonly string[], +): Promise> => { + if (approvedRoots.length === 0) + return err(failure("policy", "No approved reference source roots")); + try { + if (!(await stat(requestedRoot)).isDirectory()) + return err( + failure("invalid-root", "Reference source root is not a directory"), + ); + const canonicalRoot = await realpath(resolve(requestedRoot)); + for (const approvedRoot of approvedRoots) { + const canonicalApproved = await realpath(resolve(approvedRoot)); + if (withinRoot(canonicalApproved, canonicalRoot)) + return ok(canonicalRoot); + } + return err( + failure("policy", "Reference source root is outside approved roots"), + ); + } catch { + return err( + failure("invalid-root", "Reference source root could not be resolved"), + ); + } +}; + +const buildIgnored = async ( + root: string, + excludePaths: readonly string[], +): Promise> => { + const ignored = ignore(); + try { + ignored.add(await readFile(join(root, ".gitignore"), "utf8")); + } catch { + // Missing or unreadable ignore file does not authorize broader access. + } + ignored.add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]); + for (const path of excludePaths) { + ignored.add(path); + ignored.add(`${path}/`); + } + return ignored; +}; + +/** Validate limits, authorize root, and build non-executing path filters. */ +export const prepareReferenceSourceImport = async ( + options: ReferenceSourceImportOptions, +): Promise< + Result +> => { + const requested = options.limits ?? DEFAULT_REFERENCE_SOURCE_LIMITS; + const maximum = maximumLimits(options); + if (!validLimits(requested) || !validLimits(maximum)) + return err( + failure("invalid-limits", "Import limits must be positive integers"), + ); + const root = await authorizeRoot(options.root, options.policy.roots); + if (!root.ok) return root; + return ok({ + root: root.value, + limits: clampLimits(requested, maximum), + ignored: await buildIgnored(root.value, options.excludePaths ?? []), + secrets: ignore().add([...options.policy.secretPatterns]), + }); +}; diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts new file mode 100644 index 000000000..3ea671c7a --- /dev/null +++ b/src/application/ReferenceSourceImportTypes.ts @@ -0,0 +1,50 @@ +import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; +import type { ReferenceSourceLimits } from "../reference/ReferenceSourceReader.js"; + +/** Typed expected failure returned by historical-source imports. */ +export interface ReferenceSourceImportError { + readonly tag: "reference-source-import"; + readonly code: + | "cancelled" + | "invalid-limits" + | "invalid-root" + | "io" + | "parse" + | "policy" + | "limit"; + readonly message: string; +} + +/** Bounded import request paired with mandatory operator policy. */ +export interface ReferenceSourceImportOptions { + readonly root: string; + readonly limits?: ReferenceSourceLimits; + readonly signal?: AbortSignal; + readonly caller: string; + readonly policy: ReferenceSourcePolicy; + readonly importer?: string; + readonly importerVersion?: string | null; + readonly excludePaths?: readonly string[]; +} + +export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [ + ".git/", + ".git/hooks/", + "node_modules/", + "dist/", + "build/", + "coverage/", + "htmlcov/", + ".coverage", + "*.log", +] as const; + +export const DEFAULT_REFERENCE_SOURCE_LIMITS: ReferenceSourceLimits = { + maxBytes: 16 * 1024 * 1024, + maxEntries: 10_000, + maxDepth: 32, + maxPathBytes: 4_096, +}; + +export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet = + new Set(["JavaScript", "TypeScript", "JSX", "TSX"]); diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts new file mode 100644 index 000000000..e30e475c0 --- /dev/null +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; + +import { log, statusMatrix } from "isomorphic-git"; + +export type ReferenceSourceVcsInfo = + | { readonly kind: "git"; readonly head: string; readonly dirty: boolean } + | { readonly kind: "none"; readonly head: null; readonly dirty: null } + | { readonly kind: "unknown"; readonly head: null; readonly dirty: null }; + +/** + * Read Git metadata for a directory using isomorphic-git. + * + * No git subprocess or network is used; only the local `.git` object store is read. + */ +export const readReferenceSourceVcs = async ( + root: string, +): Promise => { + try { + const commits = await log({ + fs, + dir: root, + ref: "HEAD", + depth: 1, + cache: {}, + }); + if (commits.length === 0) + return { kind: "unknown", head: null, dirty: null }; + + const head = commits[0]?.oid; + if (head === undefined) return { kind: "unknown", head: null, dirty: null }; + + let dirty = false; + try { + const matrix = await statusMatrix({ + fs, + dir: root, + ref: head, + cache: {}, + }); + for (const row of matrix) { + const [, headStatus, workdirStatus, stageStatus] = row; + if (headStatus !== 1 || workdirStatus !== 1 || stageStatus !== 1) { + dirty = true; + break; + } + } + } catch { + dirty = true; + } + + return { kind: "git", head, dirty }; + } catch { + return { kind: "none", head: null, dirty: null }; + } +}; diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 36685bb3a..a17fc200f 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -12,6 +12,7 @@ import { dirname, join } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { z } from "zod"; +import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; import { runDoctor, systemDoctorHost } from "./Doctor.js"; @@ -267,7 +268,8 @@ export const configureJsonClient = async ( }; }; -const installCanonicalSkill = async ( +/** Transactionally install or upgrade the versioned canonical REA skill. */ +export const installCanonicalSkill = async ( home: string, ): Promise<"installed" | "unchanged" | "failed"> => { const destination = join( @@ -276,6 +278,8 @@ const installCanonicalSkill = async ( PRODUCT_IDENTITY.skillName, "SKILL.md", ); + const backup = `${destination}.rea.backup`; + let original: string | undefined; try { const content = await readFile( new URL( @@ -284,16 +288,32 @@ const installCanonicalSkill = async ( ), "utf8", ); - if ( - (await readFile(destination, "utf8").catch(() => undefined)) === content - ) - return "unchanged"; + original = await readFile(destination, "utf8").catch(() => undefined); + if (original === content) return "unchanged"; await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, content, { encoding: "utf8", mode: 0o600 }); - return (await readFile(destination, "utf8")) === content - ? "installed" - : "failed"; + if (original !== undefined) + await writeFileAtomic(backup, original, { + encoding: "utf8", + mode: 0o600, + }); + await writeFileAtomic(destination, content, { + encoding: "utf8", + mode: 0o600, + }); + if ((await readFile(destination, "utf8")) !== content) + throw new Error("skill readback mismatch"); + return "installed"; } catch { + try { + if (original === undefined) await rm(destination, { force: true }); + else + await writeFileAtomic(destination, original, { + encoding: "utf8", + mode: 0o600, + }); + } catch { + // The backup remains beside the skill for explicit operator recovery. + } return "failed"; } }; diff --git a/src/application/runtime.ts b/src/application/runtime.ts index c49580b17..dcef83779 100644 --- a/src/application/runtime.ts +++ b/src/application/runtime.ts @@ -1,6 +1,9 @@ import type { AppConfig } from "../config.js"; import { BinarySession } from "./BinarySession.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; +import { NativeMacOSProvider } from "../native/NativeMacOSProvider.js"; +import { ArtifactProvider } from "../artifacts/ArtifactProvider.js"; +import { CompositeProvider } from "./CompositeProvider.js"; import { silentLogger, type Logger } from "../logger.js"; /** @@ -12,5 +15,11 @@ export const createBinarySession = ( config: AppConfig, logger: Logger = silentLogger, ): BinarySession => { - return new BinarySession(new HopperProvider(config, logger)); + return new BinarySession( + new CompositeProvider([ + new ArtifactProvider(), + new NativeMacOSProvider(), + new HopperProvider(config, logger), + ]), + ); }; diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts new file mode 100644 index 000000000..a45f36493 --- /dev/null +++ b/src/artifacts/ArtifactPaths.ts @@ -0,0 +1,86 @@ +import { Buffer } from "node:buffer"; +import { posix } from "node:path"; + +import { + ArtifactReaderFailure, + type ArtifactLimits, +} from "./ArtifactReader.js"; + +const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u; + +/** Normalize an untrusted logical archive path without touching filesystem. */ +export const normalizeArtifactPath = ( + input: string, + limits: Pick, +): string => { + if ( + input.includes("\0") || + input.includes("\\") || + input.startsWith("/") || + DRIVE_OR_UNC.test(input) + ) + throw new ArtifactReaderFailure( + "path", + "Artifact path is absolute or unsafe", + ); + const normalized = input.normalize("NFC").replace(/\/+$/u, ""); + const parts = normalized.split("/"); + if ( + normalized.length === 0 || + parts.some((part) => part === "" || part === "." || part === "..") || + parts.length > limits.maxDepth || + Buffer.byteLength(normalized, "utf8") > limits.maxPathBytes || + posix.normalize(normalized) !== normalized + ) + throw new ArtifactReaderFailure("path", "Artifact path is not normalized"); + return normalized; +}; + +/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */ +export class ArtifactPathRegistry { + readonly #root: PathTrieNode = { kind: undefined, children: new Map() }; + readonly #folded = new Set(); + + add(path: string, kind: "file" | "directory" | "symlink" | "slice"): void { + const folded = path.toLocaleLowerCase("en-US"); + if (this.#folded.has(folded)) + throw new ArtifactReaderFailure( + "path", + `Artifact path collision: ${path}`, + ); + const parts = path.split("/"); + let node = this.#root; + for (const [index, part] of parts.entries()) { + if (node.kind !== undefined && node.kind !== "directory") + throw new ArtifactReaderFailure( + "path", + `Artifact prefix conflict: ${path}`, + ); + let child = node.children.get(part); + if (child === undefined) { + child = { kind: undefined, children: new Map() }; + node.children.set(part, child); + } + node = child; + if (index === parts.length - 1) { + if (node.kind !== undefined) + throw new ArtifactReaderFailure( + "path", + `Artifact path collision: ${path}`, + ); + if (kind !== "directory" && node.children.size > 0) + throw new ArtifactReaderFailure( + "path", + `Artifact prefix conflict: ${path}`, + ); + } + } + node.kind = kind; + this.#folded.add(folded); + } +} + +interface PathTrieNode { + kind: "file" | "directory" | "symlink" | "slice" | undefined; + readonly children: Map; +} diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts new file mode 100644 index 000000000..bd16fe8b5 --- /dev/null +++ b/src/artifacts/ArtifactProvider.ts @@ -0,0 +1,212 @@ +import { + createAnalysisExecution, + type AnalysisClient, + type AnalysisOperation, + type AnalysisProvider, + type CapabilityDescriptor, + type ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { inventoryArtifact } from "../application/ArtifactInventory.js"; +import { extractArtifact } from "../application/ArtifactExtraction.js"; +import { + ARTIFACT_TOOL_CONTRACTS, + artifactInventoryInputSchema, + artifactExtractionInputSchema, + type ArtifactToolName, +} from "../contracts/artifactToolContracts.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import { + AnalysisCapabilityUnavailableError, + ArtifactOperationError, + type AnalysisError, +} from "../domain/errors.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, ok } from "../domain/result.js"; +import { + ArtifactReaderFailure, + type ArtifactLimits, +} from "./ArtifactReader.js"; + +const IDENTITY: ProviderIdentity = Object.freeze({ + id: "rea-artifact-graph", + name: "REA safe artifact graph provider", + version: "1", +}); + +/** Read-only inventory and transaction-owned extraction provider. */ +export class ArtifactProvider implements AnalysisProvider { + readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze( + ARTIFACT_TOOL_CONTRACTS.map((contract) => + Object.freeze({ + provider: IDENTITY, + operation: contract.name, + inputContractVersion: 1, + outputContractVersion: 1, + available: true as const, + reason: null, + pagination: "offset" as const, + exhaustive: false, + effects: Object.freeze({ + mutatesArtifact: false, + launchesProcess: true, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: contract.name === "extract_artifact", + changesPermissions: false, + requiresRoot: false, + }), + limits: Object.freeze({ + maxResults: 500, + maxPayloadBytes: 4 * 1024 * 1024, + timeoutMs: 120_000, + }), + limitations: Object.freeze([ + "DMG and PKG child inventory requires a future native read-only adapter.", + "Nested containers are recorded but not recursively expanded implicitly.", + ]), + }), + ), + ); + + identity(): ProviderIdentity { + return IDENTITY; + } + + capabilities(): readonly CapabilityDescriptor[] { + return this.#capabilities; + } + + createClient(target: BinaryTarget): AnalysisClient { + return new ArtifactClient(target); + } +} + +class ArtifactClient implements AnalysisClient { + constructor(private readonly target: BinaryTarget) {} + + async execute( + operation: AnalysisOperation, + parameters: Readonly>, + options?: { readonly signal?: AbortSignal }, + ) { + if (operation === "health") + return ok(createAnalysisExecution(null, IDENTITY)); + if (!isArtifactOperation(operation)) + return err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + operation, + "Operation is not implemented by artifact graph provider.", + ), + ); + try { + if (operation === "extract_artifact") { + const parsed = artifactExtractionInputSchema.parse(parameters); + const result = await extractArtifact( + { + inputPath: this.target.sourcePath ?? this.target.path, + inputFormat: this.target.format, + outputRoot: parsed.output_root, + occurrenceIds: parsed.occurrence_ids, + offset: parsed.offset, + limit: parsed.limit, + limits: limitsFrom(parsed), + }, + options?.signal, + ); + return ok( + createAnalysisExecution(result, IDENTITY, { + rawResult: null, + limitations: result.limitations, + subject: subjectFor( + this.target.sourcePath ?? this.target.path, + result.manifest, + ), + locations: result.artifacts.items.map( + ({ relative_path: path }) => ({ + kind: "artifact-path" as const, + path, + }), + ), + }), + ); + } + const parsed = artifactInventoryInputSchema.parse(parameters); + const result = await inventoryArtifact( + this.target.sourcePath ?? this.target.path, + limitsFrom(parsed), + { + nodeOffset: parsed.node_offset, + nodeLimit: parsed.node_limit, + occurrenceOffset: parsed.occurrence_offset, + occurrenceLimit: parsed.occurrence_limit, + edgeOffset: parsed.edge_offset, + edgeLimit: parsed.edge_limit, + }, + options?.signal, + ); + return ok( + createAnalysisExecution(result, IDENTITY, { + rawResult: null, + limitations: result.limitations, + subject: subjectFor( + this.target.sourcePath ?? this.target.path, + result.manifest, + ), + locations: result.occurrences.items.map(({ logical_path: path }) => ({ + kind: "artifact-path" as const, + path, + })), + }), + ); + } catch (cause: unknown) { + return err(translateFailure(operation, cause)); + } + } + + close(): Promise { + return Promise.resolve(); + } +} + +const limitsFrom = (input: { + readonly max_entries: number; + readonly max_total_bytes: number; + readonly max_entry_bytes: number; + readonly max_compression_ratio: number; + readonly max_depth: number; + readonly max_path_bytes: number; +}): ArtifactLimits => ({ + maxEntries: input.max_entries, + maxTotalBytes: input.max_total_bytes, + maxEntryBytes: input.max_entry_bytes, + maxCompressionRatio: input.max_compression_ratio, + maxDepth: input.max_depth, + maxPathBytes: input.max_path_bytes, +}); + +const isArtifactOperation = ( + operation: AnalysisOperation, +): operation is ArtifactToolName => + ARTIFACT_TOOL_CONTRACTS.some(({ name }) => name === operation); + +const translateFailure = ( + operation: ArtifactToolName, + cause: unknown, +): AnalysisError => { + if (cause instanceof ArtifactReaderFailure) + return new ArtifactOperationError(operation, cause.reason); + return new ArtifactOperationError(operation, "io"); +}; + +const subjectFor = ( + path: string, + manifest: { + readonly root_sha256: string; + readonly root_format: import("../domain/artifactGraph.js").ArtifactNode["format"]; + }, +) => ({ + path, + sha256: manifest.root_sha256, + format: manifest.root_format, +}); diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts new file mode 100644 index 000000000..0b1d5720b --- /dev/null +++ b/src/artifacts/ArtifactReader.ts @@ -0,0 +1,58 @@ +import type { Readable } from "node:stream"; +import type { ArtifactCommand } from "../domain/artifactGraph.js"; + +/** Archive-neutral entry metadata. Reader adapters never choose output paths. */ +export interface ArtifactEntry { + readonly path: string; + readonly kind: "file" | "directory" | "symlink" | "slice"; + readonly declaredSize: number | null; + readonly compressedSize: number | null; + readonly executable: boolean; + readonly encrypted: boolean; + readonly byteOffset: number | null; + readonly declaredSha256: string | null; + readonly limitations: readonly string[]; + readonly adapterKey: string; + /** Filesystem identity captured during traversal, when supplied by an adapter. */ + readonly sourceIdentity?: { + readonly device: number; + readonly inode: number; + }; +} + +/** Read-only adapter over one directory, archive, or virtual container. */ +export interface ArtifactReader { + readonly format: "directory" | "zip" | "ipa" | "apk" | "asar" | "file"; + entries(signal?: AbortSignal): AsyncIterable; + open(entry: ArtifactEntry, signal?: AbortSignal): Promise; + provenance(): readonly ArtifactCommand[]; + close(): Promise; +} + +/** Hard traversal limits shared by every reader and extraction transaction. */ +export interface ArtifactLimits { + readonly maxEntries: number; + readonly maxTotalBytes: number; + readonly maxEntryBytes: number; + readonly maxCompressionRatio: number; + readonly maxDepth: number; + readonly maxPathBytes: number; +} + +/** Typed adapter failure translated at provider boundary. */ +export class ArtifactReaderFailure extends Error { + constructor( + readonly reason: + | "cancelled" + | "format" + | "integrity" + | "limit" + | "path" + | "unavailable", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "ArtifactReaderFailure"; + } +} diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts new file mode 100644 index 000000000..41865e507 --- /dev/null +++ b/src/artifacts/AsarArtifactReader.ts @@ -0,0 +1,80 @@ +import { Readable } from "node:stream"; + +import { extractFile, listPackage, statFile } from "@electron/asar"; + +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactReader, +} from "./ArtifactReader.js"; + +/** Official Electron ASAR adapter. Individual files remain caller-bounded. */ +export class AsarArtifactReader implements ArtifactReader { + readonly format = "asar" as const; + + constructor(private readonly path: string) {} + + async *entries(signal?: AbortSignal): AsyncIterable { + const paths = listPackage(this.path, { isPack: false }).sort( + (left, right) => left.localeCompare(right, "en"), + ); + for (const listed of paths) { + abortIfNeeded(signal); + const path = listed.replace(/^\/+|\/+$/gu, ""); + if (path.length === 0) continue; + const metadata = statFile(this.path, path, false); + const kind = + "files" in metadata + ? "directory" + : "link" in metadata + ? "symlink" + : "file"; + yield { + path, + kind, + declaredSize: "size" in metadata ? metadata.size : null, + compressedSize: null, + executable: "executable" in metadata && metadata.executable, + encrypted: false, + byteOffset: null, + declaredSha256: + "integrity" in metadata && + metadata.integrity.algorithm === "SHA256" && + /^[a-f0-9]{64}$/u.test(metadata.integrity.hash) + ? metadata.integrity.hash + : null, + limitations: + kind === "symlink" + ? ["ASAR symlink target was not followed or disclosed."] + : [ + "Official ASAR extraction buffers each individually bounded file.", + ], + adapterKey: path, + }; + } + } + + open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + abortIfNeeded(signal); + if (entry.kind !== "file") + return Promise.reject( + new ArtifactReaderFailure("format", "ASAR entry is not a regular file"), + ); + return Promise.resolve( + Readable.from(extractFile(this.path, entry.adapterKey, false)), + ); + } + + close(): Promise { + return Promise.resolve(); + } + + provenance(): readonly [] { + return []; + } +} + +const abortIfNeeded = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"); +}; diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts new file mode 100644 index 000000000..e6cc74524 --- /dev/null +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -0,0 +1,139 @@ +import { constants } from "node:fs"; +import { lstat, open, opendir, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; +import type { Readable } from "node:stream"; + +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactReader, +} from "./ArtifactReader.js"; + +/** Deterministic, non-symlink-following directory reader. */ +export class DirectoryArtifactReader implements ArtifactReader { + readonly format = "directory" as const; + readonly #rootPromise: Promise; + + constructor(root: string) { + this.#rootPromise = realpath(root); + } + + async *entries(signal?: AbortSignal): AsyncIterable { + const root = await this.#rootPromise; + const pending = [root]; + while (pending.length > 0) { + abortIfNeeded(signal); + const directory = pending.pop(); + if (directory === undefined) break; + await assertContainedDirectory(root, directory); + const children: string[] = []; + const handle = await opendir(directory); + try { + for await (const child of handle) children.push(child.name); + } finally { + await handle.close().catch(() => undefined); + } + await assertContainedDirectory(root, directory); + children.sort((left, right) => left.localeCompare(right, "en")); + const directories: string[] = []; + for (const name of children) { + abortIfNeeded(signal); + const absolute = join(directory, name); + const metadata = await lstat(absolute); + const path = relative(root, absolute).split(sep).join("/"); + const kind = metadata.isSymbolicLink() + ? "symlink" + : metadata.isDirectory() + ? "directory" + : metadata.isFile() + ? "file" + : undefined; + if (kind === undefined) continue; + if (kind === "directory") directories.push(absolute); + yield { + path, + kind, + declaredSize: kind === "file" ? metadata.size : null, + compressedSize: null, + executable: (metadata.mode & 0o111) !== 0, + encrypted: false, + byteOffset: null, + declaredSha256: null, + limitations: + kind === "symlink" + ? ["Symlink target was not followed or disclosed."] + : [], + adapterKey: absolute, + ...(kind === "file" + ? { sourceIdentity: { device: metadata.dev, inode: metadata.ino } } + : {}), + }; + } + directories.reverse(); + pending.push(...directories); + } + } + + async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + abortIfNeeded(signal); + if (entry.kind !== "file") + throw new ArtifactReaderFailure( + "format", + "Only regular files can be opened", + ); + const handle = await open( + entry.adapterKey, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const observed = await handle.stat(); + if ( + !observed.isFile() || + entry.sourceIdentity === undefined || + observed.dev !== entry.sourceIdentity.device || + observed.ino !== entry.sourceIdentity.inode + ) { + await handle.close(); + throw new ArtifactReaderFailure( + "integrity", + `Directory entry changed before open: ${entry.path}`, + ); + } + return handle.createReadStream({ autoClose: true }); + } + + close(): Promise { + return Promise.resolve(); + } + + provenance(): readonly [] { + return []; + } +} + +const abortIfNeeded = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "Artifact traversal cancelled", + ); +}; + +const assertContainedDirectory = async ( + root: string, + directory: string, +): Promise => { + const canonical = await realpath(directory); + const fromRoot = relative(root, canonical); + const metadata = await lstat(directory); + if ( + metadata.isSymbolicLink() || + !metadata.isDirectory() || + fromRoot === ".." || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) + throw new ArtifactReaderFailure( + "path", + "Directory traversal escaped or changed its artifact root", + ); +}; diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts new file mode 100644 index 000000000..c29436de9 --- /dev/null +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -0,0 +1,102 @@ +import { createReadStream } from "node:fs"; +import type { Readable } from "node:stream"; + +import type { ArtifactCommand } from "../domain/artifactGraph.js"; +import { + XcrunCommandRunner, + type NativeCommandRunner, +} from "../native/CommandRunner.js"; +import { parseLipoArchitectures } from "../native/parsers/lipo.js"; +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactReader, +} from "./ArtifactReader.js"; + +/** Read-only universal Mach-O slice reader backed by native lipo metadata. */ +export class MachOSliceArtifactReader implements ArtifactReader { + readonly format = "file" as const; + #command: ArtifactCommand | undefined; + + constructor( + private readonly path: string, + private readonly runner: NativeCommandRunner = new XcrunCommandRunner(), + ) {} + + async *entries(signal?: AbortSignal): AsyncIterable { + const captured = await this.runner.run( + "lipo", + ["-detailed_info", this.path], + { + ...(signal === undefined ? {} : { signal }), + timeoutMs: 30_000, + maxOutputBytes: 4 * 1024 * 1024, + }, + ); + if (!captured.ok) + throw new ArtifactReaderFailure( + captured.error.reason === "cancelled" ? "cancelled" : "unavailable", + "lipo could not enumerate universal Mach-O slices", + { cause: captured.error }, + ); + this.#command = { + tool: captured.value.tool, + arguments: ["-detailed_info", "$ARTIFACT"], + tool_version: captured.value.toolVersion, + executable_sha256: captured.value.executableSha256, + exit_code: captured.value.exitCode, + effects: ["read"], + }; + for (const architecture of parseLipoArchitectures(captured.value.stdout)) { + if (architecture.file_offset === null || architecture.size === null) + throw new ArtifactReaderFailure( + "integrity", + "lipo omitted a universal slice byte range", + ); + yield { + path: `slices/${architecture.name}`, + kind: "slice", + declaredSize: architecture.size, + compressedSize: null, + executable: true, + encrypted: false, + byteOffset: architecture.file_offset, + declaredSha256: null, + limitations: [], + adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`, + }; + } + } + + open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + if (signal?.aborted === true) + return Promise.reject( + new ArtifactReaderFailure("cancelled", "Mach-O slice read cancelled"), + ); + const [offsetText, sizeText] = entry.adapterKey.split(":"); + const offset = Number(offsetText); + const size = Number(sizeText); + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + size <= 0 + ) + return Promise.reject( + new ArtifactReaderFailure( + "integrity", + "Invalid Mach-O slice byte range", + ), + ); + return Promise.resolve( + createReadStream(this.path, { start: offset, end: offset + size - 1 }), + ); + } + + provenance(): readonly ArtifactCommand[] { + return this.#command === undefined ? [] : [this.#command]; + } + + close(): Promise { + return Promise.resolve(); + } +} diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts new file mode 100644 index 000000000..a2e67a3fc --- /dev/null +++ b/src/artifacts/SafeOutputTree.ts @@ -0,0 +1,321 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, realpath, rm } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import { streamChunkToBuffer } from "./StreamBytes.js"; + +import { + ArtifactPathRegistry, + normalizeArtifactPath, +} from "./ArtifactPaths.js"; +import { + ArtifactReaderFailure, + type ArtifactLimits, +} from "./ArtifactReader.js"; + +/** One file durably written to a transaction-owned output tree. */ +export interface SafeOutputFile { + readonly relativePath: string; + readonly sha256: string; + readonly bytesWritten: number; +} + +/** Cleanup facts established while rolling back an uncommitted tree. */ +export interface SafeOutputCleanup { + readonly attempted: boolean; + readonly verified: boolean; + readonly residualPaths: readonly string[]; +} + +/** Transactional, symlink-resistant materialization beneath an absent path. */ +export class SafeOutputTree { + readonly #registry = new ArtifactPathRegistry(); + readonly #limits: ArtifactLimits; + readonly #outputRoot: string; + readonly #stagingRoot: string; + #totalBytes = 0; + #published = false; + #cleanup: SafeOutputCleanup = { + attempted: false, + verified: false, + residualPaths: [], + }; + + private constructor( + outputRoot: string, + stagingRoot: string, + limits: ArtifactLimits, + ) { + this.#outputRoot = outputRoot; + this.#stagingRoot = stagingRoot; + this.#limits = limits; + } + + /** Exclusively create the absent destination as this transaction's owned tree. */ + static async create( + outputRoot: string, + limits: ArtifactLimits, + ): Promise { + if (!isAbsolute(outputRoot)) + throw new ArtifactReaderFailure( + "path", + "Extraction output root must be absolute", + ); + const requested = resolve(outputRoot); + const name = basename(requested); + if (name === "." || name === "..") + throw new ArtifactReaderFailure("path", "Invalid extraction output root"); + const parent = await realpath(dirname(requested)).catch( + (cause: unknown) => { + throw new ArtifactReaderFailure( + "unavailable", + "Extraction output parent is unavailable", + { cause }, + ); + }, + ); + const canonicalOutput = join(parent, name); + await mkdir(canonicalOutput, { mode: 0o700 }).catch((cause: unknown) => { + if (isAlreadyExists(cause)) + throw new ArtifactReaderFailure( + "path", + "Extraction output root already exists", + { cause }, + ); + throw cause; + }); + try { + const stagingHandle = await open( + canonicalOutput, + constants.O_RDONLY | constants.O_DIRECTORY, + ); + try { + await stagingHandle.chmod(0o700); + } finally { + await stagingHandle.close(); + } + return new SafeOutputTree(canonicalOutput, canonicalOutput, limits); + } catch (cause: unknown) { + await rm(canonicalOutput, { recursive: true, force: true }); + throw cause; + } + } + + get outputRoot(): string { + return this.#outputRoot; + } + + get cleanup(): SafeOutputCleanup { + return this.#cleanup; + } + + /** Stream one selected regular file with exact byte and digest verification. */ + async write( + relativePath: string, + source: Readable, + expectedSha256: string, + signal?: AbortSignal, + ): Promise { + this.#assertWritable(); + const path = normalizeArtifactPath(relativePath, this.#limits); + this.#registry.add(path, "file"); + const destination = await this.#prepareParent(path); + const handle = await open( + destination, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + constants.O_NOFOLLOW, + 0o600, + ).catch((cause: unknown) => { + throw new ArtifactReaderFailure( + "path", + `Could not exclusively create extraction path: ${path}`, + { cause }, + ); + }); + const hash = createHash("sha256"); + let bytes = 0; + try { + for await (const raw of source) { + abortIfNeeded(signal); + const chunk = streamChunkToBuffer(raw); + bytes += chunk.length; + if (bytes > this.#limits.maxEntryBytes) + throw new ArtifactReaderFailure( + "limit", + `Observed entry bytes exceed limit: ${path}`, + ); + if (this.#totalBytes + bytes > this.#limits.maxTotalBytes) + throw new ArtifactReaderFailure( + "limit", + "Observed extraction bytes exceed cumulative limit", + ); + hash.update(chunk); + await writeAll(handle, chunk); + } + const sha256 = hash.digest("hex"); + if (sha256 !== expectedSha256) + throw new ArtifactReaderFailure( + "integrity", + `Extracted content disagrees with inventory: ${path}`, + ); + await handle.sync(); + await handle.close(); + const readback = await hashFile(destination, bytes, signal); + if (readback.sha256 !== sha256 || readback.bytes !== bytes) + throw new ArtifactReaderFailure( + "integrity", + `Durable readback verification failed: ${path}`, + ); + this.#totalBytes += bytes; + return { relativePath: path, sha256, bytesWritten: bytes }; + } catch (cause: unknown) { + await handle.close().catch(() => undefined); + throw cause; + } + } + + /** Durably commit the exclusively-created output tree. */ + async commit(): Promise { + this.#assertWritable(); + const parent = await open( + dirname(this.#outputRoot), + constants.O_RDONLY | constants.O_DIRECTORY, + ); + const staging = await open( + this.#stagingRoot, + constants.O_RDONLY | constants.O_DIRECTORY, + ); + try { + await staging.sync(); + await parent.sync(); + this.#published = true; + } catch (cause: unknown) { + throw new ArtifactReaderFailure( + "path", + "Could not durably commit extraction output", + { cause }, + ); + } finally { + await Promise.allSettled([parent.close(), staging.close()]); + } + } + + /** Remove only this transaction's uncommitted tree and verify absence. */ + async rollback(): Promise { + if (this.#published) return this.#cleanup; + await rm(this.#stagingRoot, { recursive: true, force: true }); + const absent = await isAbsent(this.#stagingRoot); + this.#cleanup = { + attempted: true, + verified: absent, + residualPaths: absent ? [] : [basename(this.#stagingRoot)], + }; + if (!absent) + throw new ArtifactReaderFailure( + "integrity", + "Extraction output cleanup could not be verified", + ); + return this.#cleanup; + } + + async #prepareParent(relativePath: string): Promise { + const parts = relativePath.split("/"); + const fileName = parts.pop(); + if (fileName === undefined) + throw new ArtifactReaderFailure("path", "Invalid extraction path"); + let current = this.#stagingRoot; + for (const part of parts) { + current = join(current, part); + await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => { + if (!isAlreadyExists(cause)) throw cause; + }); + const metadata = await lstat(current); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) + throw new ArtifactReaderFailure( + "path", + `Unsafe extraction parent: ${relativePath}`, + ); + } + return join(current, fileName); + } + + #assertWritable(): void { + if (this.#published) + throw new ArtifactReaderFailure( + "integrity", + "Extraction tree is already committed", + ); + } +} + +const hashFile = async ( + path: string, + maximum: number, + signal?: AbortSignal, +): Promise<{ readonly sha256: string; readonly bytes: number }> => { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const hash = createHash("sha256"); + let bytes = 0; + try { + for await (const raw of handle.createReadStream({ autoClose: false })) { + abortIfNeeded(signal); + const chunk = streamChunkToBuffer(raw); + bytes += chunk.length; + if (bytes > maximum) + throw new ArtifactReaderFailure( + "integrity", + "Readback exceeded the bytes written", + ); + hash.update(chunk); + } + } finally { + await handle.close(); + } + return { sha256: hash.digest("hex"), bytes }; +}; + +const writeAll = async ( + handle: Awaited>, + chunk: Buffer, +): Promise => { + let offset = 0; + while (offset < chunk.length) { + const { bytesWritten } = await handle.write( + chunk, + offset, + chunk.length - offset, + ); + if (bytesWritten === 0) + throw new ArtifactReaderFailure( + "unavailable", + "Extraction output stopped accepting bytes", + ); + offset += bytesWritten; + } +}; + +const isAbsent = async (path: string): Promise => + lstat(path).then( + () => false, + (cause: unknown) => { + if (isNotFound(cause)) return true; + throw cause; + }, + ); + +const isNotFound = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + +const isAlreadyExists = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "EEXIST"; + +const abortIfNeeded = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "Artifact extraction cancelled", + ); +}; diff --git a/src/artifacts/StreamBytes.ts b/src/artifacts/StreamBytes.ts new file mode 100644 index 000000000..9723f0650 --- /dev/null +++ b/src/artifacts/StreamBytes.ts @@ -0,0 +1,7 @@ +/** Parse a Node readable chunk without unchecked coercion. */ +export const streamChunkToBuffer = (value: unknown): Buffer => { + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === "string") return Buffer.from(value); + throw new TypeError("Readable stream emitted a non-byte chunk"); +}; diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts new file mode 100644 index 000000000..088c3af50 --- /dev/null +++ b/src/artifacts/ZipArtifactReader.ts @@ -0,0 +1,138 @@ +import { open, type FileHandle } from "node:fs/promises"; +import { PassThrough, type Readable } from "node:stream"; +import { once } from "node:events"; + +import { Reader, ZipReader, type Entry, type FileEntry } from "@zip.js/zip.js"; + +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactReader, +} from "./ArtifactReader.js"; + +class NodeFileReader extends Reader { + #handle: FileHandle | undefined; + + constructor(private readonly path: string) { + super(path); + } + + override async init(): Promise { + this.#handle = await open(this.path, "r"); + this.size = (await this.#handle.stat()).size; + } + + override async readUint8Array( + index: number, + length: number, + ): Promise { + const handle = this.#handle; + if (handle === undefined) throw new Error("ZIP reader is not initialized"); + const bytes = Buffer.alloc(Math.min(length, this.size - index)); + const read = await handle.read(bytes, 0, bytes.length, index); + return bytes.subarray(0, read.bytesRead); + } + + async closeHandle(): Promise { + await this.#handle?.close(); + this.#handle = undefined; + } +} + +/** Lazy Zip64-capable reader with CRC and overlap verification on every read. */ +export class ZipArtifactReader implements ArtifactReader { + readonly format: "zip" | "ipa" | "apk"; + readonly #source: NodeFileReader; + readonly #reader: ZipReader; + readonly #entries = new Map(); + + constructor(path: string, format: "zip" | "ipa" | "apk") { + this.format = format; + this.#source = new NodeFileReader(path); + this.#reader = new ZipReader(this.#source, { + checkSignature: true, + checkOverlappingEntry: true, + }); + } + + async *entries(signal?: AbortSignal): AsyncIterable { + for await (const entry of this.#reader.getEntriesGenerator()) { + abortIfNeeded(signal); + this.#entries.set(entry.filename, entry); + const symlink = isSymlink(entry); + yield { + path: entry.filename, + kind: entry.directory ? "directory" : symlink ? "symlink" : "file", + declaredSize: entry.directory ? null : entry.uncompressedSize, + compressedSize: entry.directory ? null : entry.compressedSize, + executable: entry.executable, + encrypted: entry.encrypted, + byteOffset: null, + declaredSha256: null, + limitations: symlink ? ["Archive symlink target was not read."] : [], + adapterKey: entry.filename, + }; + } + } + + open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + abortIfNeeded(signal); + const stored = this.#entries.get(entry.adapterKey); + if (stored === undefined || stored.directory || isSymlink(stored)) + return Promise.reject( + new ArtifactReaderFailure("format", "ZIP entry is not a regular file"), + ); + if (stored.encrypted) + return Promise.reject( + new ArtifactReaderFailure( + "unavailable", + "Encrypted ZIP entry is unsupported", + ), + ); + return Promise.resolve(extractStream(stored, signal)); + } + + async close(): Promise { + await this.#reader.close().catch(() => undefined); + await this.#source.closeHandle(); + } + + provenance(): readonly [] { + return []; + } +} + +const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => { + const output = new PassThrough(); + const writable = new WritableStream({ + write: async (chunk) => { + abortIfNeeded(signal); + if (!output.write(Buffer.from(chunk))) await once(output, "drain"); + }, + close: () => { + output.end(); + }, + abort: (reason) => { + output.destroy(toError(reason)); + }, + }); + void entry + .getData(writable, { + checkSignature: true, + checkOverlappingEntry: true, + onprogress: () => abortIfNeeded(signal), + }) + .catch((cause: unknown) => output.destroy(toError(cause))); + return output; +}; + +const isSymlink = (entry: Entry): boolean => + entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000; + +const abortIfNeeded = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure("cancelled", "ZIP operation cancelled"); +}; + +const toError = (cause: unknown): Error => + cause instanceof Error ? cause : new Error("ZIP extraction failed"); diff --git a/src/cli.ts b/src/cli.ts index 448752d2b..b976dc031 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,10 +2,18 @@ import { Cli, z } from "incur"; import { fileURLToPath } from "node:url"; import { runDoctor } from "./application/Doctor.js"; -import { runDirectAnalysis } from "./application/DirectAnalysis.js"; +import { + runDirectAnalysis, + runProviderAnalysis, +} from "./application/DirectAnalysis.js"; import { runSetup } from "./application/Setup.js"; import { PRODUCT_IDENTITY } from "./identity.js"; import { createLogger, parseLogLevel, type Logger } from "./logger.js"; +import { parseConfig } from "./config.js"; +import { + exportEvidenceBundleCommand, + importEvidenceBundleCommand, +} from "./application/EvidenceBundleCommands.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -85,9 +93,172 @@ export const createCli = (): ReturnType => { ), ), }); + registerNativeCommands(cli, logger); + registerArtifactCommands(cli, logger); + registerEvidenceCommands(cli, logger); return cli; }; +const registerArtifactCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("inventory-artifact", { + description: "Build a bounded deterministic artifact graph", + args: z.object({ + path: z.string().describe("Application or package path"), + }), + options: z.object({ + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), + }), + run: ({ args, options }) => + logCliCommand(logger, "inventory-artifact", () => + runProviderAnalysis( + args.path, + "inventory_artifact", + { + node_offset: options.offset, + node_limit: options.limit, + occurrence_offset: options.offset, + occurrence_limit: options.limit, + edge_offset: options.offset, + edge_limit: options.limit, + }, + logger, + ), + ), + }); + cli.command("extract-artifact", { + description: "Extract explicitly selected artifact occurrences safely", + args: z.object({ + path: z.string().describe("Application or package path"), + outputRoot: z.string().describe("Absent absolute output root"), + occurrenceIds: z + .array(z.string().regex(/^occ_[a-f0-9]{64}$/u)) + .min(1) + .max(500), + }), + alias: { outputRoot: "output-root", occurrenceIds: "occurrence-ids" }, + run: ({ args }) => + logCliCommand(logger, "extract-artifact", () => + runProviderAnalysis( + args.path, + "extract_artifact", + { + approved: true, + output_root: args.outputRoot, + occurrence_ids: args.occurrenceIds, + }, + logger, + ), + ), + }); +}; + +const registerNativeCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + for (const [command, tool] of [ + ["inspect-macho", "inspect_macho"], + ["inspect-signature", "inspect_signature"], + ["list-architectures", "list_architectures"], + ] as const) { + cli.command(command, { + description: `Run ${tool} without launching Hopper`, + args: z.object({ path: z.string().describe("Mach-O or app path") }), + run: ({ args }) => + logCliCommand(logger, command, () => + runProviderAnalysis(args.path, tool, {}, logger), + ), + }); + } + cli.command("inspect-plist", { + description: "Parse app plist metadata without launching Hopper", + args: z.object({ path: z.string().describe("App or Mach-O path") }), + options: z.object({ + relativePath: z.string().default("Contents/Info.plist"), + }), + alias: { relativePath: "relative-path" }, + run: ({ args, options }) => + logCliCommand(logger, "inspect-plist", () => + runProviderAnalysis( + args.path, + "inspect_plist", + { relative_path: options.relativePath }, + logger, + ), + ), + }); + cli.command("demangle-swift", { + description: "Demangle a bounded Swift symbol batch without Hopper", + args: z.object({ + path: z.string().describe("Artifact path used for evidence identity"), + symbols: z.array(z.string().min(1)).min(1).max(500), + }), + run: ({ args }) => + logCliCommand(logger, "demangle-swift", () => + runProviderAnalysis( + args.path, + "demangle_swift", + { symbols: args.symbols }, + logger, + ), + ), + }); +}; + +const registerEvidenceCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("evidence-import", { + description: "Validate and import a bounded local Evidence v2 bundle", + args: z.object({ + path: z.string().describe("Evidence bundle JSON path"), + }), + run: ({ args }) => + logCliCommand(logger, "evidence-import", async () => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const imported = await importEvidenceBundleCommand( + args.path, + config.value.evidenceFilePolicy, + ); + return imported.ok + ? imported.value + : { error: imported.error._tag, message: imported.error.message }; + }), + }); + cli.command("evidence-export", { + description: "Validate and atomically export canonical Evidence v2 JSON", + args: z.object({ + source: z.string().describe("Existing evidence bundle JSON path"), + output: z.string().describe("Canonical output JSON path"), + }), + options: z.object({ + overwrite: z.boolean().default(false).describe("Replace output file"), + }), + run: ({ args, options }) => + logCliCommand(logger, "evidence-export", async () => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const exported = await exportEvidenceBundleCommand( + args.source, + args.output, + options.overwrite, + config.value.evidenceFilePolicy, + ); + return exported.ok + ? exported.value + : { error: exported.error._tag, message: exported.error.message }; + }), + }); +}; + const logCliCommand = async ( logger: Logger, command: string, diff --git a/src/config.ts b/src/config.ts index 0830c3936..2d13d61bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,8 @@ import { ConfigurationError } from "./domain/errors.js"; import { err, ok, type Result } from "./domain/result.js"; import type { LogLevel } from "./logger.js"; import type { ProcessExecutionPolicy } from "./domain/processCapture.js"; +import type { EvidenceFilePolicy } from "./domain/evidenceBundle.js"; +import type { ReferenceSourcePolicy } from "./domain/referenceSourcePolicy.js"; const DEFAULT_HOPPER_LAUNCHER_PATH = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; @@ -15,6 +17,8 @@ export interface AppConfig { readonly hopperLoaderArgs: readonly string[]; readonly logLevel: LogLevel; readonly processExecutionPolicy: ProcessExecutionPolicy; + readonly evidenceFilePolicy: EvidenceFilePolicy; + readonly referenceSourcePolicy: ReferenceSourcePolicy; } const environmentSchema = z.object({ @@ -26,9 +30,15 @@ const environmentSchema = z.object({ .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) .default("info"), REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"), + REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z + .enum(["true", "false"]) + .default("false"), REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"), REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"), REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"), + REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"), + REA_REFERENCE_ROOTS_JSON: z.string().default("[]"), + REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"), }); const parseStringArray = ( @@ -106,6 +116,21 @@ export const parseConfig = ( "REA_PROCESS_ALLOWED_ENV_JSON", ); if (!allowedEnvironment.ok) return allowedEnvironment; + const evidenceRoots = parseStringArray( + parsedEnvironment.data.REA_EVIDENCE_ROOTS_JSON, + "REA_EVIDENCE_ROOTS_JSON", + ); + if (!evidenceRoots.ok) return evidenceRoots; + const referenceRoots = parseStringArray( + parsedEnvironment.data.REA_REFERENCE_ROOTS_JSON, + "REA_REFERENCE_ROOTS_JSON", + ); + if (!referenceRoots.ok) return referenceRoots; + const secretPatterns = parseStringArray( + parsedEnvironment.data.REA_REFERENCE_SECRET_PATTERNS_JSON, + "REA_REFERENCE_SECRET_PATTERNS_JSON", + ); + if (!secretPatterns.ok) return secretPatterns; return ok({ hopperLauncherPath: parsedEnvironment.data.HOPPER_LAUNCHER_PATH ?? @@ -119,6 +144,23 @@ export const parseConfig = ( executableRoots: executableRoots.value, workingRoots: workingRoots.value, allowedEnvironment: allowedEnvironment.value, + allowExternalNetwork: + parsedEnvironment.data.REA_PROCESS_ALLOW_EXTERNAL_NETWORK === "true", + }, + evidenceFilePolicy: { + roots: evidenceRoots.value, + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024 * 1024, + maxNodes: 1_000_000, + }, + referenceSourcePolicy: { + roots: referenceRoots.value, + secretPatterns: secretPatterns.value, + maxBytes: 16 * 1024 * 1024, + maxEntries: 10_000, + maxDepth: 32, + maxPathBytes: 4_096, }, }); }; diff --git a/src/contracts/artifactComparisonExample.ts b/src/contracts/artifactComparisonExample.ts new file mode 100644 index 000000000..d8e80ed80 --- /dev/null +++ b/src/contracts/artifactComparisonExample.ts @@ -0,0 +1,98 @@ +import { createEvidence } from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; + +const inventory = (digit: string) => { + const sha = digit.repeat(64); + const artifactId = `art_${sha}`; + const occurrenceId = `occ_${sha}`; + return jsonValueSchema.parse({ + manifest: { + schema_version: 1, + manifest_id: `agm_${sha}`, + root_artifact_id: artifactId, + root_sha256: sha, + root_format: "file", + graph_sha256: sha, + node_count: 1, + occurrence_count: 1, + edge_count: 0, + }, + nodes: { + items: [ + { + artifact_id: artifactId, + kind: "resource", + format: "file", + sha256: sha, + size: 1, + media_type: null, + architecture: null, + executable: false, + content_state: "materialized", + limitations: [], + }, + ], + offset: 0, + limit: 100, + total: 1, + next_offset: null, + }, + occurrences: { + items: [ + { + occurrence_id: occurrenceId, + artifact_id: artifactId, + parent_occurrence_id: null, + logical_path: ".", + entry_kind: "file", + declared_size: 1, + compressed_size: null, + executable: false, + encrypted: false, + hash_status: "verified", + source_location: null, + limitations: [], + }, + ], + offset: 0, + limit: 100, + total: 1, + next_offset: null, + }, + edges: { items: [], offset: 0, limit: 100, total: 0, next_offset: null }, + limits: { + max_entries: 100, + max_total_bytes: 1024, + max_entry_bytes: 1024, + max_compression_ratio: 10, + max_depth: 10, + max_path_bytes: 256, + }, + provenance: [], + limitations: [], + }); +}; + +const provider = { + id: "rea-artifact", + name: "REA artifact graph", + version: "1", +} as const; + +/** Canonical complete inputs used to advertise artifact comparison. */ +export const ARTIFACT_COMPARISON_EXAMPLE = { + left: createEvidence(undefined, provider, { + operation: "inventory_artifact", + parameters: {}, + result: inventory("0"), + confidence: "observed", + authority: "shipped-artifact", + }), + right: createEvidence(undefined, provider, { + operation: "inventory_artifact", + parameters: {}, + result: inventory("1"), + confidence: "observed", + authority: "shipped-artifact", + }), +} as const; diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts new file mode 100644 index 000000000..c42af930d --- /dev/null +++ b/src/contracts/artifactToolContracts.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +import type { ToolContract } from "./toolContracts.js"; +import { artifactOutputSchemas } from "./toolOutputSchemas.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; + +const pageInput = { + node_offset: z.number().int().min(0).default(0), + node_limit: z.number().int().min(1).max(500).default(100), + occurrence_offset: z.number().int().min(0).default(0), + occurrence_limit: z.number().int().min(1).max(500).default(100), + edge_offset: z.number().int().min(0).default(0), + edge_limit: z.number().int().min(1).max(500).default(100), +}; + +const traversalLimitsInput = { + max_entries: z.number().int().min(1).max(1_000_000).default(10_000), + max_total_bytes: z + .number() + .int() + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(1_073_741_824), + max_entry_bytes: z + .number() + .int() + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(268_435_456), + max_compression_ratio: z.number().min(1).max(100_000).default(1_000), + max_depth: z.number().int().min(0).max(100).default(20), + max_path_bytes: z.number().int().min(1).max(65_535).default(4_096), +}; + +/** Exact caller boundary for deterministic artifact inventory. */ +export const artifactInventoryInputSchema = z.object({ + ...pageInput, + ...traversalLimitsInput, +}); + +/** Exact caller boundary for approved artifact extraction. */ +export const artifactExtractionInputSchema = z.object({ + approved: z.literal(true), + output_root: z.string().min(1).max(4_096), + occurrence_ids: z + .array(z.string().regex(/^occ_[a-f0-9]{64}$/u)) + .min(1) + .max(500), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), + ...traversalLimitsInput, +}); + +const exampleInputSchema = z.record(z.string(), jsonValueSchema); +const examples: Readonly>>> = { + inventory_artifact: {}, + extract_artifact: { + approved: true, + output_root: "/tmp/rea-extracted", + occurrence_ids: [`occ_${"0".repeat(64)}`], + }, +}; + +const artifact = ( + name: Name, + description: string, + inputSchema: z.ZodObject, + mutatesFilesystem: boolean, +): ToolContract => { + const outputSchema = artifactOutputSchemas[name]; + if (outputSchema === undefined) + throw new Error(`Missing artifact output schema for ${name}`); + return { + name, + description, + kind: "artifact-provider", + inputSchema, + outputSchema, + annotations: { + readOnlyHint: !mutatesFilesystem, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + examples: [ + { + title: `Example ${name.replaceAll("_", " ")} request`, + input: exampleInputSchema.parse(examples[name] ?? {}), + }, + ], + }; +}; + +/** Artifact-graph inventory and explicitly approved safe extraction contracts. */ +export const ARTIFACT_TOOL_CONTRACTS = [ + artifact( + "inventory_artifact", + "Inventory the active application or package as a deterministic, content-addressed artifact graph. Returns bounded node and edge pages without extracting or mounting by default.", + artifactInventoryInputSchema, + false, + ), + artifact( + "extract_artifact", + "Extract selected graph artifacts beneath an explicit output root. Requires approval, rejects traversal and symlink escapes, never overwrites, enforces bomb limits, and verifies cleanup.", + artifactExtractionInputSchema, + true, + ), +] as const satisfies readonly ToolContract[]; + +/** Names of provider-neutral artifact graph operations. */ +export type ArtifactToolName = (typeof ARTIFACT_TOOL_CONTRACTS)[number]["name"]; diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 3b36e5fe9..2167d3ac5 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -57,6 +57,10 @@ export const enhancedInputSchemas = { case_sensitive: z.boolean().default(false), limit: z.number().int().min(1).max(100).default(20), max_operations: z.number().int().min(1).max(100).default(20), + unknown_registry_approved: z + .literal(true) + .optional() + .describe("Explicit approval to record bounded residuals durably"), }), } as const; diff --git a/src/contracts/functionComparisonExample.ts b/src/contracts/functionComparisonExample.ts new file mode 100644 index 000000000..bc7fa4e57 --- /dev/null +++ b/src/contracts/functionComparisonExample.ts @@ -0,0 +1,67 @@ +import { enhancedInputSchemas } from "./enhancedInputs.js"; +import { createEvidence } from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; + +const bounded = (items: readonly Item[]) => ({ + items, + total: items.length, + returned: items.length, + truncated: false, + next_offset: null, +}); + +const dossier = (text: string) => + jsonValueSchema.parse({ + procedure: { + address: "0x1000", + name: "main", + signature: "int main(void)", + locals: [], + }, + pseudocode: { + text, + total_chars: [...text].length, + returned_chars: [...text].length, + truncated: false, + next_offset: null, + }, + assembly: bounded([]), + comments: bounded([]), + callers: bounded([]), + callees: bounded([]), + incoming_references: bounded([]), + outgoing_references: bounded([]), + referenced_strings: bounded([]), + referenced_names: bounded([]), + basic_blocks: bounded([{ start: "0x1000", end: "0x1001", successors: [] }]), + instruction_scan: { scanned: 1, truncated: false }, + }); + +const observe = (digit: string, text: string) => + createEvidence( + { + path: `/tmp/function-${digit}`, + sha256: digit.repeat(64), + format: "mach-o", + }, + { + id: "rea-workflow", + name: "REA composed investigation workflow", + version: "1", + }, + { + operation: "analyze_function", + parameters: enhancedInputSchemas.analyze_function.parse({ + procedure: "main", + }), + result: dossier(text), + confidence: "derived", + authority: "shipped-artifact", + }, + ); + +/** Canonical explicit function pair used in public contract examples. */ +export const FUNCTION_COMPARISON_EXAMPLE = { + left: observe("0", "return 0;"), + right: observe("1", "return 1;"), +} as const; diff --git a/src/contracts/investigationExamples.ts b/src/contracts/investigationExamples.ts new file mode 100644 index 000000000..c7ec1e3f4 --- /dev/null +++ b/src/contracts/investigationExamples.ts @@ -0,0 +1,139 @@ +import { compareFunctions } from "../domain/functionComparison.js"; +import { createEvidence } from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { + compareProcessCaptures, + processCaptureSchema, +} from "../domain/processCapture.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "./functionComparisonExample.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js"; + +const comparison = compareFunctions( + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + 0, + 100, +); + +export const FUNCTION_COMPARISON_EVIDENCE = createEvidence( + undefined, + { + id: "rea-function-comparison", + name: "REA function comparison", + version: "1", + }, + { + predicateType: "rea.function-comparison/v1", + operation: "compare_functions", + parameters: { + left_evidence_ids: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id], + right_evidence_ids: [FUNCTION_COMPARISON_EXAMPLE.right.evidence_id], + offset: 0, + limit: 100, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [ + FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, + FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + ], + }, +); + +const PROCESS_PROVIDER = { + id: "rea-process", + name: "REA deterministic process harness", + version: "1", +} as const; +const capture = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); +const captureEvidence = (scenario: string) => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v2", + operation: "capture_process_scenario", + parameters: { scenario }, + result: jsonValueSchema.parse(capture), + confidence: "observed", + authority: "controlled-replay", + environment: { + id: "fixture-linux", + platform: "linux", + architecture: "x86_64", + isolation: "container", + }, + }); +export const PROCESS_CAPTURE_REFERENCE = captureEvidence("reference"); +export const PROCESS_CAPTURE_RECONSTRUCTION = captureEvidence("reconstruction"); +export const PROCESS_COMPARISON_EVIDENCE = createEvidence( + undefined, + PROCESS_PROVIDER, + { + predicateType: "rea.process-comparison/v1", + operation: "compare_process_captures", + parameters: { + left_evidence_id: PROCESS_CAPTURE_REFERENCE.evidence_id, + right_evidence_id: PROCESS_CAPTURE_RECONSTRUCTION.evidence_id, + left_normalization: capture.normalization, + right_normalization: capture.normalization, + }, + result: jsonValueSchema.parse(compareProcessCaptures(capture, capture)), + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [ + PROCESS_CAPTURE_REFERENCE.evidence_id, + PROCESS_CAPTURE_RECONSTRUCTION.evidence_id, + ], + }, +); + +/** Canonical inputs for comparison-composed investigation contracts. */ +export const INVESTIGATION_EXAMPLES = { + find_changed_behavior: { comparisons: [FUNCTION_COMPARISON_EVIDENCE] }, + build_call_path: { + functions: [FUNCTION_COMPARISON_EXAMPLE.left], + start: { address: "0x1000" }, + goal: { address: "0x1000" }, + }, + correlate_static_and_runtime: { + static_comparisons: [FUNCTION_COMPARISON_EVIDENCE], + runtime_comparisons: [PROCESS_COMPARISON_EVIDENCE], + mappings: [ + { + static: { + comparison_evidence_id: FUNCTION_COMPARISON_EVIDENCE.evidence_id, + selector: { kind: "function", dimension: "pseudocode" }, + }, + runtime: { + comparison_evidence_id: PROCESS_COMPARISON_EVIDENCE.evidence_id, + dimension: "terminal", + }, + side_alignment: "left_to_left", + hypothesis: { + statement: "Static implementation changed without terminal change.", + expected_pattern: "static_only", + }, + }, + ], + }, + verify_reconstruction: { + specification: { + schema_version: 1, + name: "Terminal compatibility", + claims: [ + { + kind: "behavioral", + claim_id: "terminal-output", + title: "Terminal output remains equivalent", + comparison_evidence_id: PROCESS_COMPARISON_EVIDENCE.evidence_id, + dimension: "terminal", + }, + ], + }, + evidence_bundle: createEvidenceBundle([ + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]), + }, +}; diff --git a/src/contracts/nativeToolContracts.ts b/src/contracts/nativeToolContracts.ts new file mode 100644 index 000000000..4ed43be98 --- /dev/null +++ b/src/contracts/nativeToolContracts.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; + +import type { ToolContract } from "./toolContracts.js"; +import { nativeOutputSchemas } from "./toolOutputSchemas.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; + +const examples: Readonly>>> = { + inspect_macho: {}, + inspect_signature: {}, + inspect_plist: { relative_path: "Contents/Info.plist" }, + list_architectures: {}, + demangle_swift: { symbols: ["$s4Test3fooyyF"] }, +}; + +const native = ( + name: Name, + description: string, + inputSchema: z.ZodObject, +): ToolContract => { + const outputSchema = nativeOutputSchemas[name]; + if (outputSchema === undefined) + throw new Error(`Missing native output schema for ${name}`); + return { + name, + description, + kind: "native-provider", + inputSchema, + outputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + examples: [ + { + title: `Example ${name.replaceAll("_", " ")} request`, + input: z + .record(z.string(), jsonValueSchema) + .parse(examples[name] ?? {}), + }, + ], + }; +}; + +/** Provider-neutral semantic operations backed initially by macOS utilities. */ +export const NATIVE_TOOL_CONTRACTS = [ + native( + "inspect_macho", + "Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper.", + z.object({}), + ), + native( + "inspect_signature", + "Inspect the active artifact's code-signing identity, hashes, authorities, requirements, entitlements, hardened-runtime state, and bounded command provenance.", + z.object({}), + ), + native( + "inspect_plist", + "Parse a plist at a bounded relative path beneath the active artifact container. Symlink and traversal escapes are rejected; output is normalized JSON rather than plutil text.", + z.object({ + relative_path: z + .string() + .min(1) + .max(1_024) + .default("Contents/Info.plist"), + }), + ), + native( + "list_architectures", + "List thin or universal Mach-O slices with offsets, sizes, alignment, explicit coverage, and bounded native-tool provenance.", + z.object({}), + ), + native( + "demangle_swift", + "Demangle an ordered bounded batch of Swift symbols without requiring Hopper. Each input returns demangled, unchanged, or invalid status.", + z.object({ + symbols: z.array(z.string().min(1).max(4_096)).min(1).max(500), + }), + ), +] as const satisfies readonly ToolContract[]; + +export type NativeToolName = (typeof NATIVE_TOOL_CONTRACTS)[number]["name"]; diff --git a/src/contracts/processCaptureExample.ts b/src/contracts/processCaptureExample.ts new file mode 100644 index 000000000..2144cfac6 --- /dev/null +++ b/src/contracts/processCaptureExample.ts @@ -0,0 +1,27 @@ +import type { JsonValue } from "../domain/jsonValue.js"; + +/** Minimal valid process capture used only in public contract examples. */ +export const EMPTY_PROCESS_CAPTURE_EXAMPLE = { + schema_version: 2, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + exit: { code: 0, signal: null, reason: "exited" }, + process_samples: [], + protocol_events: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified", + temporary_root: "removed", + }, +} satisfies JsonValue; diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts new file mode 100644 index 000000000..d6d452960 --- /dev/null +++ b/src/contracts/toolContractExamples.ts @@ -0,0 +1,73 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js"; +import { UNKNOWN_CONTRACT_EXAMPLES } from "./unknownContractExamples.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "./artifactComparisonExample.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "./functionComparisonExample.js"; +import { INVESTIGATION_EXAMPLES } from "./investigationExamples.js"; + +/** Canonical examples for contracts whose required inputs have no defaults. */ +export const TOOL_EXAMPLE_OVERRIDES: Readonly< + Record>> +> = { + ...UNKNOWN_CONTRACT_EXAMPLES, + ...INVESTIGATION_EXAMPLES, + goto_address: { address: "0x1000" }, + procedure_address: { procedure: "main" }, + procedure_assembly: { procedure: "main" }, + procedure_callees: { procedure: "main" }, + procedure_callers: { procedure: "main" }, + procedure_info: { procedure: "main" }, + procedure_references: { procedure: "main" }, + procedure_pseudo_code: { procedure: "main" }, + resolve_containing_procedure: { address: "0x1000" }, + search_procedures: { pattern: "main" }, + search_strings: { pattern: "authorization failed" }, + set_address_name: { address: "0x1000", name: "entry" }, + set_addresses_names: { names: { "0x1000": "entry" } }, + set_bookmark: { address: "0x1000" }, + set_comment: { address: "0x1000", comment: "validated entry point" }, + set_current_document: { document: "fixture" }, + set_inline_comment: { address: "0x1000", comment: "calls parser" }, + unset_bookmark: { address: "0x1000" }, + get_call_graph: { address: "0x1000" }, + find_xrefs_to_name: { name: "malloc" }, + analyze_function: { procedure: "main" }, + trace_feature: { query: "license" }, + open_binary: { path: "/tmp/fixture" }, + import_evidence_bundle: { path: "evidence.json" }, + capture_process_scenario: { + approved: true, + executable: "/usr/bin/true", + working_directory: "/tmp", + }, + compare_process_captures: { + left_evidence_id: `ev_${"0".repeat(64)}`, + left: EMPTY_PROCESS_CAPTURE_EXAMPLE, + right_evidence_id: `ev_${"1".repeat(64)}`, + right: EMPTY_PROCESS_CAPTURE_EXAMPLE, + }, + compare_artifacts: ARTIFACT_COMPARISON_EXAMPLE, + compare_functions: FUNCTION_COMPARISON_EXAMPLE, + compare_bundles: { + left: { + bundle_version: 2, + artifacts: [], + providers: [], + environments: [], + scenarios: [], + captures: [], + unknowns: [], + records: [], + }, + right: { + bundle_version: 2, + artifacts: [], + providers: [], + environments: [], + scenarios: [], + captures: [], + unknowns: [], + records: [], + }, + }, +}; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index bf6d213d2..20d7661e3 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,22 +1,41 @@ import { z } from "zod"; -import { evidenceBundleSchema } from "../domain/evidenceBundle.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { processCaptureSchema, processScenarioSchema, } from "../domain/processCapture.js"; +import { + recordUnknownInputSchema, + updateUnknownInputSchema, +} from "../domain/residualUnknown.js"; +import { artifactComparisonInputSchema } from "../domain/artifactComparison.js"; +import { functionComparisonInputSchema } from "../domain/functionComparison.js"; +import { bundleComparisonInputSchema } from "../domain/bundleComparison.js"; +import { changedBehaviorInputSchema } from "../domain/changedBehavior.js"; +import { callPathInputSchema } from "../domain/callPath.js"; +import { staticRuntimeCorrelationInputSchema } from "../domain/staticRuntimeCorrelation.js"; +import { reconstructionVerificationInputSchema } from "../domain/reconstructionVerification.js"; import { enhancedInputSchemas } from "./enhancedInputs.js"; import { enhancedOutputSchemas, officialOutputSchemas, + requireOutputSchema, sessionOutputSchemas, } from "./toolOutputSchemas.js"; +import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js"; +import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; const document = z.string().optional().describe("The document name"); const address = z.string().describe("A Hopper address"); const optionalAddress = address.optional(); const procedure = z.string().describe("The procedure name or address"); -const pattern = z.string().describe("The regex pattern to search for"); +const searchPattern = z + .string() + .min(1) + .max(256) + .describe("The literal text or bounded regex pattern to search for"); const caseSensitive = z .boolean() .default(false) @@ -25,94 +44,129 @@ const pagination = { offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(500).default(100), }; +const searchInput = { + pattern: searchPattern, + mode: z.enum(["literal", "regex"]).default("literal"), + case_sensitive: caseSensitive, + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + document, +}; export interface ToolAnnotations { readonly readOnlyHint: boolean; readonly destructiveHint: boolean; - readonly idempotentHint: true; - readonly openWorldHint: false; + readonly idempotentHint: boolean; + readonly openWorldHint: boolean; +} + +export interface ToolExample { + readonly title: string; + readonly input: Readonly>; } /** Adapter family responsible for implementing a public MCP tool. */ -type ToolKind = "official-proxy" | "enhanced" | "session"; +type ToolKind = + | "official-proxy" + | "enhanced" + | "native-provider" + | "artifact-provider" + | "session"; /** Single source of truth for a public tool's name, description, and input. */ -export interface ToolContract { - readonly name: string; +export interface ToolContract { + readonly name: Name; readonly description: string; readonly kind: ToolKind; readonly inputSchema: z.ZodObject; readonly outputSchema: z.ZodObject; readonly annotations: ToolAnnotations; + readonly examples: readonly ToolExample[]; } +const exampleInputSchema = z.record(z.string(), jsonValueSchema); +const examplesFor = ( + name: string, + inputSchema: z.ZodObject, +): readonly ToolExample[] => { + const parsed = inputSchema.parse(TOOL_EXAMPLE_OVERRIDES[name] ?? {}); + return [ + { + title: `Example ${name.replaceAll("_", " ")} request`, + input: exampleInputSchema.parse(parsed), + }, + ]; +}; + const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({ readOnlyHint: - kind === "enhanced" || - name === "export_evidence_bundle" || - (!name.startsWith("set_") && - name !== "unset_bookmark" && - name !== "goto_address" && - kind !== "session"), + (kind === "enhanced" && name !== "trace_feature") || + name === "binary_session" || + name === "list_unknowns" || + name === "verify_unknown_resolution", destructiveHint: + name === "export_evidence_bundle" || name === "unset_bookmark" || name === "set_address_name" || name === "set_addresses_names" || name === "set_comment" || name === "set_inline_comment", - idempotentHint: true, - openWorldHint: false, + idempotentHint: name !== "record_unknown" && name !== "update_unknown", + openWorldHint: name === "capture_process_scenario", }); -const official = ( - name: string, +const official = ( + name: Name, description: string, inputSchema: z.ZodObject, -): ToolContract => ({ - name, - description, - kind: "official-proxy", - inputSchema, - outputSchema: requireOutputSchema(officialOutputSchemas, name), - annotations: annotations(name, "official-proxy"), -}); +): ToolContract => { + const trackedInputSchema = inputSchema.extend({ + unknown_registry_approved: z + .literal(true) + .optional() + .describe( + "Explicit approval to record typed capability unavailability as a residual unknown", + ), + }); + return { + name, + description, + kind: "official-proxy", + inputSchema: trackedInputSchema, + outputSchema: requireOutputSchema(officialOutputSchemas, name), + annotations: annotations(name, "official-proxy"), + examples: examplesFor(name, trackedInputSchema), + }; +}; -const enhanced = ( - name: string, +const enhanced = ( + name: Name, description: string, inputSchema: z.ZodObject, -): ToolContract => ({ +): ToolContract => ({ name, description, kind: "enhanced", inputSchema, outputSchema: requireOutputSchema(enhancedOutputSchemas, name), annotations: annotations(name, "enhanced"), + examples: examplesFor(name, inputSchema), }); -const session = ( - name: string, +const session = ( + name: Name, description: string, inputSchema: z.ZodObject, -): ToolContract => ({ +): ToolContract => ({ name, description, kind: "session", inputSchema, outputSchema: requireOutputSchema(sessionOutputSchemas, name), annotations: annotations(name, "session"), + examples: examplesFor(name, inputSchema), }); -const requireOutputSchema = ( - schemas: Readonly>, - name: string, -): z.ZodObject => { - const schema = schemas[name]; - if (schema === undefined) - throw new Error(`Missing output schema for ${name}`); - return schema; -}; - /** Bridge operations exposed without additional application composition. */ export const OFFICIAL_TOOL_CONTRACTS = [ official( @@ -239,13 +293,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_procedures", - "Regex-search all analyzed procedure names with optional case sensitivity. The current bridge returns an unpaginated address/name map, so constrain patterns and use list_procedures for controlled exhaustive traversal.", - z.object({ pattern, case_sensitive: caseSensitive, document }), + "Search analyzed procedure names using literal matching by default or a structurally bounded regex. Returns a deterministic, offset-paginated page; continue at next_offset while has_more is true.", + z.object(searchInput), ), official( "search_strings", - "Regex-search all analyzed strings with optional case sensitivity. The current bridge is unpaginated and evaluates Python regex, so use narrow patterns and follow matches with xrefs.", - z.object({ pattern, case_sensitive: caseSensitive, document }), + "Search analyzed strings using literal matching by default or a structurally bounded regex. Returns a deterministic, offset-paginated page with explicit value truncation; follow matches with xrefs.", + z.object(searchInput), ), official( "set_address_name", @@ -289,6 +343,9 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), ] as const satisfies readonly ToolContract[]; +/** Closed provider operation names exposed by direct analysis adapters. */ +export type OfficialToolName = (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"]; + /** Bounded workflows composed from one or more bridge operations. */ export const ENHANCED_TOOL_CONTRACTS = [ enhanced( @@ -338,7 +395,7 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "trace_feature", - "Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; it does not infer reference kinds.", + "Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; unknown_registry_approved: true records them durably without inferring reference kinds.", enhancedInputSchemas.trace_feature, ), ] as const satisfies readonly ToolContract[]; @@ -347,32 +404,35 @@ export const ENHANCED_TOOL_CONTRACTS = [ export const SESSION_TOOL_CONTRACTS = [ session( "open_binary", - "Open a local executable, application bundle, or Hopper database, replacing the active target only after validation. This launches Hopper and may show UI; call binary_overview after success.", + "Open a local executable, application bundle, archive, JavaScript, source map, plist, or Hopper database after validation. Providers start lazily: inventory_artifact does not launch Hopper; deep native operations may show Hopper UI.", z.object({ path: z.string().min(1) }), ), session( "close_binary", - "Close the active Hopper-backed target and release its provider process. The operation is idempotent; call binary_session to verify the session is closed.", + "Close the active target and every provider resource started for it. The operation is idempotent; call binary_session to verify the session is closed.", z.object({}), ), session( "binary_session", - "Report whether a target is open and, when open, its canonical path, format, and kind. Use before analysis calls or target switches; this performs no analysis.", + "Report provider identity, deterministic capability descriptors, and whether a target is open; open targets include canonical path, format, and kind. Use availability, effects, limits, and limitations before selecting analysis operations.", z.object({}), ), session( "export_evidence_bundle", - "Return the session's deterministic Evidence v2 bundle without clearing it. Records are sorted by evidence ID, and array order carries no investigative meaning.", - z.object({}), + "Return the session's deterministic Evidence v2 bundle, or atomically write it beneath an operator-approved root. Existing files require overwrite: true; records and manifests use canonical byte-stable ordering.", + z.object({ + path: z.string().min(1).optional(), + overwrite: z.boolean().default(false), + }), ), session( "import_evidence_bundle", - "Validate and atomically merge a local Evidence v2 bundle supplied as data. Semantic IDs are recomputed; tampering, unsupported versions, conflicts, and ledger overflow reject the entire import.", - z.object({ bundle: evidenceBundleSchema }), + "Read a bounded local JSON bundle beneath an operator-approved root, validate every Evidence v2 ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.", + z.object({ path: z.string().min(1) }), ), session( "capture_process_scenario", - "Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true on every call. Captures normalized terminal frames, sampled descendants, filesystem snapshots, and loopback HTTP/WebSocket replay. This launches a process and is disabled unless operator policy enables it; it is not a security sandbox.", + "Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true; unknown_registry_approved: true separately records capture residuals. Captures normalized terminal frames, descendants, filesystem snapshots, and loopback replay. Disabled unless operator policy enables it; not a security sandbox.", processScenarioSchema, ), session( @@ -383,17 +443,84 @@ export const SESSION_TOOL_CONTRACTS = [ left: processCaptureSchema, right_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u), right: processCaptureSchema, + unknown_registry_approved: z + .literal(true) + .optional() + .describe("Explicit approval to record capture disagreement durably"), }), ), + session( + "compare_artifacts", + "Compare two bounded sets of inventory_artifact Evidence pages by logical occurrence path, content identity, metadata, and graph relations. Pages must share and satisfy their graph commitment; every delta cites both sets, and gaps yield truncated or unknown, never equivalence.", + artifactComparisonInputSchema, + ), + session( + "compare_functions", + "Compare two explicit bounded sets of analyze_function Evidence pages across identity, exact provider text, calls, references, strings, and address-normalized CFG topology. Missing or provider-incompatible facets remain truncated or unknown; every conclusion cites both Evidence sets.", + functionComparisonInputSchema, + ), + session( + "compare_bundles", + "Compare two canonical Evidence v2 bundles by exact record membership, explicit one-to-one observation pairs, and complete residual-unknown revision histories. Missing bundle members describe omission only, never behavioral equivalence; output is digest-anchored and deterministically paginated.", + bundleComparisonInputSchema, + ), + session( + "find_changed_behavior", + "Aggregate validated process, artifact, and function comparison Evidence into a deterministic change report. Runtime observations remain distinct from static behavior candidates; missing or incomplete comparisons produce unresolved findings, never causal claims.", + changedBehaviorInputSchema, + ), + session( + "build_call_path", + "Build bounded shortest-first direct-callee paths from explicit analyze_function Evidence groups using exact canonical addresses. Missing dossiers, incomplete callee pages, provider mixing, and depth frontiers remain unknown; every node and edge cites source Evidence.", + callPathInputSchema, + ), + session( + "correlate_static_and_runtime", + "Evaluate explicit caller-declared hypotheses between exact static comparison findings and runtime comparison dimensions. Similar names or paths are never auto-matched, consistent cochange never proves causality, and unknown or truncated inputs remain unresolved.", + staticRuntimeCorrelationInputSchema, + ), + session( + "verify_reconstruction", + "Verify a finite typed behavioral and structural specification against a canonical Evidence bundle. Pass means every declared claim has complete comparable authority—not global source equivalence; changed claims fail and missing, limited, or unresolved evidence stays unknown.", + reconstructionVerificationInputSchema, + ), + session( + "list_unknowns", + "List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.", + z.object({ + status: z + .enum(["open", "investigating", "blocked", "contradicted", "resolved"]) + .optional(), + severity: z.enum(["low", "medium", "high", "critical"]).optional(), + domain: z.string().trim().min(1).max(100).optional(), + }), + ), + session( + "record_unknown", + "Create one deterministic residual unknown and immutable mutation evidence. Requires approved: true, validates all evidence and relationship references, and rejects duplicate stable identity.", + recordUnknownInputSchema, + ), + session( + "update_unknown", + "Append one immutable full-state revision and mutation evidence. Requires approved: true and exact expected_revision; stale concurrent writers fail instead of overwriting newer analysis.", + updateUnknownInputSchema, + ), + session( + "verify_unknown_resolution", + "Revalidate the current residual-unknown head against live bundled evidence, exact authority/confidence/environment requirements, and revision integrity. Withdrawn and out-of-scope dispositions are not truth claims.", + z.object({ unknown_id: z.string().regex(/^unk_[a-f0-9]{64}$/u) }), + ), ] as const satisfies readonly ToolContract[]; /** * Complete ordered public inventory used by registration and verification. - * Keep this collection at 50 tools unless a deliberate contract change updates + * Keep this collection at 68 tools unless a deliberate contract change updates * snapshots, package verification, and real-Hopper verification together. */ export const TOOL_CONTRACTS = [ ...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS, + ...NATIVE_TOOL_CONTRACTS, + ...ARTIFACT_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ] as const; diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index f2bd991b3..ac323bd71 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -1,23 +1,108 @@ import { z } from "zod"; import { evidenceSchema } from "../domain/evidence.js"; -import { processCaptureSchema } from "../domain/processCapture.js"; +import { + processCaptureComparisonSchema, + processCaptureSchema, +} from "../domain/processCapture.js"; import { evidenceBundleSchema } from "../domain/evidenceBundle.js"; +import { residualUnknownSchema } from "../domain/residualUnknown.js"; +import { functionDossierSchema } from "../domain/hopperValues.js"; +import { + demangleSwiftSchema, + inspectMachoSchema, + inspectPlistSchema, + inspectSignatureSchema, + listArchitecturesSchema, +} from "../domain/nativeInspection.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../domain/artifactGraph.js"; +import { artifactComparisonResultSchema } from "../domain/artifactComparison.js"; +import { functionComparisonResultSchema } from "../domain/functionComparison.js"; +import { bundleComparisonResultSchema } from "../domain/bundleComparison.js"; +import { changedBehaviorResultSchema } from "../domain/changedBehavior.js"; +import { callPathResultSchema } from "../domain/callPath.js"; +import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCorrelation.js"; +import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerification.js"; const resultOf = (schema: z.ZodType) => - evidenceSchema.omit({ result: true }).extend({ result: schema }); + evidenceSchema + .omit({ normalized_result: true }) + .extend({ normalized_result: schema }); const lifecycleResultOf = (schema: z.ZodType) => z.object({ result: schema }); -const comparisonStatus = z.enum([ - "unchanged", - "added", - "removed", - "changed", - "truncated", - "unknown", + +/** Resolve a required named output schema or reject contract drift. */ +export const requireOutputSchema = ( + schemas: Readonly>, + name: string, +): z.ZodObject => { + const schema = schemas[name]; + if (schema === undefined) + throw new Error(`Missing output schema for ${name}`); + return schema; +}; +const targetFormatSchema = z.enum([ + "hopper", + "mach-o", + "elf", + "pe", + "zip", + "ipa", + "apk", + "asar", + "dmg", + "pkg", + "plist", + "javascript", + "source-map", ]); +const targetKindSchema = z.enum([ + "executable", + "database", + "archive", + "artifact", +]); +const providerCapability = z.object({ + operation: z.string(), + available: z.boolean(), + reason: z.string().nullable(), + input_contract_version: z.number().int().min(1), + output_contract_version: z.number().int().min(1), + pagination: z.enum(["none", "offset", "cursor"]), + exhaustive: z.boolean(), + effects: z.object({ + mutates_artifact: z.boolean(), + launches_process: z.boolean(), + may_show_ui: z.boolean(), + may_access_network: z.boolean(), + may_write_filesystem: z.boolean(), + changes_permissions: z.boolean(), + requires_root: z.boolean(), + }), + limits: z.object({ + max_results: z.number().int().min(0).nullable(), + max_payload_bytes: z.number().int().min(0).nullable(), + timeout_ms: z.number().int().min(0).nullable(), + }), + limitations: z.array(z.string()), +}); +const sessionProvider = z.object({ + provider: z.object({ + id: z.string(), + name: z.string(), + version: z.string().nullable(), + }), + capabilities: z.array(providerCapability), +}); const nullableText = z.string().nullable(); const addressList = z.array(z.string()); const addressedEntry = z.object({ address: z.string(), name: z.string() }); const procedureIdentity = z.object({ address: z.string(), name: z.string() }); +const localVariable = z.object({ + description: z.string(), + provenance: z.literal("hopper-public-python-api"), +}); const containingProcedureResolution = z.discriminatedUnion("found", [ z.object({ query_address: z.string(), @@ -51,20 +136,27 @@ const pageOutput = z.object({ next_offset: z.number().int().min(0).nullable(), has_more: z.boolean(), }); -const segmentOutput = resultOf( - z.array( +const searchPageOutput = pageOutput.extend({ + items: z.array( z.object({ - name: z.string(), - start: z.string(), - end: z.string(), - writable: z.boolean().nullable(), - executable: z.boolean().nullable(), - permissions: unavailable, - sections: z.array( - z.object({ name: z.string(), start: z.string(), end: z.string() }), - ), + address: z.string(), + value: z.string(), + value_truncated: z.boolean(), }), ), +}); +const memoryRegionOutput = z.object({ + name: z.string(), + start: z.string(), + end: z.string(), + readable: z.boolean().nullable(), + writable: z.boolean().nullable(), + executable: z.boolean().nullable(), + permissions: unavailable, + provenance: z.literal("hopper-public-python-api"), +}); +const segmentOutput = resultOf( + z.array(memoryRegionOutput.extend({ sections: z.array(memoryRegionOutput) })), ); const procedureInfoOutput = resultOf( z.object({ @@ -73,7 +165,7 @@ const procedureInfoOutput = resultOf( basicblock_count: z.number().int().min(0), length: z.number().min(0), signature: nullableText, - locals: z.array(z.json()), + locals: z.array(localVariable), }), ); const symbolDiscoveryOutput = (property: "classes" | "protocols") => @@ -87,60 +179,7 @@ const graphNode = z.union([ z.object({ address: z.string(), calls: z.array(z.string()) }), z.object({ address: z.string(), error: z.string() }), ]); -const referenceEdge = z.object({ - source_address: z.string(), - target_address: z.string(), - source_procedure: procedureIdentity.nullable(), - target_procedure: procedureIdentity.nullable(), - kind: unavailable, -}); -const referencedValue = z.object({ - address: z.string(), - value: z.string(), - source_address: z.string(), -}); -const functionDossierOutput = resultOf( - z.object({ - procedure: z.object({ - address: z.string(), - name: z.string(), - signature: nullableText, - locals: z.array(z.json()), - }), - pseudocode: z.object({ - text: z.string(), - total_chars: z.number().int().min(0), - returned_chars: z.number().int().min(0), - truncated: z.boolean(), - next_offset: z.number().int().min(0).nullable(), - }), - assembly: bounded(z.string()), - comments: bounded( - z.object({ - address: z.string(), - kind: z.enum(["comment", "inline"]), - text: z.string(), - }), - ), - callers: bounded(procedureIdentity), - callees: bounded(procedureIdentity), - incoming_references: bounded(referenceEdge), - outgoing_references: bounded(referenceEdge), - referenced_strings: bounded(referencedValue), - referenced_names: bounded(referencedValue), - basic_blocks: bounded( - z.object({ - start: z.string(), - end: z.string(), - successors: z.array(z.string()), - }), - ), - instruction_scan: z.object({ - scanned: z.number().int().min(0), - truncated: z.boolean(), - }), - }), -); +const functionDossierOutput = resultOf(functionDossierSchema); /** Exact structured-content schemas for the direct Hopper operations. */ export const officialOutputSchemas: Readonly> = { @@ -183,8 +222,8 @@ export const officialOutputSchemas: Readonly> = { ), procedure_pseudo_code: resultOf(nullableText), resolve_containing_procedure: resultOf(containingProcedureResolution), - search_procedures: resultOf(z.record(z.string(), z.string())), - search_strings: resultOf(z.record(z.string(), z.string())), + search_procedures: resultOf(searchPageOutput), + search_strings: resultOf(searchPageOutput), set_address_name: resultOf(z.boolean()), set_addresses_names: resultOf(z.record(z.string(), z.boolean())), set_bookmark: resultOf(z.boolean()), @@ -269,13 +308,28 @@ export const enhancedOutputSchemas: Readonly> = { ), }; +/** Exact Evidence v2 schemas for provider-neutral native inspection. */ +export const nativeOutputSchemas: Readonly> = { + inspect_macho: resultOf(inspectMachoSchema), + inspect_signature: resultOf(inspectSignatureSchema), + inspect_plist: resultOf(inspectPlistSchema), + list_architectures: resultOf(listArchitecturesSchema), + demangle_swift: resultOf(demangleSwiftSchema), +}; + +/** Exact Evidence v2 schemas for provider-neutral artifact graph operations. */ +export const artifactOutputSchemas: Readonly> = { + inventory_artifact: resultOf(artifactInventoryResultSchema), + extract_artifact: resultOf(artifactExtractionResultSchema), +}; + /** Exact structured-content schemas for target lifecycle operations. */ export const sessionOutputSchemas: Readonly> = { open_binary: lifecycleResultOf( z.object({ path: z.string(), - format: z.enum(["hopper", "mach-o", "elf", "pe"]), - kind: z.enum(["executable", "database"]), + format: targetFormatSchema, + kind: targetKindSchema, loaderArgs: z.array(z.string()), sha256: z.string().regex(/^[a-f0-9]{64}$/u), architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(), @@ -284,18 +338,27 @@ export const sessionOutputSchemas: Readonly> = { close_binary: lifecycleResultOf(z.null()), binary_session: lifecycleResultOf( z.union([ - z.object({ open: z.literal(false) }), - z.object({ + sessionProvider.extend({ open: z.literal(false) }), + sessionProvider.extend({ open: z.literal(true), path: z.string(), - format: z.enum(["hopper", "mach-o", "elf", "pe"]), - kind: z.enum(["executable", "database"]), + format: targetFormatSchema, + kind: targetKindSchema, sha256: z.string().regex(/^[a-f0-9]{64}$/u), architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(), }), ]), ), - export_evidence_bundle: lifecycleResultOf(evidenceBundleSchema), + export_evidence_bundle: lifecycleResultOf( + z.union([ + evidenceBundleSchema, + z.object({ + path: z.string(), + bytes: z.number().int().min(0), + records: z.number().int().min(0), + }), + ]), + ), import_evidence_bundle: lifecycleResultOf( z.object({ imported: z.number().int().min(0), @@ -304,20 +367,57 @@ export const sessionOutputSchemas: Readonly> = { ), capture_process_scenario: lifecycleResultOf( evidenceSchema - .omit({ result: true }) - .extend({ result: processCaptureSchema }), + .omit({ normalized_result: true }) + .extend({ normalized_result: processCaptureSchema }), ), compare_process_captures: lifecycleResultOf( - evidenceSchema.omit({ result: true }).extend({ - result: z.object({ - status: comparisonStatus, - terminal: comparisonStatus, - exit: comparisonStatus, - filesystem: comparisonStatus, - protocol: comparisonStatus, - process: comparisonStatus, - limitations: z.array(z.string()), - }), + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: processCaptureComparisonSchema, + }), + ), + compare_artifacts: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: artifactComparisonResultSchema, + }), + ), + compare_functions: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: functionComparisonResultSchema, + }), + ), + compare_bundles: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: bundleComparisonResultSchema, + }), + ), + find_changed_behavior: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: changedBehaviorResultSchema, + }), + ), + build_call_path: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: callPathResultSchema, + }), + ), + correlate_static_and_runtime: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: staticRuntimeCorrelationResultSchema, + }), + ), + verify_reconstruction: lifecycleResultOf( + evidenceSchema.omit({ normalized_result: true }).extend({ + normalized_result: reconstructionVerificationResultSchema, + }), + ), + list_unknowns: lifecycleResultOf(z.array(residualUnknownSchema)), + record_unknown: lifecycleResultOf(residualUnknownSchema), + update_unknown: lifecycleResultOf(residualUnknownSchema), + verify_unknown_resolution: lifecycleResultOf( + z.object({ + valid: z.boolean(), + truthVerified: z.boolean(), + unknown: residualUnknownSchema, }), ), }; diff --git a/src/contracts/unknownContractExamples.ts b/src/contracts/unknownContractExamples.ts new file mode 100644 index 000000000..b854df325 --- /dev/null +++ b/src/contracts/unknownContractExamples.ts @@ -0,0 +1,35 @@ +import type { JsonValue } from "../domain/jsonValue.js"; + +/** Exact example inputs for residual-unknown lifecycle contracts. */ +export const UNKNOWN_CONTRACT_EXAMPLES = { + list_unknowns: {}, + record_unknown: { + approved: true, + question: "Does this branch require an unavailable external service?", + severity: "medium", + domain: "protocol", + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { operation: "capture_process_scenario", rationale: "Replay branch." }, + ], + relationships: [], + }, + update_unknown: { + approved: true, + unknown_id: `unk_${"0".repeat(64)}`, + expected_revision: 1, + status: "investigating", + severity: "medium", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + resolution: null, + }, + verify_unknown_resolution: { unknown_id: `unk_${"0".repeat(64)}` }, +} satisfies Readonly>>>; diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts new file mode 100644 index 000000000..0df4803d5 --- /dev/null +++ b/src/domain/artifactComparison.ts @@ -0,0 +1,390 @@ +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { + type ArtifactInventoryResult, + type ArtifactNode, + type ArtifactOccurrence, +} from "./artifactGraph.js"; +import { evidenceSchema } from "./evidence.js"; +import { + parseArtifactInventoryEvidence, + type InventorySet, +} from "./artifactInventoryEvidence.js"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const comparisonStatusSchema = z.enum([ + "unchanged", + "added", + "removed", + "changed", + "truncated", + "unknown", +]); +const changeKindSchema = z.enum(["added", "removed", "changed", "unknown"]); +const comparisonDimensionSchema = z.enum([ + "content", + "kind", + "format", + "size", + "executable", + "relations", + "metadata", + "availability", +]); + +/** Strict Evidence-backed input for deterministic artifact comparison. */ +export const artifactComparisonInputSchema = z.object({ + left: z.union([evidenceSchema, z.array(evidenceSchema).min(1).max(100)]), + right: z.union([evidenceSchema, z.array(evidenceSchema).min(1).max(100)]), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), + unknown_registry_approved: z + .literal(true) + .optional() + .describe("Explicit approval to record incomplete or differing evidence"), +}); + +/** One path-classified artifact change with citations to both observations. */ +const artifactChangeSchema = z.object({ + classification: changeKindSchema, + logical_path: z.string().min(1).max(4_096), + dimensions: z.array(comparisonDimensionSchema).min(1).max(8), + left_occurrence_id: z + .string() + .regex(/^occ_[a-f0-9]{64}$/u) + .nullable(), + right_occurrence_id: z + .string() + .regex(/^occ_[a-f0-9]{64}$/u) + .nullable(), + left_artifact_id: z + .string() + .regex(/^art_[a-f0-9]{64}$/u) + .nullable(), + right_artifact_id: z + .string() + .regex(/^art_[a-f0-9]{64}$/u) + .nullable(), + evidence_links: z.array(evidenceIdSchema).min(2).max(200), +}); + +/** Bounded deterministic artifact comparison with an independently paged delta. */ +export const artifactComparisonResultSchema = z.object({ + status: comparisonStatusSchema, + left_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u), + right_manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u), + summary: z.object({ + unchanged: z.number().int().min(0), + added: z.number().int().min(0), + removed: z.number().int().min(0), + changed: z.number().int().min(0), + unknown: z.number().int().min(0), + }), + changes: z.object({ + items: z.array(artifactChangeSchema).max(500), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(500), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + limitations: z.array(z.string()), +}); + +export type ArtifactComparisonResult = z.infer< + typeof artifactComparisonResultSchema +>; +type ArtifactChange = z.infer; + +/** Compare complete inventory pages without treating missing evidence as equality. */ +export const compareArtifacts = ( + leftEvidence: unknown, + rightEvidence: unknown, + offset: number, + limit: number, +): ArtifactComparisonResult => { + const left = parseArtifactInventoryEvidence(leftEvidence); + const right = parseArtifactInventoryEvidence(rightEvidence); + const leftComplete = left.inventory.complete; + const rightComplete = right.inventory.complete; + const leftCovered = leftComplete && left.inventory.limitations.length === 0; + const rightCovered = + rightComplete && right.inventory.limitations.length === 0; + const limitations = [ + ...left.inventory.limitations.map((item) => `Left: ${item}`), + ...right.inventory.limitations.map((item) => `Right: ${item}`), + ...(leftComplete ? [] : ["Left artifact inventory is incomplete."]), + ...(rightComplete ? [] : ["Right artifact inventory is incomplete."]), + ]; + const changes = compareOccurrences( + left.inventory, + right.inventory, + [ + ...left.evidence.map(({ evidence_id: id }) => id), + ...right.evidence.map(({ evidence_id: id }) => id), + ], + leftCovered && rightCovered, + ); + const summary = summarize( + left.inventory, + right.inventory, + changes, + leftCovered && rightCovered, + ); + const page = changes.slice(offset, offset + limit); + return artifactComparisonResultSchema.parse({ + status: + !leftComplete || !rightComplete + ? "truncated" + : !leftCovered || !rightCovered + ? "unknown" + : changes.some(({ classification }) => classification === "unknown") + ? "unknown" + : changes.length === 0 + ? "unchanged" + : "changed", + left_manifest_id: left.inventory.manifest.manifest_id, + right_manifest_id: right.inventory.manifest.manifest_id, + summary, + changes: { + items: page, + offset, + limit, + total: changes.length, + next_offset: + offset + page.length < changes.length ? offset + page.length : null, + }, + limitations: [...new Set(limitations)].sort((a, b) => a.localeCompare(b)), + }); +}; + +const compareOccurrences = ( + left: InventorySet, + right: InventorySet, + evidenceLinks: readonly string[], + complete: boolean, +): ArtifactChange[] => { + const leftByPath = new Map( + left.occurrences.map((item) => [item.logical_path, item]), + ); + const rightByPath = new Map( + right.occurrences.map((item) => [item.logical_path, item]), + ); + const leftNodes = new Map(left.nodes.map((item) => [item.artifact_id, item])); + const rightNodes = new Map( + right.nodes.map((item) => [item.artifact_id, item]), + ); + const leftRelations = relationsByPath(left); + const rightRelations = relationsByPath(right); + const paths = [ + ...new Set([...leftByPath.keys(), ...rightByPath.keys()]), + ].sort((a, b) => a.localeCompare(b, "en")); + const output: ArtifactChange[] = []; + for (const path of paths) { + const leftOccurrence = leftByPath.get(path); + const rightOccurrence = rightByPath.get(path); + const change = classifyPath({ + path, + leftOccurrence, + rightOccurrence, + leftNode: nodeFor(leftOccurrence, leftNodes), + rightNode: nodeFor(rightOccurrence, rightNodes), + leftRelations: leftRelations.get(path) ?? [], + rightRelations: rightRelations.get(path) ?? [], + complete, + evidenceLinks, + }); + if (change !== null) output.push(change); + } + return output; +}; + +const classifyPath = (input: { + readonly path: string; + readonly leftOccurrence: ArtifactOccurrence | undefined; + readonly rightOccurrence: ArtifactOccurrence | undefined; + readonly leftNode: ArtifactNode | undefined; + readonly rightNode: ArtifactNode | undefined; + readonly leftRelations: readonly RelationProjection[]; + readonly rightRelations: readonly RelationProjection[]; + readonly complete: boolean; + readonly evidenceLinks: readonly string[]; +}): ArtifactChange | null => { + const base = { + logical_path: input.path, + left_occurrence_id: input.leftOccurrence?.occurrence_id ?? null, + right_occurrence_id: input.rightOccurrence?.occurrence_id ?? null, + left_artifact_id: input.leftOccurrence?.artifact_id ?? null, + right_artifact_id: input.rightOccurrence?.artifact_id ?? null, + evidence_links: [...input.evidenceLinks], + }; + if (input.leftOccurrence === undefined) + return { + ...base, + classification: input.complete ? "added" : "unknown", + dimensions: [input.complete ? "content" : "availability"], + }; + if (input.rightOccurrence === undefined) + return { + ...base, + classification: input.complete ? "removed" : "unknown", + dimensions: [input.complete ? "content" : "availability"], + }; + if ( + input.leftOccurrence.hash_status !== "verified" || + input.rightOccurrence.hash_status !== "verified" || + input.leftNode === undefined || + input.rightNode === undefined + ) + return { ...base, classification: "unknown", dimensions: ["availability"] }; + const dimensions = changedDimensions(input); + return dimensions.length === 0 + ? null + : { ...base, classification: "changed", dimensions }; +}; + +const changedDimensions = (input: { + readonly leftNode: ArtifactNode | undefined; + readonly rightNode: ArtifactNode | undefined; + readonly leftOccurrence: ArtifactOccurrence | undefined; + readonly rightOccurrence: ArtifactOccurrence | undefined; + readonly leftRelations: readonly RelationProjection[]; + readonly rightRelations: readonly RelationProjection[]; +}): z.infer[] => { + const left = input.leftNode; + const right = input.rightNode; + if (left === undefined || right === undefined) return ["availability"]; + const checks: readonly [ + z.infer, + boolean, + ][] = [ + ["content", left.sha256 !== right.sha256], + ["kind", left.kind !== right.kind], + ["format", left.format !== right.format], + ["size", left.size !== right.size], + [ + "executable", + input.leftOccurrence?.executable !== input.rightOccurrence?.executable, + ], + [ + "relations", + JSON.stringify(input.leftRelations) !== + JSON.stringify(input.rightRelations), + ], + ["metadata", metadataChanged(input, left, right)], + ]; + return checks + .filter(([, changed]) => changed) + .map(([dimension]) => dimension); +}; + +const metadataChanged = ( + input: { + readonly leftOccurrence: ArtifactOccurrence | undefined; + readonly rightOccurrence: ArtifactOccurrence | undefined; + }, + left: ArtifactNode, + right: ArtifactNode, +): boolean => + left.media_type !== right.media_type || + left.architecture !== right.architecture || + left.executable !== right.executable || + left.content_state !== right.content_state || + input.leftOccurrence?.entry_kind !== input.rightOccurrence?.entry_kind || + input.leftOccurrence?.encrypted !== input.rightOccurrence?.encrypted || + JSON.stringify(left.limitations) !== JSON.stringify(right.limitations) || + JSON.stringify(input.leftOccurrence?.limitations) !== + JSON.stringify(input.rightOccurrence?.limitations); + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError( + "Artifact comparison could not canonicalize graph data", + ); + return encoded; +}; + +const nodeFor = ( + occurrence: ArtifactOccurrence | undefined, + nodes: ReadonlyMap, +): ArtifactNode | undefined => + occurrence?.artifact_id === null || occurrence?.artifact_id === undefined + ? undefined + : nodes.get(occurrence.artifact_id); + +const relationsByPath = ( + inventory: InventorySet, +): ReadonlyMap => { + const output = new Map(); + const occurrences = new Map( + inventory.occurrences.map((item) => [item.occurrence_id, item]), + ); + for (const edge of inventory.edges) { + const path = edge.logical_path ?? "."; + const values = output.get(path); + const occurrence = occurrences.get(edge.occurrence_id); + const projection: RelationProjection = { + parent_logical_path: + occurrence?.parent_occurrence_id === null || + occurrence?.parent_occurrence_id === undefined + ? null + : (occurrences.get(occurrence.parent_occurrence_id)?.logical_path ?? + null), + child_artifact_id: edge.child_artifact_id, + relation: edge.relation, + logical_path: edge.logical_path, + producer: edge.producer, + }; + if (values === undefined) output.set(path, [projection]); + else values.push(projection); + } + for (const values of output.values()) + values.sort((left, right) => + canonicalJson(left).localeCompare(canonicalJson(right)), + ); + return output; +}; + +interface RelationProjection { + /** Logical parent is stable across graph-root ID changes. */ + readonly parent_logical_path: string | null; + readonly child_artifact_id: string; + readonly relation: ArtifactInventoryResult["edges"]["items"][number]["relation"]; + readonly logical_path: string | null; + /** Producer is semantic provenance; edge ordinal is presentation order. */ + readonly producer: ArtifactInventoryResult["edges"]["items"][number]["producer"]; +} + +const summarize = ( + left: InventorySet, + right: InventorySet, + changes: readonly ArtifactChange[], + covered: boolean, +) => { + const counts = { added: 0, removed: 0, changed: 0, unknown: 0 }; + for (const change of changes) counts[change.classification] += 1; + return { + unchanged: covered + ? Math.max( + 0, + Math.min(left.occurrences.length, right.occurrences.length) - + counts.changed - + counts.unknown, + ) + : 0, + ...counts, + unknown: covered + ? counts.unknown + : Math.max( + counts.unknown, + Math.max( + left.manifest.occurrence_count, + right.manifest.occurrence_count, + ) - + counts.added - + counts.removed - + counts.changed, + ), + }; +}; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts new file mode 100644 index 000000000..10accebb8 --- /dev/null +++ b/src/domain/artifactGraph.ts @@ -0,0 +1,239 @@ +import { z } from "zod"; + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); +const artifactIdSchema = z.string().regex(/^art_[a-f0-9]{64}$/u); +const occurrenceIdSchema = z.string().regex(/^occ_[a-f0-9]{64}$/u); +const edgeIdSchema = z.string().regex(/^edge_[a-f0-9]{64}$/u); +const manifestIdSchema = z.string().regex(/^agm_[a-f0-9]{64}$/u); +const extractionIdSchema = z.string().regex(/^aex_[a-f0-9]{64}$/u); +const boundedRelativePathSchema = z + .string() + .min(1) + .max(4_096) + .refine( + (path) => + !path.startsWith("/") && + !path.includes("\\") && + path.split("/").every((part) => part !== "" && part !== ".."), + "Expected a normalized relative POSIX path without traversal", + ); + +/** Closed artifact categories used to traverse application containers. */ +const artifactKindSchema = z.enum([ + "container", + "executable", + "dynamic-library", + "framework", + "native-addon", + "javascript", + "source-map", + "plist", + "entitlements", + "resource", + "universal-slice", + "package-metadata", + "unknown", +]); + +/** Recognized storage or application formats; unknown bytes remain explicit. */ +const artifactFormatSchema = z.enum([ + "dmg", + "zip", + "pkg", + "asar", + "javascript-bundle", + "source-map", + "mach-o-universal", + "mach-o", + "elf", + "pe", + "hopper", + "ipa", + "apk", + "plist", + "entitlements", + "directory", + "file", + "unknown", +]); + +/** Exact bounded policy applied while discovering or extracting artifacts. */ +const artifactTraversalLimitsSchema = z.object({ + max_entries: z.number().int().min(1).max(1_000_000), + max_total_bytes: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), + max_entry_bytes: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER), + max_compression_ratio: z.number().min(1).max(100_000), + max_depth: z.number().int().min(0).max(100), + max_path_bytes: z.number().int().min(1).max(65_535), +}); + +/** File byte interval for an artifact derived without writing it to disk. */ +const artifactByteRangeSchema = z.object({ + offset: z.number().int().min(0), + length: z.number().int().min(0), +}); + +/** Bounded external command provenance, including observable side effects. */ +const artifactCommandSchema = z.object({ + tool: z.string().min(1), + arguments: z.array(z.string().max(4_096)).max(1_024), + tool_version: z.string().nullable(), + executable_sha256: sha256Schema.nullable(), + exit_code: z.number().int().nullable(), + effects: z.array(z.enum(["read", "write", "mount"])).max(3), +}); + +/** Parsed bounded artifact-producing command provenance. */ +export type ArtifactCommand = z.infer; + +/** One content-addressed application artifact in deterministic manifest order. */ +const artifactNodeSchema = z.object({ + artifact_id: artifactIdSchema, + kind: artifactKindSchema, + format: artifactFormatSchema, + sha256: sha256Schema, + size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + media_type: z.string().min(1).nullable(), + architecture: z.string().min(1).nullable(), + executable: z.boolean(), + content_state: z.enum(["materialized", "embedded", "virtual"]), + limitations: z.array(z.string()), +}); + +/** One path or virtual location where an artifact occurs in its parent. */ +const artifactOccurrenceSchema = z.object({ + occurrence_id: occurrenceIdSchema, + artifact_id: artifactIdSchema.nullable(), + parent_occurrence_id: occurrenceIdSchema.nullable(), + logical_path: boundedRelativePathSchema, + entry_kind: z.enum(["file", "directory", "symlink", "slice"]), + declared_size: z + .number() + .int() + .min(0) + .max(Number.MAX_SAFE_INTEGER) + .nullable(), + compressed_size: z + .number() + .int() + .min(0) + .max(Number.MAX_SAFE_INTEGER) + .nullable(), + executable: z.boolean(), + encrypted: z.boolean(), + hash_status: z.enum(["verified", "not-hashed-limit", "unavailable"]), + source_location: artifactByteRangeSchema.nullable(), + limitations: z.array(z.string()), +}); + +/** Parent-child derivation relation between two content-addressed artifacts. */ +const artifactEdgeSchema = z.object({ + edge_id: edgeIdSchema, + ordinal: z.number().int().min(0), + parent_artifact_id: artifactIdSchema, + child_artifact_id: artifactIdSchema, + relation: z.enum([ + "contains", + "extracts", + "slice-of", + "embeds", + "loads", + "maps-source", + "derived-from", + ]), + occurrence_id: occurrenceIdSchema, + logical_path: boundedRelativePathSchema.nullable(), + producer: artifactCommandSchema.nullable(), +}); + +/** Stable cryptographic commitment and counts for a complete artifact graph. */ +const artifactGraphManifestSchema = z.object({ + schema_version: z.literal(1), + manifest_id: manifestIdSchema, + root_artifact_id: artifactIdSchema, + root_sha256: sha256Schema, + root_format: artifactFormatSchema, + graph_sha256: sha256Schema, + node_count: z.number().int().min(1), + occurrence_count: z.number().int().min(1), + edge_count: z.number().int().min(0), +}); + +/** Independently bounded offset page used for deterministic graph traversal. */ +const artifactPageSchema = (item: Item) => + z.object({ + items: z.array(item).max(500), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(500), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }); + +/** Inventory observation containing stable manifest identity and bounded pages. */ +export const artifactInventoryResultSchema = z.object({ + manifest: artifactGraphManifestSchema, + nodes: artifactPageSchema(artifactNodeSchema), + occurrences: artifactPageSchema(artifactOccurrenceSchema), + edges: artifactPageSchema(artifactEdgeSchema), + limits: artifactTraversalLimitsSchema, + provenance: z.array(artifactCommandSchema).max(256), + limitations: z.array(z.string()), +}); + +/** One safely materialized artifact beneath caller-approved output root. */ +const extractedArtifactSchema = z.object({ + artifact_id: artifactIdSchema, + relative_path: boundedRelativePathSchema, + sha256: sha256Schema, + bytes_written: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + created: z.boolean(), +}); + +/** Path-independent commitment to one selected extraction transaction. */ +const artifactExtractionManifestSchema = z.object({ + schema_version: z.literal(1), + extraction_id: extractionIdSchema, + source_manifest_id: manifestIdSchema, + selected_occurrence_ids: z.array(occurrenceIdSchema).min(1).max(500), + files_sha256: sha256Schema, + output_root_alias: z.literal("$OUTPUT_ROOT"), +}); + +/** Safe extraction observation with containment and cleanup verification. */ +export const artifactExtractionResultSchema = z.object({ + manifest: artifactGraphManifestSchema, + extraction_manifest: artifactExtractionManifestSchema, + output_root: z.string().min(1), + artifacts: artifactPageSchema(extractedArtifactSchema), + containment_verified: z.boolean(), + cleanup: z.object({ + attempted: z.boolean(), + verified: z.boolean(), + residual_paths: z.array(boundedRelativePathSchema).max(500), + }), + limits: artifactTraversalLimitsSchema, + provenance: z.array(artifactCommandSchema).max(256), + limitations: z.array(z.string()), +}); + +/** Parsed artifact graph node. */ +export type ArtifactNode = z.infer; + +/** Parsed artifact derivation edge. */ +export type ArtifactEdge = z.infer; + +/** Parsed artifact occurrence. */ +export type ArtifactOccurrence = z.infer; + +/** Parsed deterministic artifact graph manifest. */ +export type ArtifactGraphManifest = z.infer; + +/** Parsed paginated inventory result. */ +export type ArtifactInventoryResult = z.infer< + typeof artifactInventoryResultSchema +>; + +/** Parsed paginated extraction result. */ +export type ArtifactExtractionResult = z.infer< + typeof artifactExtractionResultSchema +>; diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts new file mode 100644 index 000000000..652efff92 --- /dev/null +++ b/src/domain/artifactInventoryEvidence.ts @@ -0,0 +1,268 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, + type ArtifactNode, + type ArtifactOccurrence, +} from "./artifactGraph.js"; +import { parseEvidence, type Evidence } from "./evidence.js"; + +/** Complete or partially assembled inventory used by comparison workflows. */ +export interface InventorySet { + readonly manifest: ArtifactInventoryResult["manifest"]; + readonly nodes: readonly ArtifactNode[]; + readonly occurrences: readonly ArtifactOccurrence[]; + readonly edges: readonly ArtifactInventoryResult["edges"]["items"][number][]; + readonly limitations: readonly string[]; + readonly complete: boolean; +} + +interface ParsedInventorySet { + readonly evidence: readonly Evidence[]; + readonly inventory: InventorySet; +} + +/** Parse, merge, and verify a bounded set of inventory Evidence pages. */ +export const parseArtifactInventoryEvidence = ( + input: unknown, +): ParsedInventorySet => + assembleInventorySet( + (Array.isArray(input) ? input : [input]).map((value) => { + const evidence = parseEvidence(value); + if (evidence.operation !== "inventory_artifact") + throw new TypeError( + "Artifact comparison requires inventory_artifact Evidence", + ); + const inventory = artifactInventoryResultSchema.parse( + evidence.normalized_result, + ); + if ( + evidence.subject === null || + evidence.subject.digest.sha256 !== inventory.manifest.root_sha256 || + evidence.subject.format !== inventory.manifest.root_format + ) + throw new TypeError( + "Artifact inventory Evidence subject does not match its root digest", + ); + validateInventoryPage(inventory); + return { evidence, inventory }; + }), + ); + +const validateInventoryPage = (inventory: ArtifactInventoryResult): void => { + assertUnique( + inventory.nodes.items.map(({ artifact_id: id }) => id), + "node ID", + ); + assertUnique( + inventory.occurrences.items.map(({ occurrence_id: id }) => id), + "occurrence ID", + ); + assertUnique( + inventory.occurrences.items.map(({ logical_path: path }) => path), + "logical path", + ); + assertUnique( + inventory.edges.items.map(({ edge_id: id }) => id), + "edge ID", + ); + for (const node of inventory.nodes.items) + if ( + node.artifact_id !== + `art_${digestCanonical({ schema_version: 1, sha256: node.sha256 })}` + ) + throw new TypeError("Artifact node ID is not content-addressed"); +}; + +const assembleInventorySet = ( + pages: readonly { + readonly evidence: Evidence; + readonly inventory: ArtifactInventoryResult; + }[], +): ParsedInventorySet => { + if (pages.length === 0 || pages.length > 100) + throw new TypeError("Artifact comparison requires 1 to 100 Evidence pages"); + const first = pages[0]; + if (first === undefined) + throw new TypeError("Artifact comparison requires Evidence pages"); + const manifestJson = canonicalJson(first.inventory.manifest); + const nodes = new Map(); + const occurrences = new Map(); + const paths = new Map(); + const edges = new Map< + string, + ArtifactInventoryResult["edges"]["items"][number] + >(); + for (const page of pages) { + if (canonicalJson(page.inventory.manifest) !== manifestJson) + throw new TypeError("Artifact inventory pages do not share one manifest"); + for (const node of page.inventory.nodes.items) + mergeExact(nodes, node.artifact_id, node, "node"); + for (const occurrence of page.inventory.occurrences.items) { + mergeExact( + occurrences, + occurrence.occurrence_id, + occurrence, + "occurrence", + ); + mergeExact(paths, occurrence.logical_path, occurrence, "logical path"); + } + for (const edge of page.inventory.edges.items) + mergeExact(edges, edge.edge_id, edge, "edge"); + } + const inventory = orderedInventory(first.inventory, pages, { + nodes, + occurrences, + edges, + }); + if (inventory.complete) validateCompleteInventory(inventory); + return { evidence: pages.map(({ evidence }) => evidence), inventory }; +}; + +const orderedInventory = ( + first: ArtifactInventoryResult, + pages: readonly { readonly inventory: ArtifactInventoryResult }[], + values: { + readonly nodes: ReadonlyMap; + readonly occurrences: ReadonlyMap; + readonly edges: ReadonlyMap< + string, + ArtifactInventoryResult["edges"]["items"][number] + >; + }, +): InventorySet => { + const nodes = [...values.nodes.values()].sort((left, right) => + left.artifact_id.localeCompare(right.artifact_id), + ); + const occurrences = [...values.occurrences.values()].sort((left, right) => + left.logical_path.localeCompare(right.logical_path, "en"), + ); + const edges = [...values.edges.values()].sort((left, right) => + left.edge_id.localeCompare(right.edge_id), + ); + return { + manifest: first.manifest, + nodes, + occurrences, + edges, + limitations: [ + ...new Set(pages.flatMap(({ inventory }) => inventory.limitations)), + ].sort((left, right) => left.localeCompare(right)), + complete: + nodes.length === first.manifest.node_count && + occurrences.length === first.manifest.occurrence_count && + edges.length === first.manifest.edge_count, + }; +}; + +const validateCompleteInventory = (inventory: InventorySet): void => { + const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id)); + const occurrenceIds = new Set( + inventory.occurrences.map(({ occurrence_id: id }) => id), + ); + const rootNode = inventory.nodes.find( + ({ artifact_id: id }) => id === inventory.manifest.root_artifact_id, + ); + if (rootNode?.sha256 !== inventory.manifest.root_sha256) + throw new TypeError( + "Artifact root node does not match its manifest digest", + ); + for (const occurrence of inventory.occurrences) + validateOccurrence(occurrence, inventory, nodeIds, occurrenceIds); + for (const edge of inventory.edges) + validateEdge(edge, nodeIds, occurrenceIds); + const graphSha256 = digestCanonical({ + nodes: inventory.nodes, + occurrences: inventory.occurrences, + edges: inventory.edges, + }); + if (graphSha256 !== inventory.manifest.graph_sha256) + throw new TypeError("Artifact graph commitment does not match its members"); + const manifestId = `agm_${digestCanonical({ + schema_version: 1, + root_artifact_id: inventory.manifest.root_artifact_id, + graph_sha256: graphSha256, + })}`; + if (manifestId !== inventory.manifest.manifest_id) + throw new TypeError("Artifact manifest ID does not match its commitment"); +}; + +const validateOccurrence = ( + occurrence: ArtifactOccurrence, + inventory: InventorySet, + nodeIds: ReadonlySet, + occurrenceIds: ReadonlySet, +): void => { + if (occurrence.artifact_id !== null && !nodeIds.has(occurrence.artifact_id)) + throw new TypeError("Artifact occurrence references a missing node"); + if ( + occurrence.parent_occurrence_id !== null && + !occurrenceIds.has(occurrence.parent_occurrence_id) + ) + throw new TypeError("Artifact occurrence references a missing parent"); + const expectedId = + occurrence.logical_path === "." + ? `occ_${digestCanonical({ root: inventory.manifest.root_artifact_id })}` + : `occ_${digestCanonical({ + root_artifact_id: inventory.manifest.root_artifact_id, + logical_path: occurrence.logical_path, + entry_kind: occurrence.entry_kind, + })}`; + if (occurrence.occurrence_id !== expectedId) + throw new TypeError("Artifact occurrence ID does not match its identity"); +}; + +const validateEdge = ( + edge: ArtifactInventoryResult["edges"]["items"][number], + nodeIds: ReadonlySet, + occurrenceIds: ReadonlySet, +): void => { + if ( + !nodeIds.has(edge.parent_artifact_id) || + !nodeIds.has(edge.child_artifact_id) || + !occurrenceIds.has(edge.occurrence_id) + ) + throw new TypeError("Artifact edge references a missing graph member"); + const semantic = { + parent_artifact_id: edge.parent_artifact_id, + child_artifact_id: edge.child_artifact_id, + relation: edge.relation, + occurrence_id: edge.occurrence_id, + logical_path: edge.logical_path, + }; + if (edge.edge_id !== `edge_${digestCanonical(semantic)}`) + throw new TypeError("Artifact edge ID does not match its identity"); +}; + +const mergeExact = ( + output: Map, + key: string, + value: Value, + label: string, +): void => { + const previous = output.get(key); + if ( + previous !== undefined && + canonicalJson(previous) !== canonicalJson(value) + ) + throw new TypeError(`Artifact inventory has conflicting ${label} pages`); + output.set(key, value); +}; + +const assertUnique = (values: readonly string[], label: string): void => { + if (new Set(values).size !== values.length) + throw new TypeError(`Artifact inventory contains a duplicate ${label}`); +}; + +const digestCanonical = (value: unknown): string => + createHash("sha256").update(canonicalJson(value)).digest("hex"); + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Artifact inventory could not canonicalize graph data"); + return encoded; +}; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 749193fd4..c777b565a 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -27,9 +27,23 @@ type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64"; */ export interface BinaryTarget { readonly path: string; + readonly sourcePath?: string; readonly sha256: string; - readonly kind: "executable" | "database"; - readonly format: "hopper" | "mach-o" | "elf" | "pe"; + readonly kind: "executable" | "database" | "archive" | "artifact"; + readonly format: + | "hopper" + | "mach-o" + | "elf" + | "pe" + | "zip" + | "ipa" + | "apk" + | "asar" + | "dmg" + | "pkg" + | "plist" + | "javascript" + | "source-map"; readonly architecture?: BinaryArchitecture; readonly availableArchitectures?: readonly BinaryArchitecture[]; readonly loaderArgs: readonly string[]; @@ -61,6 +75,7 @@ export const parseBinaryTarget = async ( ) return ok({ path, + sourcePath: canonical, sha256: await sha256File(path), kind: "database", format: "hopper", @@ -69,6 +84,16 @@ export const parseBinaryTarget = async ( const handle = await open(path, "r"); let detected: Result; try { + const artifactFormat = await detectArtifactFormat(path, handle); + if (artifactFormat !== undefined) + return ok({ + path, + sourcePath: canonical, + sha256: await sha256File(path), + kind: isArchiveFormat(artifactFormat) ? "archive" : "artifact", + format: artifactFormat, + loaderArgs: [], + }); detected = await readExecutableMetadata(handle, hostArchitecture); } finally { await handle.close(); @@ -76,6 +101,7 @@ export const parseBinaryTarget = async ( if (!detected.ok) return err(new BinaryTargetError(path, detected.error)); return ok({ path, + sourcePath: canonical, sha256: await sha256File(path), kind: "executable", ...detected.value, @@ -87,6 +113,61 @@ export const parseBinaryTarget = async ( } }; +const detectArtifactFormat = async ( + path: string, + handle: FileHandle, +): Promise< + | Exclude + | undefined +> => { + const lower = path.toLowerCase(); + const magic = Buffer.alloc(8); + const observed = await handle.read(magic, 0, magic.length, 0); + if ( + observed.bytesRead >= 4 && + magic[0] === 0x50 && + magic[1] === 0x4b && + [0x03, 0x05, 0x07].includes(magic[2] ?? -1) && + [0x04, 0x06, 0x08].includes(magic[3] ?? -1) + ) { + return lower.endsWith(".ipa") + ? "ipa" + : lower.endsWith(".apk") + ? "apk" + : "zip"; + } + const named = namedArtifactFormat(lower); + if (named !== undefined) return named; + if ( + lower.endsWith(".pkg") && + observed.bytesRead >= 4 && + magic.subarray(0, 4).toString("ascii") === "xar!" + ) + return "pkg"; + if (lower.endsWith(".dmg")) { + const size = (await handle.stat()).size; + if (size >= 512) { + const trailer = Buffer.alloc(4); + const read = await handle.read(trailer, 0, trailer.length, size - 512); + if (read.bytesRead === 4 && trailer.toString("ascii") === "koly") + return "dmg"; + } + } + return undefined; +}; + +const namedArtifactFormat = ( + lowerPath: string, +): "asar" | "plist" | "source-map" | "javascript" | undefined => { + if (lowerPath.endsWith(".asar")) return "asar"; + if (lowerPath.endsWith(".plist")) return "plist"; + if (lowerPath.endsWith(".map")) return "source-map"; + return /\.(?:m?js|cjs)$/u.test(lowerPath) ? "javascript" : undefined; +}; + +const isArchiveFormat = (format: BinaryTarget["format"]): boolean => + ["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(format); + const sha256File = async (path: string): Promise => { const hash = createHash("sha256"); for await (const chunk of createReadStream(path)) hash.update(chunk); diff --git a/src/domain/bundleComparison.ts b/src/domain/bundleComparison.ts new file mode 100644 index 000000000..e11fa47b2 --- /dev/null +++ b/src/domain/bundleComparison.ts @@ -0,0 +1,371 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { + evidenceBundleSchema, + parseEvidenceBundle, + serializeEvidenceBundle, + type EvidenceBundle, +} from "./evidenceBundle.js"; +import type { ResidualUnknown } from "./residualUnknown.js"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const classificationSchema = z.enum([ + "added", + "removed", + "changed", + "unknown", + "history_advanced", + "history_diverged", +]); + +/** Strict bounded input for canonical Evidence bundle comparison. */ +export const bundleComparisonInputSchema = z.object({ + left: evidenceBundleSchema, + right: evidenceBundleSchema, + record_pairs: z + .array( + z.object({ + left_evidence_id: evidenceIdSchema, + right_evidence_id: evidenceIdSchema, + }), + ) + .max(500) + .default([]), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), +}); + +const bundleChangeSchema = z.object({ + entity: z.enum(["evidence", "residual_unknown"]), + key: z.string().min(1), + classification: classificationSchema, + conclusion_kind: z.enum([ + "observed_change", + "contradiction", + "unresolved_branch", + ]), + left_evidence_ids: z.array(evidenceIdSchema), + right_evidence_ids: z.array(evidenceIdSchema), + left_digest: digestSchema.nullable(), + right_digest: digestSchema.nullable(), + limitations: z.array(z.string()), +}); + +/** Deterministic classified delta between two canonical Evidence bundles. */ +export const bundleComparisonResultSchema = z.object({ + status: z.enum(["unchanged", "changed", "unknown"]), + left_bundle_sha256: digestSchema, + right_bundle_sha256: digestSchema, + summary: z.object({ + records_unchanged: z.number().int().min(0), + records_added: z.number().int().min(0), + records_removed: z.number().int().min(0), + records_changed: z.number().int().min(0), + unknowns_unchanged: z.number().int().min(0), + unknowns_added: z.number().int().min(0), + unknowns_removed: z.number().int().min(0), + unknowns_advanced: z.number().int().min(0), + unknowns_diverged: z.number().int().min(0), + unresolved: z.number().int().min(0), + }), + changes: z.object({ + items: z.array(bundleChangeSchema).max(500), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(500), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + limitations: z.array(z.string()), +}); + +export type BundleComparisonResult = z.infer< + typeof bundleComparisonResultSchema +>; +type BundleChange = z.infer; +type RecordPair = z.infer< + typeof bundleComparisonInputSchema +>["record_pairs"][number]; + +const MAX_ENTITIES = 100_000; + +/** Compare canonical bundle membership and only explicitly paired observations. */ +export const compareBundles = ( + ...[leftInput, rightInput, recordPairs, offset, limit]: readonly [ + unknown, + unknown, + readonly RecordPair[], + number, + number, + ] +): BundleComparisonResult => { + const left = parseEvidenceBundle(leftInput); + const right = parseEvidenceBundle(rightInput); + enforceBounds(left, right); + const leftDigest = bundleDigest(left); + const rightDigest = bundleDigest(right); + const recordResult = compareRecords(left, right, recordPairs); + const unknownResult = compareUnknowns(left, right); + const changes = [...recordResult.changes, ...unknownResult.changes].sort( + compareChanges, + ); + const page = changes.slice(offset, offset + limit); + const unresolved = changes.filter( + ({ conclusion_kind: kind }) => kind !== "observed_change", + ).length; + return bundleComparisonResultSchema.parse({ + status: + leftDigest === rightDigest && changes.length === 0 + ? "unchanged" + : unresolved > 0 + ? "unknown" + : "changed", + left_bundle_sha256: leftDigest, + right_bundle_sha256: rightDigest, + summary: { + ...recordResult.summary, + ...unknownResult.summary, + unresolved, + }, + changes: { + items: page, + offset, + limit, + total: changes.length, + next_offset: + offset + page.length < changes.length ? offset + page.length : null, + }, + limitations: changes.some(({ limitations }) => limitations.length > 0) + ? [ + "One-sided membership proves only bundle inclusion or omission, not behavioral absence.", + ] + : [], + }); +}; + +const compareRecords = ( + left: EvidenceBundle, + right: EvidenceBundle, + pairs: readonly RecordPair[], +) => { + const leftIds = new Set(left.records.map(({ evidence_id: id }) => id)); + const rightIds = new Set(right.records.map(({ evidence_id: id }) => id)); + const pairedLeft = new Set(); + const pairedRight = new Set(); + const changes: BundleChange[] = []; + let changed = 0; + for (const pair of pairs) { + if (!leftIds.has(pair.left_evidence_id)) + throw new TypeError( + `Explicit pair references missing left evidence ${pair.left_evidence_id}`, + ); + if (!rightIds.has(pair.right_evidence_id)) + throw new TypeError( + `Explicit pair references missing right evidence ${pair.right_evidence_id}`, + ); + if ( + pairedLeft.has(pair.left_evidence_id) || + pairedRight.has(pair.right_evidence_id) + ) + throw new TypeError("Explicit evidence pairs must be one-to-one"); + pairedLeft.add(pair.left_evidence_id); + pairedRight.add(pair.right_evidence_id); + if (pair.left_evidence_id === pair.right_evidence_id) continue; + changed += 1; + changes.push({ + entity: "evidence", + key: `pair:${pair.left_evidence_id}:${pair.right_evidence_id}`, + classification: "changed", + conclusion_kind: "observed_change", + left_evidence_ids: [pair.left_evidence_id], + right_evidence_ids: [pair.right_evidence_id], + left_digest: pair.left_evidence_id.slice(3), + right_digest: pair.right_evidence_id.slice(3), + limitations: [], + }); + } + const common = [...leftIds].filter( + (id) => rightIds.has(id) && !pairedLeft.has(id) && !pairedRight.has(id), + ); + const removed = [...leftIds].filter( + (id) => !rightIds.has(id) && !pairedLeft.has(id), + ); + const added = [...rightIds].filter( + (id) => !leftIds.has(id) && !pairedRight.has(id), + ); + for (const [classification, ids, side] of [ + ["removed", removed, "left"], + ["added", added, "right"], + ] as const) + for (const id of ids) + changes.push({ + entity: "evidence", + key: id, + classification, + conclusion_kind: "observed_change", + left_evidence_ids: side === "left" ? [id] : [], + right_evidence_ids: side === "right" ? [id] : [], + left_digest: side === "left" ? id.slice(3) : null, + right_digest: side === "right" ? id.slice(3) : null, + limitations: [ + "One-sided membership does not establish behavioral absence.", + ], + }); + return { + changes, + summary: { + records_unchanged: common.length + pairs.length - changed, + records_added: added.length, + records_removed: removed.length, + records_changed: changed, + }, + }; +}; + +const compareUnknowns = (left: EvidenceBundle, right: EvidenceBundle) => { + const leftHistories = histories(left.unknowns); + const rightHistories = histories(right.unknowns); + const ids = [...new Set([...leftHistories.keys(), ...rightHistories.keys()])]; + const changes: BundleChange[] = []; + const summary = { + unknowns_unchanged: 0, + unknowns_added: 0, + unknowns_removed: 0, + unknowns_advanced: 0, + unknowns_diverged: 0, + }; + for (const id of ids) { + const leftHistory = leftHistories.get(id); + const rightHistory = rightHistories.get(id); + if (leftHistory === undefined || rightHistory === undefined) { + const added = leftHistory === undefined; + summary[added ? "unknowns_added" : "unknowns_removed"] += 1; + changes.push( + unknownChange( + id, + added ? "added" : "removed", + leftHistory, + rightHistory, + ), + ); + continue; + } + const leftDigests = leftHistory.map( + ({ revision_digest: digest }) => digest, + ); + const rightDigests = rightHistory.map( + ({ revision_digest: digest }) => digest, + ); + if (same(leftDigests, rightDigests)) { + summary.unknowns_unchanged += 1; + continue; + } + const advanced = + isPrefix(leftDigests, rightDigests) || + isPrefix(rightDigests, leftDigests); + summary[advanced ? "unknowns_advanced" : "unknowns_diverged"] += 1; + changes.push( + unknownChange( + id, + advanced ? "history_advanced" : "history_diverged", + leftHistory, + rightHistory, + ), + ); + } + return { changes, summary }; +}; + +const unknownChange = ( + id: string, + classification: BundleChange["classification"], + left: readonly ResidualUnknown[] | undefined, + right: readonly ResidualUnknown[] | undefined, +): BundleChange => ({ + entity: "residual_unknown", + key: id, + classification, + conclusion_kind: + classification === "history_diverged" + ? "contradiction" + : classification === "history_advanced" + ? "observed_change" + : "unresolved_branch", + left_evidence_ids: historyEvidenceIds(left ?? []), + right_evidence_ids: historyEvidenceIds(right ?? []), + left_digest: left?.at(-1)?.revision_digest ?? null, + right_digest: right?.at(-1)?.revision_digest ?? null, + limitations: + left === undefined || right === undefined + ? ["A missing unknown history does not establish resolution."] + : [], +}); + +const histories = (unknowns: readonly ResidualUnknown[]) => { + const output = new Map(); + for (const unknown of unknowns) { + const history = output.get(unknown.unknown_id) ?? []; + history.push(unknown); + output.set(unknown.unknown_id, history); + } + return output; +}; + +const historyEvidenceIds = (history: readonly ResidualUnknown[]): string[] => + [ + ...new Set( + history.flatMap((item) => [ + ...item.supporting_evidence_ids, + ...item.contradicting_evidence_ids, + ...item.mutation_evidence_ids, + ...(item.resolution?.evidence_ids ?? []), + ]), + ), + ].sort(ascii); + +const enforceBounds = (left: EvidenceBundle, right: EvidenceBundle): void => { + const total = + left.records.length + + right.records.length + + left.unknowns.length + + right.unknowns.length; + if (total > MAX_ENTITIES) + throw new TypeError(`Bundle comparison exceeds ${MAX_ENTITIES} entities`); +}; + +const bundleDigest = (bundle: EvidenceBundle): string => + createHash("sha256").update(serializeEvidenceBundle(bundle)).digest("hex"); + +const same = (left: readonly string[], right: readonly string[]): boolean => + left.length === right.length && + left.every((value, index) => value === right[index]); + +const isPrefix = ( + shorter: readonly string[], + longer: readonly string[], +): boolean => + shorter.length < longer.length && + shorter.every((value, index) => value === longer[index]); + +const ascii = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const compareChanges = (left: BundleChange, right: BundleChange): number => + ascii( + [ + left.entity, + left.key, + left.classification, + left.left_digest ?? "", + left.right_digest ?? "", + ].join("\u0000"), + [ + right.entity, + right.key, + right.classification, + right.left_digest ?? "", + right.right_digest ?? "", + ].join("\u0000"), + ); diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts new file mode 100644 index 000000000..f5c178e2e --- /dev/null +++ b/src/domain/callPath.ts @@ -0,0 +1,466 @@ +import { DirectedGraph } from "graphology"; +import { bidirectional } from "graphology-shortest-path/unweighted.js"; +import { z } from "zod"; + +import { evidenceSchema } from "./evidence.js"; +import { + parseFunctionEvidence, + type FunctionSnapshot, +} from "./functionDossierEvidence.js"; + +const addressSchema = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]*)$/u); +const inputAddressSchema = z + .string() + .regex(/^0[xX][0-9a-fA-F]+$/u) + .transform((address) => `0x${BigInt(address).toString(16)}`); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const evidenceGroupSchema = z.union([ + evidenceSchema, + z.array(evidenceSchema).min(1).max(100), +]); +const MAX_EVIDENCE_PAGES = 2_000; +const MAX_GRAPH_EDGES = 10_000; +const MAX_PATH_EXPANSIONS = 100_000; + +/** Strict, bounded input for explicit call-path reconstruction. */ +export const callPathInputSchema = z + .object({ + functions: z.array(evidenceGroupSchema).min(1).max(500), + start: z.object({ address: inputAddressSchema }).strict(), + goal: z.object({ address: inputAddressSchema }).strict(), + max_depth: z.number().int().min(0).max(32).default(8), + max_paths: z.number().int().min(1).max(100).default(10), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + unknown_registry_approved: z.literal(true).optional(), + }) + .superRefine(({ functions }, context) => { + const pages = functions.reduce( + (total, group) => total + (Array.isArray(group) ? group.length : 1), + 0, + ); + if (pages > MAX_EVIDENCE_PAGES) + context.addIssue({ + code: "custom", + message: `Call-path Evidence exceeds ${MAX_EVIDENCE_PAGES} pages`, + path: ["functions"], + }); + }); + +const citedNodeSchema = z.object({ + address: addressSchema, + name: z.string().nullable(), + evidence_links: z.array(evidenceIdSchema).min(1).max(100), +}); +const citedEdgeSchema = z.object({ + source: addressSchema, + target: addressSchema, + evidence_links: z.array(evidenceIdSchema).min(1).max(100), +}); +const pathSchema = z.object({ + hops: z.number().int().min(0), + nodes: z.array(citedNodeSchema).min(1).max(33), + edges: z.array(citedEdgeSchema).max(32), + evidence_links: z.array(evidenceIdSchema).min(1).max(3_300), +}); + +/** Evidence-cited, bounded directed call-path result. */ +export const callPathResultSchema = z.object({ + status: z.enum(["found", "not_found", "unknown", "truncated"]), + start: addressSchema, + goal: addressSchema, + shortest_hops: z.number().int().min(0).nullable(), + search_scope: z.object({ + max_depth: z.number().int().min(0).max(32), + max_paths: z.number().int().min(1).max(100), + exhaustive: z.boolean(), + }), + explored: z.object({ + nodes: z.number().int().min(0), + edges: z.number().int().min(0), + depth_reached: z.number().int().min(0), + }), + paths: z.object({ + items: z.array(pathSchema).max(100), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(0).nullable(), + returned: z.number().int().min(0).max(100), + truncated: z.boolean(), + lower_bound: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + evidence_links: z.array(evidenceIdSchema).min(1).max(50_000), + limitations: z.array(z.string()), +}); + +export type CallPathInput = z.infer; +export type CallPathResult = z.infer; +type OutputPath = z.infer; + +interface SearchState { + readonly graph: DirectedGraph; + readonly snapshots: ReadonlyMap; + readonly reached: ReadonlyMap; + readonly exhaustive: boolean; + readonly limitations: readonly string[]; +} + +/** Reconstruct bounded direct-callee paths from explicit analyze_function Evidence. */ +export const buildCallPath = (input: CallPathInput): CallPathResult => { + const parsed = callPathInputSchema.parse(input); + const snapshots = parseSnapshots(parsed.functions); + assertCompatible(snapshots); + if (!snapshots.has(parsed.start.address)) + throw new TypeError( + `No analyze_function Evidence was supplied for start ${parsed.start.address}`, + ); + const graph = createGraph(snapshots); + const search = inspectSearch({ + graph, + snapshots, + start: parsed.start.address, + goal: parsed.goal.address, + maxDepth: parsed.max_depth, + }); + const shortest = + graph.hasNode(parsed.start.address) && graph.hasNode(parsed.goal.address) + ? bidirectional(graph, parsed.start.address, parsed.goal.address) + : null; + const enumeration = + shortest === null || shortest.length - 1 > parsed.max_depth + ? { paths: [], budgetExhausted: false } + : enumeratePaths({ + graph, + start: parsed.start.address, + goal: parsed.goal.address, + shortestDepth: shortest.length - 1, + maxDepth: parsed.max_depth, + limit: parsed.max_paths + 1, + }); + const hasKnownExtraPath = enumeration.paths.length > parsed.max_paths; + const capped = hasKnownExtraPath || enumeration.budgetExhausted; + const retained = enumeration.paths.slice(0, parsed.max_paths); + const paths = retained.map((path) => citePath(path, snapshots)); + const total = paths.length; + const items = paths.slice(parsed.offset, parsed.offset + parsed.limit); + const found = paths.length > 0; + const exhaustive = search.exhaustive && !capped; + const status = callPathStatus({ capped, found, exhaustive }); + const limitations = [...search.limitations]; + if (capped) + limitations.push( + enumeration.budgetExhausted + ? `Path enumeration stopped at ${MAX_PATH_EXPANSIONS} expansions` + : `Path enumeration stopped at max_paths=${parsed.max_paths}`, + ); + if (!found && shortest !== null && shortest.length - 1 > parsed.max_depth) + limitations.push( + `The shortest observed path exceeds max_depth=${parsed.max_depth}`, + ); + return callPathResultSchema.parse({ + status, + start: parsed.start.address, + goal: parsed.goal.address, + shortest_hops: found ? (paths[0]?.hops ?? null) : null, + search_scope: { + max_depth: parsed.max_depth, + max_paths: parsed.max_paths, + exhaustive, + }, + explored: { + nodes: search.reached.size, + edges: [...search.reached.keys()].reduce( + (count, node) => + count + (graph.hasNode(node) ? graph.outDegree(node) : 0), + 0, + ), + depth_reached: Math.max(0, ...search.reached.values()), + }, + paths: { + items, + offset: parsed.offset, + limit: parsed.limit, + total: capped ? null : total, + returned: items.length, + truncated: capped, + lower_bound: total + (hasKnownExtraPath ? 1 : 0), + next_offset: + parsed.offset + items.length < total + ? parsed.offset + items.length + : null, + }, + evidence_links: uniqueEvidence(snapshots.values()), + limitations: [...new Set(limitations)].sort((left, right) => + left.localeCompare(right), + ), + }); +}; + +const callPathStatus = ({ + capped, + found, + exhaustive, +}: { + readonly capped: boolean; + readonly found: boolean; + readonly exhaustive: boolean; +}): CallPathResult["status"] => { + if (capped) return "truncated"; + if (found) return "found"; + return exhaustive ? "not_found" : "unknown"; +}; + +const parseSnapshots = ( + groups: readonly z.infer[], +): Map => { + const snapshots = new Map(); + for (const group of groups) { + const snapshot = parseFunctionEvidence(group); + const address = normalizeAddress(snapshot.procedure.address); + for (const callee of snapshot.collections.callees.items) + normalizeAddress(callee.address); + if (snapshots.has(address)) + throw new TypeError(`Duplicate function Evidence for ${address}`); + snapshots.set(address, snapshot); + } + return snapshots; +}; + +const assertCompatible = ( + snapshots: ReadonlyMap, +): void => { + const first = snapshots.values().next().value as FunctionSnapshot | undefined; + if (first === undefined) return; + for (const snapshot of snapshots.values()) { + if ( + snapshot.subject.digest.sha256 !== first.subject.digest.sha256 || + snapshot.subject.format !== first.subject.format || + snapshot.subject.architecture !== first.subject.architecture + ) + throw new TypeError("Call-path Evidence mixes artifact subjects"); + if ( + snapshot.provider.id !== first.provider.id || + snapshot.provider.name !== first.provider.name || + snapshot.provider.version !== first.provider.version + ) + throw new TypeError("Call-path Evidence mixes providers"); + } +}; + +const createGraph = ( + snapshots: ReadonlyMap, +): DirectedGraph => { + const graph = new DirectedGraph({ allowSelfLoops: true, multi: false }); + for (const [address, snapshot] of snapshots) { + graph.mergeNode(address); + for (const callee of snapshot.collections.callees.items) { + const calleeAddress = normalizeAddress(callee.address); + graph.mergeNode(calleeAddress); + graph.mergeDirectedEdge(address, calleeAddress); + if (graph.size > MAX_GRAPH_EDGES) + throw new TypeError( + `Call graph exceeds ${MAX_GRAPH_EDGES} directed edges`, + ); + } + } + return graph; +}; + +interface SearchInput { + readonly graph: DirectedGraph; + readonly snapshots: ReadonlyMap; + readonly start: string; + readonly goal: string; + readonly maxDepth: number; +} + +const inspectSearch = ({ + graph, + snapshots, + start, + goal, + maxDepth, +}: SearchInput): SearchState => { + if (!graph.hasNode(start)) + return { + graph, + snapshots, + reached: new Map(), + exhaustive: false, + limitations: [ + `No analyze_function Evidence was supplied for start ${start}`, + ], + }; + const reached = new Map([[start, 0]]); + const queue = [start]; + const limitations: string[] = []; + for (let index = 0; index < queue.length; index += 1) { + const node = queue[index]; + if (node === undefined) continue; + const depth = reached.get(node) ?? 0; + if (node === goal) continue; + const snapshot = snapshots.get(node); + if (snapshot === undefined) { + limitations.push( + `No analyze_function Evidence covers reachable function ${node}`, + ); + continue; + } + if (!snapshot.collections.callees.complete) + limitations.push( + `Callee coverage is incomplete for reachable function ${node}`, + ); + const neighbors = graph + .outNeighbors(node) + .sort((left, right) => left.localeCompare(right)); + if (depth === maxDepth) { + if (neighbors.length > 0) + limitations.push(`Search reached max_depth=${maxDepth} at ${node}`); + continue; + } + for (const neighbor of neighbors) + if (!reached.has(neighbor)) { + reached.set(neighbor, depth + 1); + queue.push(neighbor); + } + } + return { + graph, + snapshots, + reached, + exhaustive: limitations.length === 0, + limitations, + }; +}; + +interface EnumerationInput { + readonly graph: DirectedGraph; + readonly start: string; + readonly goal: string; + readonly shortestDepth: number; + readonly maxDepth: number; + readonly limit: number; +} + +const enumeratePaths = ({ + graph, + start, + goal, + shortestDepth, + maxDepth, + limit, +}: EnumerationInput): { + readonly paths: string[][]; + readonly budgetExhausted: boolean; +} => { + const output: string[][] = []; + const state: EnumerationState = { + graph, + goal, + path: [start], + visited: new Set([start]), + output, + limit, + expansions: 0, + budgetExhausted: false, + }; + for ( + let depth = shortestDepth; + depth <= maxDepth && output.length < limit; + depth += 1 + ) + enumerateAtDepth(state, depth); + return { paths: output, budgetExhausted: state.budgetExhausted }; +}; + +interface EnumerationState { + readonly graph: DirectedGraph; + readonly goal: string; + readonly path: string[]; + readonly visited: Set; + readonly output: string[][]; + readonly limit: number; + expansions: number; + budgetExhausted: boolean; +} + +const enumerateAtDepth = (state: EnumerationState, remaining: number): void => { + const { graph, goal, path, visited, output, limit } = state; + state.expansions += 1; + if (state.expansions > MAX_PATH_EXPANSIONS) { + state.budgetExhausted = true; + return; + } + const current = path.at(-1); + if (current === undefined || output.length >= limit) return; + if (remaining === 0) { + if (current === goal) output.push([...path]); + return; + } + if (current === goal) return; + for (const neighbor of graph + .outNeighbors(current) + .sort((left, right) => left.localeCompare(right))) { + if (visited.has(neighbor)) continue; + visited.add(neighbor); + path.push(neighbor); + enumerateAtDepth(state, remaining - 1); + path.pop(); + visited.delete(neighbor); + if (output.length >= limit || state.budgetExhausted) return; + } +}; + +const citePath = ( + addresses: readonly string[], + snapshots: ReadonlyMap, +): OutputPath => { + const edges = addresses.slice(0, -1).map((source, index) => { + const target = addresses[index + 1]; + if (target === undefined) + throw new TypeError("Call path has an invalid edge"); + return { + source, + target, + evidence_links: snapshotLinks(snapshots.get(source)), + }; + }); + const nodes = addresses.map((address, index) => { + const snapshot = snapshots.get(address); + const supporting = + snapshot ?? + (index > 0 ? snapshots.get(addresses[index - 1] ?? "") : undefined); + return { + address, + name: snapshot?.procedure.name ?? null, + evidence_links: snapshotLinks(supporting), + }; + }); + return { + hops: edges.length, + nodes, + edges, + evidence_links: [ + ...new Set( + edges + .flatMap(({ evidence_links: links }) => links) + .concat(nodes.flatMap(({ evidence_links: links }) => links)), + ), + ], + }; +}; + +const snapshotLinks = (snapshot: FunctionSnapshot | undefined): string[] => { + if (snapshot === undefined) + throw new TypeError("Every call-path claim requires supporting Evidence"); + return snapshot.evidence.map(({ evidence_id }) => evidence_id); +}; + +const uniqueEvidence = (snapshots: Iterable): string[] => + [ + ...new Set([...snapshots].flatMap((snapshot) => snapshotLinks(snapshot))), + ].sort((left, right) => left.localeCompare(right)); + +const normalizeAddress = (input: string): string => + inputAddressSchema.parse(input); diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts new file mode 100644 index 000000000..7c7b316d3 --- /dev/null +++ b/src/domain/changedBehavior.ts @@ -0,0 +1,404 @@ +import { z } from "zod"; + +import { artifactComparisonResultSchema } from "./artifactComparison.js"; +import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; +import { functionComparisonResultSchema } from "./functionComparison.js"; +import { + comparisonStatusSchema, + processCaptureComparisonSchema, +} from "./processCapture.js"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); + +/** Strict bounded input for aggregating existing comparison Evidence. */ +export const changedBehaviorInputSchema = z.object({ + comparisons: z.array(evidenceSchema).min(1).max(100), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + unknown_registry_approved: z.literal(true).optional(), +}); + +const findingSchema = z.object({ + scope: z.enum(["runtime", "protocol", "resource", "static_candidate"]), + dimension: z.string().min(1), + classification: z.enum([ + "observed_change", + "derived_relationship", + "contradiction", + "unresolved_branch", + ]), + status: comparisonStatusSchema, + source_comparison_id: evidenceIdSchema, + evidence_links: z.array(evidenceIdSchema).min(3).max(201), + limitations: z.array(z.string()), +}); + +/** Truthful aggregate: runtime observations remain separate from static candidates. */ +export const changedBehaviorResultSchema = z.object({ + behavior_status: z.enum([ + "observed_changed", + "observed_unchanged", + "unknown", + "truncated", + ]), + summary: z.object({ + observed_changes: z.number().int().min(0), + static_candidates: z.number().int().min(0), + contradictions: z.number().int().min(0), + unresolved: z.number().int().min(0), + }), + findings: z.object({ + items: z.array(findingSchema).max(100), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + evidence_links: z.array(evidenceIdSchema).min(3).max(20_100), + limitations: z.array(z.string()), +}); + +export type ChangedBehaviorResult = z.infer; +type Finding = z.infer; +type RuntimeStatus = z.infer["status"]; + +const EXPECTED_PROVIDERS = { + compare_process_captures: { + predicate: "rea.process-comparison/v1", + id: "rea-process", + name: "REA deterministic process harness", + version: "1", + }, + compare_artifacts: { + predicate: "rea.artifact-comparison/v1", + id: "rea-artifact-comparison", + name: "REA artifact comparison", + version: "1", + }, + compare_functions: { + predicate: "rea.function-comparison/v1", + id: "rea-function-comparison", + name: "REA function comparison", + version: "1", + }, +} as const; + +/** Find observed runtime changes and separately report static change candidates. */ +export const findChangedBehavior = ( + comparisonsInput: unknown, + offset: number, + limit: number, +): ChangedBehaviorResult => { + const parsedInput = changedBehaviorInputSchema.parse({ + comparisons: comparisonsInput, + offset, + limit, + }); + const comparisons = parsedInput.comparisons; + const evidence = comparisons.map(parseComparisonEvidence); + assertUnique( + evidence.map(({ evidence_id: id }) => id), + "comparison Evidence", + ); + const findings = evidence.flatMap(findingsFor).sort(compareFindings); + const runtimeStatuses = evidence.flatMap(runtimeStatus); + const links = uniqueSorted( + evidence.flatMap((item) => [item.evidence_id, ...item.evidence_links]), + ); + if (links.length > 20_100) + throw new TypeError("Changed-behavior Evidence closure exceeds limit"); + const page = findings.slice( + parsedInput.offset, + parsedInput.offset + parsedInput.limit, + ); + return changedBehaviorResultSchema.parse({ + behavior_status: behaviorStatus(runtimeStatuses), + summary: { + observed_changes: count(findings, "observed_change"), + static_candidates: count(findings, "derived_relationship"), + contradictions: count(findings, "contradiction"), + unresolved: count(findings, "unresolved_branch"), + }, + findings: { + items: page, + offset: parsedInput.offset, + limit: parsedInput.limit, + total: findings.length, + next_offset: + parsedInput.offset + page.length < findings.length + ? parsedInput.offset + page.length + : null, + }, + evidence_links: links, + limitations: uniqueSorted([ + ...evidence.flatMap(({ limitations }) => limitations), + ...(evidence.some( + ({ operation }) => operation !== "compare_process_captures", + ) + ? [ + "Static differences are behavior candidates, not runtime observations.", + ] + : []), + ...(runtimeStatuses.length === 0 + ? ["No process comparison Evidence was supplied."] + : []), + ]), + }); +}; + +const parseComparisonEvidence = (input: unknown): Evidence => { + const evidence = parseEvidence(input); + if (!(evidence.operation in EXPECTED_PROVIDERS)) + throw new TypeError( + "Changed-behavior analysis requires process, artifact, or function comparison Evidence", + ); + const operation = z + .enum([ + "compare_process_captures", + "compare_artifacts", + "compare_functions", + ]) + .parse(evidence.operation); + const expected = EXPECTED_PROVIDERS[operation]; + if ( + evidence.predicate_type !== expected.predicate || + evidence.provider.id !== expected.id || + evidence.provider.name !== expected.name || + evidence.provider.version !== expected.version || + evidence.confidence !== "derived" || + evidence.authority !== "analyst-inference" || + evidence.subject !== null + ) + throw new TypeError( + "Comparison Evidence operation, predicate, and provider disagree", + ); + if (evidence.evidence_links.length < 2) + throw new TypeError( + "Comparison Evidence must cite both source observations", + ); + assertUnique(evidence.evidence_links, "source Evidence links"); + parseResult(evidence); + return evidence; +}; + +const parseResult = (evidence: Evidence): void => { + if (evidence.operation === "compare_process_captures") { + const result = processCaptureComparisonSchema.parse( + evidence.normalized_result, + ); + const dimensions = [ + result.terminal, + result.exit, + result.filesystem, + result.protocol, + result.process, + ]; + const expected = dimensions.includes("truncated") + ? "truncated" + : dimensions.some(isChange) + ? "changed" + : dimensions.includes("unknown") + ? "unknown" + : "unchanged"; + if (result.status !== expected) + throw new TypeError( + "Process comparison status contradicts its dimensions", + ); + return; + } + if (evidence.operation === "compare_functions") { + const result = functionComparisonResultSchema.parse( + evidence.normalized_result, + ); + assertNestedLinks( + evidence, + result.dimensions.flatMap(({ evidence_links: links }) => links), + ); + return; + } + const result = artifactComparisonResultSchema.parse( + evidence.normalized_result, + ); + if ( + result.changes.offset !== 0 || + result.changes.next_offset !== null || + result.changes.items.length !== result.changes.total + ) + throw new TypeError( + "Changed-behavior analysis requires complete artifact comparison pagination", + ); + assertNestedLinks( + evidence, + result.changes.items.flatMap(({ evidence_links: links }) => links), + ); +}; + +const assertNestedLinks = ( + evidence: Evidence, + nestedLinks: readonly string[], +): void => { + const topLevel = new Set(evidence.evidence_links); + if (nestedLinks.some((evidenceId) => !topLevel.has(evidenceId))) + throw new TypeError( + "Comparison finding cites Evidence outside its top-level closure", + ); +}; + +const findingsFor = (evidence: Evidence): Finding[] => { + if (evidence.operation === "compare_process_captures") + return processFindings(evidence); + if (evidence.operation === "compare_functions") + return functionFindings(evidence); + return artifactFindings(evidence); +}; + +const processFindings = (evidence: Evidence): Finding[] => { + const result = processCaptureComparisonSchema.parse( + evidence.normalized_result, + ); + const dimensions = [ + ["terminal", result.terminal, "runtime"], + ["exit", result.exit, "runtime"], + ["filesystem", result.filesystem, "resource"], + ["protocol", result.protocol, "protocol"], + ["process", result.process, "runtime"], + ] as const; + return dimensions.flatMap(([dimension, status, scope]) => + status === "unchanged" + ? [] + : [ + makeFinding({ + evidence, + dimension, + scope, + status, + classification: isChange(status) + ? "observed_change" + : "unresolved_branch", + limitations: result.limitations, + }), + ], + ); +}; + +const functionFindings = (evidence: Evidence): Finding[] => { + const result = functionComparisonResultSchema.parse( + evidence.normalized_result, + ); + return result.dimensions.flatMap((dimension) => + dimension.status === "unchanged" + ? [] + : [ + makeFinding({ + evidence, + dimension: dimension.dimension, + scope: "static_candidate", + status: dimension.status, + classification: + dimension.status === "unknown" || dimension.status === "truncated" + ? "unresolved_branch" + : dimension.conclusion_kind, + limitations: dimension.limitations, + links: dimension.evidence_links, + }), + ], + ); +}; + +const artifactFindings = (evidence: Evidence): Finding[] => { + const result = artifactComparisonResultSchema.parse( + evidence.normalized_result, + ); + const changes = result.changes.items.map((change) => + makeFinding({ + evidence, + dimension: `artifact:${change.logical_path}`, + scope: "static_candidate", + status: change.classification, + classification: + change.classification === "unknown" + ? "unresolved_branch" + : "derived_relationship", + limitations: result.limitations, + links: change.evidence_links, + }), + ); + return (result.status === "unknown" || result.status === "truncated") && + !changes.some( + ({ classification }) => classification === "unresolved_branch", + ) + ? [ + ...changes, + makeFinding({ + evidence, + dimension: "artifact:coverage", + scope: "static_candidate", + status: result.status, + classification: "unresolved_branch", + limitations: result.limitations, + }), + ] + : changes; +}; + +const makeFinding = (input: { + readonly evidence: Evidence; + readonly dimension: string; + readonly scope: Finding["scope"]; + readonly status: Finding["status"]; + readonly classification: Finding["classification"]; + readonly limitations: readonly string[]; + readonly links?: readonly string[]; +}): Finding => + findingSchema.parse({ + scope: input.scope, + dimension: input.dimension, + classification: input.classification, + status: input.status, + source_comparison_id: input.evidence.evidence_id, + evidence_links: uniqueSorted([ + input.evidence.evidence_id, + ...(input.links ?? input.evidence.evidence_links), + ]), + limitations: uniqueSorted(input.limitations), + }); + +const runtimeStatus = (evidence: Evidence): RuntimeStatus[] => + evidence.operation === "compare_process_captures" + ? [processCaptureComparisonSchema.parse(evidence.normalized_result).status] + : []; + +const behaviorStatus = ( + statuses: readonly RuntimeStatus[], +): ChangedBehaviorResult["behavior_status"] => { + if (statuses.length === 0) return "unknown"; + if (statuses.includes("truncated")) return "truncated"; + if (statuses.includes("unknown")) return "unknown"; + return statuses.some(isChange) ? "observed_changed" : "observed_unchanged"; +}; + +const isChange = (status: RuntimeStatus): boolean => + status === "added" || status === "removed" || status === "changed"; + +const count = ( + findings: readonly Finding[], + classification: Finding["classification"], +): number => + findings.filter((finding) => finding.classification === classification) + .length; + +const compareFindings = (left: Finding, right: Finding): number => + [left.scope, left.dimension, left.source_comparison_id] + .join("\0") + .localeCompare( + [right.scope, right.dimension, right.source_comparison_id].join("\0"), + "en", + ); + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right, "en")); + +const assertUnique = (values: readonly string[], label: string): void => { + if (new Set(values).size !== values.length) + throw new TypeError(`Changed-behavior analysis rejects duplicate ${label}`); +}; diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 1ce11aa29..d53376e62 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,6 +1,31 @@ +export type AnalysisErrorTag = + | "AnalysisProtocolError" + | "AnalysisInputError" + | "AnalysisOutputError" + | "AnalysisCapabilityUnavailableError" + | "AnalysisCancelledError" + | "AnalysisTimeoutError" + | "ProviderSelectionError" + | "ProviderAdapterError" + | "ArtifactOperationError" + | "ProcessCaptureError" + | "EvidenceIntegrityError" + | "EvidenceLimitError" + | "EvidenceFileError" + | "UnknownRegistryError" + | "HopperTimeoutError" + | "HopperCancelledError" + | "HopperProtocolError" + | "HopperRemoteError" + | "HopperProcessError" + | "HopperStartError" + | "ConfigurationError" + | "NoBinaryOpenError" + | "BinaryTargetError"; + /** Base class for expected analysis, provider, and session failures. */ export abstract class AnalysisError extends Error { - abstract readonly _tag: string; + abstract readonly _tag: AnalysisErrorTag; } /** Base class for failures produced specifically by the Hopper provider. */ @@ -11,9 +36,161 @@ export class AnalysisProtocolError extends AnalysisError { readonly _tag = "AnalysisProtocolError"; } -/** An evidence bundle or bounded session ledger rejected caller-controlled data. */ -export class EvidenceLedgerError extends AnalysisError { - readonly _tag = "EvidenceLedgerError"; +/** Caller input failed provider-neutral application parsing. */ +export class AnalysisInputError extends AnalysisError { + readonly _tag = "AnalysisInputError"; + + constructor( + readonly operation: string, + options?: ErrorOptions, + ) { + super(`Invalid analysis input for ${operation}`, options); + } +} + +/** Provider output failed provider-neutral application parsing. */ +export class AnalysisOutputError extends AnalysisError { + readonly _tag = "AnalysisOutputError"; + + constructor( + readonly operation: string, + readonly reason: string, + options?: ErrorOptions, + ) { + super(`Invalid analysis output for ${operation}: ${reason}`, options); + } +} + +/** Selected provider cannot execute a declared analysis operation. */ +export class AnalysisCapabilityUnavailableError extends AnalysisError { + readonly _tag = "AnalysisCapabilityUnavailableError"; + + constructor( + readonly providerId: string, + readonly operation: string, + readonly reason: string, + ) { + super(`Provider ${providerId} cannot execute ${operation}: ${reason}`); + } +} + +/** Caller cancellation won before provider-neutral work completed. */ +export class AnalysisCancelledError extends AnalysisError { + readonly _tag = "AnalysisCancelledError"; + + constructor(readonly operation: string) { + super(`Analysis operation was cancelled: ${operation}`); + } +} + +/** Provider-neutral operation exceeded its declared execution deadline. */ +export class AnalysisTimeoutError extends AnalysisError { + readonly _tag = "AnalysisTimeoutError"; + + constructor( + readonly operation: string, + readonly timeoutMs: number, + ) { + super( + `Analysis operation timed out after ${String(timeoutMs)}ms: ${operation}`, + ); + } +} + +/** No configured provider can satisfy a requested operation. */ +export class ProviderSelectionError extends AnalysisError { + readonly _tag = "ProviderSelectionError"; + + constructor(readonly operation: string) { + super(`No configured provider can execute ${operation}`); + } +} + +/** A provider adapter failed outside its more precise typed variants. */ +export class ProviderAdapterError extends AnalysisError { + readonly _tag = "ProviderAdapterError"; + + constructor( + readonly providerId: string, + readonly operation: string, + options?: ErrorOptions, + ) { + super(`Provider ${providerId} adapter failed during ${operation}`, options); + } +} + +/** Artifact inventory or extraction failed a typed safety boundary. */ +export class ArtifactOperationError extends AnalysisError { + readonly _tag = "ArtifactOperationError"; + + constructor( + readonly operation: "inventory_artifact" | "extract_artifact", + readonly reason: + | "cancelled" + | "format" + | "integrity" + | "limit" + | "path" + | "unavailable" + | "io", + ) { + super(`Artifact ${operation} failed: ${reason}`); + } +} + +/** Evidence identity, schema, or bundle manifests failed integrity checks. */ +export class EvidenceIntegrityError extends AnalysisError { + readonly _tag = "EvidenceIntegrityError"; +} + +/** A bounded evidence ledger cannot accept more records or serialized bytes. */ +export class EvidenceLimitError extends AnalysisError { + readonly _tag = "EvidenceLimitError"; + + constructor( + readonly limit: "records" | "bytes", + readonly maximum: number, + ) { + super(`Evidence ledger ${limit} limit exceeded (${String(maximum)})`); + } +} + +/** Evidence bundle filesystem access failed within the configured policy. */ +export class EvidenceFileError extends AnalysisError { + readonly _tag = "EvidenceFileError"; + + constructor( + readonly operation: "read" | "write", + readonly reason: + | "disabled" + | "outside-root" + | "not-file" + | "too-large" + | "exists" + | "invalid-json" + | "io", + options?: ErrorOptions, + ) { + super(`Evidence bundle ${operation} failed: ${reason}`, options); + } +} + +/** Residual-unknown mutation failed a lifecycle, reference, or CAS invariant. */ +export class UnknownRegistryError extends AnalysisError { + readonly _tag = "UnknownRegistryError"; + + constructor( + readonly reason: + | "not-found" + | "already-exists" + | "revision-conflict" + | "invalid-transition" + | "integrity" + | "limit", + options?: ErrorOptions, + ) { + super(`Residual unknown registry mutation failed: ${reason}`, options); + } } /** Hopper did not respond within the configured operation deadline. */ @@ -92,6 +269,91 @@ export class BinaryTargetError extends AnalysisError { reason: string, options?: ErrorOptions, ) { - super(`Cannot open app: ${reason}: ${path}`, options); + super(`Cannot open artifact: ${reason}`, options); } } + +export interface AnalysisErrorProjection { + readonly tag: AnalysisErrorTag; + readonly message: string; + readonly details: Readonly>; +} + +/** Project expected failures into exhaustive, secret-safe caller fields. */ +export const projectAnalysisError = ( + error: AnalysisError, +): AnalysisErrorProjection => { + assertKnownTag(error._tag); + return { + tag: error._tag, + message: error.message, + details: safeDetails(error), + }; +}; + +const safeDetails = ( + error: AnalysisError, +): Readonly> => { + if (error instanceof AnalysisInputError) + return { operation: error.operation }; + if (error instanceof AnalysisOutputError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof AnalysisCapabilityUnavailableError) + return { + providerId: error.providerId, + operation: error.operation, + reason: error.reason, + }; + if (error instanceof AnalysisCancelledError) + return { operation: error.operation }; + if (error instanceof AnalysisTimeoutError) + return { operation: error.operation, timeoutMs: error.timeoutMs }; + if (error instanceof ProviderSelectionError) + return { operation: error.operation }; + if (error instanceof ProviderAdapterError) + return { providerId: error.providerId, operation: error.operation }; + if (error instanceof ArtifactOperationError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof EvidenceLimitError) + return { limit: error.limit, maximum: error.maximum }; + if (error instanceof EvidenceFileError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof UnknownRegistryError) return { reason: error.reason }; + if (error instanceof HopperTimeoutError) + return { timeoutMs: error.timeoutMs }; + if (error instanceof HopperRemoteError) + return { code: error.code, safeMessage: error.safeMessage }; + if (error instanceof HopperProcessError) return { exitCode: error.exitCode }; + return {}; +}; + +const KNOWN_ERROR_TAGS = { + AnalysisProtocolError: true, + AnalysisInputError: true, + AnalysisOutputError: true, + AnalysisCapabilityUnavailableError: true, + AnalysisCancelledError: true, + AnalysisTimeoutError: true, + ProviderSelectionError: true, + ProviderAdapterError: true, + ArtifactOperationError: true, + ProcessCaptureError: true, + EvidenceIntegrityError: true, + EvidenceLimitError: true, + EvidenceFileError: true, + UnknownRegistryError: true, + HopperTimeoutError: true, + HopperCancelledError: true, + HopperProtocolError: true, + HopperRemoteError: true, + HopperProcessError: true, + HopperStartError: true, + ConfigurationError: true, + NoBinaryOpenError: true, + BinaryTargetError: true, +} as const satisfies Readonly>; + +const assertKnownTag = (tag: AnalysisErrorTag): void => { + if (KNOWN_ERROR_TAGS[tag] !== true) + throw new TypeError("Unknown analysis error tag"); +}; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 74a863065..8e487cfbe 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -15,11 +15,32 @@ const providerSchema = z.object({ const subjectSchema = z.object({ name: z.string().min(1), digest: z.object({ sha256: digestSchema }), - format: z.enum(["hopper", "mach-o", "elf", "pe"]), + format: z.enum([ + "hopper", + "mach-o", + "elf", + "pe", + "zip", + "ipa", + "apk", + "asar", + "dmg", + "pkg", + "plist", + "javascript", + "source-map", + "directory", + "file", + "unknown", + "mach-o-universal", + "javascript-bundle", + "entitlements", + ]), architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(), local_path: z.string(), }); -const locationSchema = z.discriminatedUnion("kind", [ +/** Source location attached to an evidence observation. */ +const evidenceLocationSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("address"), address: z.string().min(1) }), z.object({ kind: z.literal("address-range"), @@ -27,6 +48,12 @@ const locationSchema = z.discriminatedUnion("kind", [ end: z.string().min(1), }), z.object({ kind: z.literal("artifact-path"), path: z.string().min(1) }), + z.object({ kind: z.literal("file-offset"), offset: z.number().int().min(0) }), + z.object({ + kind: z.literal("file-offset-range"), + start: z.number().int().min(0), + end: z.number().int().min(0), + }), ]); const evidenceAuthoritySchema = z.enum([ @@ -53,18 +80,26 @@ export const evidenceSchema = z.object({ predicate_type: z.string().min(1), operation: z.string().min(1), parameters: z.record(z.string(), jsonValueSchema), - result: jsonValueSchema, - raw_payload_sha256: digestSchema.nullable(), + raw_result: jsonValueSchema.nullable(), + normalized_result: jsonValueSchema, confidence: z.enum(["observed", "derived", "inferred"]), authority: evidenceAuthoritySchema, environment: executionEnvironmentSchema.nullable(), limitations: z.array(z.string()), - locations: z.array(locationSchema), + locations: z.array(evidenceLocationSchema), evidence_links: z.array(z.string().regex(/^ev_[a-f0-9]{64}$/u)), }); export type Evidence = z.infer; -type EvidenceLocation = z.infer; +export type EvidenceLocation = z.infer; + +/** Minimal immutable local artifact identity accepted by Evidence v2. */ +export interface EvidenceSubjectTarget { + readonly path: string; + readonly sha256: string; + readonly format: z.infer["format"]; + readonly architecture?: "x86" | "x86_64" | "arm" | "arm64"; +} type EvidenceAuthority = z.infer; type ExecutionEnvironment = z.infer; @@ -79,7 +114,7 @@ export interface EvidenceObservation { readonly operation: string; readonly parameters: Readonly>; readonly result: JsonValue; - readonly redactedRawPayload?: JsonValue; + readonly rawResult?: JsonValue; readonly confidence?: "observed" | "derived" | "inferred"; readonly authority?: EvidenceAuthority; readonly environment?: ExecutionEnvironment | null; @@ -115,7 +150,8 @@ const semanticProjection = ( predicate_type: evidence.predicate_type, operation: evidence.operation, parameters: evidence.parameters, - result: evidence.result, + raw_result: evidence.raw_result, + normalized_result: evidence.normalized_result, confidence: evidence.confidence, authority: evidence.authority, environment: evidence.environment, @@ -130,6 +166,15 @@ const computeEvidenceId = (evidence: Omit): string => /** Parse evidence and reject a syntactically valid but tampered semantic ID. */ export const parseEvidence = (input: unknown): Evidence => { + if ( + typeof input === "object" && + input !== null && + "schema_version" in input && + input.schema_version !== 2 + ) + throw new TypeError( + `Unsupported evidence schema_version ${String(input.schema_version)}; Evidence v1 is not accepted. Produce Evidence v2.`, + ); const evidence = evidenceSchema.parse(input); const { evidence_id: evidenceId, ...withoutId } = evidence; if (computeEvidenceId(withoutId) !== evidenceId) @@ -141,7 +186,7 @@ export const parseEvidence = (input: unknown): Evidence => { /** Build deterministic Evidence v2 from an immutable artifact subject. */ export const createEvidence = ( - target: BinaryTarget | undefined, + target: EvidenceSubjectTarget | BinaryTarget | undefined, provider: EvidenceProvider, observation: EvidenceObservation, ): Evidence => { @@ -173,7 +218,8 @@ export const createEvidence = ( predicate_type: observation.predicateType ?? "rea.analysis/v2", operation: observation.operation, parameters: observation.parameters, - result: observation.result, + raw_result: observation.rawResult ?? null, + normalized_result: observation.result, confidence: observation.confidence ?? "observed", authority: observation.authority ?? "shipped-artifact", environment: observation.environment ?? null, @@ -190,9 +236,5 @@ export const createEvidence = ( ...semantic, evidence_id: `ev_${sha256(canonicalJson(semantic))}`, subject, - raw_payload_sha256: - observation.redactedRawPayload === undefined - ? null - : sha256(canonicalJson(observation.redactedRawPayload)), }); }; diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 7b4a9ce03..b278fe5ec 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -1,20 +1,268 @@ import { z } from "zod"; +import canonicalize from "canonicalize"; -import { evidenceSchema, type Evidence } from "./evidence.js"; +import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; +import { + residualUnknownSchema, + type ResidualUnknown, +} from "./residualUnknown.js"; + +const artifactManifestSchema = evidenceSchema.shape.subject.unwrap().pick({ + digest: true, + format: true, + architecture: true, +}); +const providerManifestSchema = evidenceSchema.shape.provider; +const environmentManifestSchema = evidenceSchema.shape.environment.unwrap(); +const scenarioManifestSchema = z.object({ + evidence_id: evidenceSchema.shape.evidence_id, + operation: z.string().min(1), + authority: z.literal("controlled-replay"), +}); +const captureManifestSchema = z.object({ + evidence_id: evidenceSchema.shape.evidence_id, + predicate_type: z.string().min(1), +}); export const evidenceBundleSchema = z.object({ - bundle_version: z.literal(1), + bundle_version: z.literal(2), + artifacts: z.array(artifactManifestSchema), + providers: z.array(providerManifestSchema), + environments: z.array(environmentManifestSchema), + scenarios: z.array(scenarioManifestSchema), + captures: z.array(captureManifestSchema), + unknowns: z.array(residualUnknownSchema), records: z.array(evidenceSchema), }); export type EvidenceBundle = z.infer; +export interface EvidenceFilePolicy { + readonly roots: readonly string[]; + readonly maxBytes: number; + readonly maxDepth: number; + readonly maxStringLength: number; + readonly maxNodes: number; +} + /** Project records into a deterministic bundle whose order has no semantics. */ export const createEvidenceBundle = ( records: readonly Evidence[], -): EvidenceBundle => ({ - bundle_version: 1, - records: [...records].sort((left, right) => + unknowns: readonly ResidualUnknown[] = [], +): EvidenceBundle => { + const sortedRecords = [...records].sort((left, right) => left.evidence_id.localeCompare(right.evidence_id), - ), -}); + ); + return { + bundle_version: 2, + artifacts: uniqueSorted( + sortedRecords.flatMap(({ subject }) => + subject === null + ? [] + : [ + { + digest: subject.digest, + format: subject.format, + architecture: subject.architecture, + }, + ], + ), + ), + providers: uniqueSorted(sortedRecords.map(({ provider }) => provider)), + environments: uniqueSorted( + sortedRecords.flatMap(({ environment }) => + environment === null ? [] : [environment], + ), + ), + scenarios: sortedRecords.flatMap((evidence) => + evidence.authority === "controlled-replay" + ? [ + { + evidence_id: evidence.evidence_id, + operation: evidence.operation, + authority: evidence.authority, + }, + ] + : [], + ), + captures: sortedRecords.flatMap((evidence) => + evidence.predicate_type.startsWith("rea.process-capture/") + ? [ + { + evidence_id: evidence.evidence_id, + predicate_type: evidence.predicate_type, + }, + ] + : [], + ), + unknowns: [...unknowns].sort( + (left, right) => + left.unknown_id.localeCompare(right.unknown_id) || + left.revision - right.revision, + ), + records: sortedRecords, + }; +}; + +/** Parse records, verify semantic IDs, and reject inconsistent manifests. */ +export const parseEvidenceBundle = (input: unknown): EvidenceBundle => { + const parsed = evidenceBundleSchema.parse(input); + const recordIds = parsed.records.map(({ evidence_id: id }) => id); + if (new Set(recordIds).size !== recordIds.length) + throw new TypeError("Evidence bundle contains duplicate record IDs"); + validateUnknownGraph(parsed.unknowns, parsed.records); + const canonical = createEvidenceBundle( + parsed.records.map(parseEvidence), + parsed.unknowns, + ); + if (JSON.stringify(parsed) !== JSON.stringify(canonical)) + throw new TypeError("Evidence bundle manifests are not canonical"); + return canonical; +}; + +const validateUnknownGraph = ( + unknowns: readonly ResidualUnknown[], + records: readonly Evidence[], +): void => { + const evidenceById = new Map( + records.map((record) => [record.evidence_id, record]), + ); + const histories = new Map(); + for (const unknown of unknowns) { + const history = histories.get(unknown.unknown_id) ?? []; + history.push(unknown); + histories.set(unknown.unknown_id, history); + } + const unknownById = new Map(); + for (const [id, unordered] of histories) { + const history = [...unordered].sort( + (left, right) => left.revision - right.revision, + ); + for (const [index, revision] of history.entries()) { + if (revision.revision !== index + 1) + throw new TypeError( + `Residual unknown ${id} revision history has a gap`, + ); + if ( + index > 0 && + revision.previous_revision_digest !== + history[index - 1]?.revision_digest + ) + throw new TypeError(`Residual unknown ${id} revision chain is broken`); + } + const head = history.at(-1); + if (head === undefined) + throw new TypeError("Residual unknown revision history is empty"); + unknownById.set(id, head); + } + for (const unknown of unknowns) { + const referencedEvidence = [ + ...unknown.supporting_evidence_ids, + ...unknown.contradicting_evidence_ids, + ...unknown.mutation_evidence_ids, + ...(unknown.resolution?.evidence_ids ?? []), + ]; + for (const evidenceId of referencedEvidence) + if (!evidenceById.has(evidenceId)) + throw new TypeError( + `Residual unknown references missing evidence ${evidenceId}`, + ); + for (const relationship of unknown.relationships) + if (!unknownById.has(relationship.unknown_id)) + throw new TypeError( + `Residual unknown references missing unknown ${relationship.unknown_id}`, + ); + if (unknown.resolution?.disposition === "verified") + validateVerifiedResolution(unknown, evidenceById); + } + rejectDependencyCycles(unknownById); +}; + +const validateVerifiedResolution = ( + unknown: ResidualUnknown, + evidenceById: ReadonlyMap, +): void => { + if (unknown.contradicting_evidence_ids.length > 0) + throw new TypeError( + `Residual unknown ${unknown.unknown_id} retains contradicting evidence`, + ); + const qualifies = unknown.resolution?.evidence_ids.some((evidenceId) => { + const evidence = evidenceById.get(evidenceId); + return ( + unknown.supporting_evidence_ids.includes(evidenceId) && + evidence !== undefined && + evidence.predicate_type !== "rea.residual-unknown-mutation/v1" && + evidenceQualifies(unknown, evidence) + ); + }); + if (qualifies !== true) + throw new TypeError( + `Residual unknown ${unknown.unknown_id} has no qualifying resolution evidence`, + ); +}; + +const evidenceQualifies = ( + unknown: ResidualUnknown, + evidence: Evidence, +): boolean => { + if ( + unknown.required_authority !== null && + evidence.authority !== unknown.required_authority + ) + return false; + const confidenceRank = { inferred: 1, derived: 2, observed: 3 } as const; + if ( + confidenceRank[evidence.confidence] < + confidenceRank[unknown.required_confidence] + ) + return false; + const requirement = unknown.required_environment; + if (requirement === null) return true; + if (evidence.environment === null) return false; + return ( + (requirement.id === null || requirement.id === evidence.environment.id) && + (requirement.platform === null || + requirement.platform === evidence.environment.platform) && + (requirement.architecture === null || + requirement.architecture === evidence.environment.architecture) && + (requirement.isolation === null || + requirement.isolation === evidence.environment.isolation) + ); +}; + +const rejectDependencyCycles = ( + unknownById: ReadonlyMap, +): void => { + const visited = new Set(); + const active = new Set(); + const visit = (id: string): void => { + if (active.has(id)) + throw new TypeError("Residual unknown dependency graph contains a cycle"); + if (visited.has(id)) return; + active.add(id); + const unknown = unknownById.get(id); + if (unknown === undefined) + throw new TypeError("Residual unknown dependency graph is inconsistent"); + for (const relationship of unknown.relationships) + if (relationship.type === "depends-on") visit(relationship.unknown_id); + active.delete(id); + visited.add(id); + }; + for (const id of unknownById.keys()) visit(id); +}; + +/** Encode a validated bundle as byte-stable RFC 8785 canonical JSON. */ +export const serializeEvidenceBundle = (bundle: EvidenceBundle): string => { + const serialized = canonicalize(parseEvidenceBundle(bundle)); + if (serialized === undefined) + throw new TypeError("Evidence bundle canonicalization failed"); + return serialized; +}; + +const uniqueSorted = (values: readonly Value[]): Value[] => { + const unique = new Map(); + for (const value of values) unique.set(JSON.stringify(value), value); + return [...unique.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, value]) => value); +}; diff --git a/src/domain/functionComparison.ts b/src/domain/functionComparison.ts new file mode 100644 index 000000000..ccfbe99eb --- /dev/null +++ b/src/domain/functionComparison.ts @@ -0,0 +1,509 @@ +import { diffLines } from "diff"; + +import { + parseFunctionEvidence, + type FunctionCollection, + type FunctionSnapshot, +} from "./functionDossierEvidence.js"; +import { + commentProjection, + combineCoverage, + functionMatch, + identityProjection, + isAutoName, + normalizeCfg, + project, + referenceProjection, + sorted, + stringAndNameProjection, +} from "./functionComparisonNormalization.js"; +import { + functionComparisonResultSchema, + type DimensionName, + type FunctionComparisonResult, + type FunctionDimension, +} from "./functionComparisonSchemas.js"; +import { + canonicalJson, + dimensionResult, + overallStatus, + summarize, + unresolvedDimension as buildUnresolvedDimension, +} from "./functionComparisonResults.js"; + +export { + functionComparisonInputSchema, + functionComparisonResultSchema, +} from "./functionComparisonSchemas.js"; + +const unresolvedDimension = ( + ...[dimension, status, links, leftCount, rightCount, limitations]: readonly [ + DimensionName, + "truncated" | "unknown", + readonly string[], + number | null, + number | null, + readonly string[], + ] +): FunctionDimension => + buildUnresolvedDimension({ + dimension, + status, + links, + leftCount, + rightCount, + limitations, + }); + +/** Compare two explicit function Evidence page sets without fuzzy matching. */ +export const compareFunctions = ( + leftInput: unknown, + rightInput: unknown, + offset: number, + limit: number, +): FunctionComparisonResult => { + const left = parseFunctionEvidence(leftInput); + const right = parseFunctionEvidence(rightInput); + const links = [ + ...left.evidence.map(({ evidence_id: id }) => id), + ...right.evidence.map(({ evidence_id: id }) => id), + ]; + const providersDiffer = + canonicalJson(left.provider) !== canonicalJson(right.provider); + const dimensions = compareDimensions(left, right, links, providersDiffer); + const match = functionMatch(left, right); + const changes = dimensions.filter(({ status }) => status !== "unchanged"); + const page = changes.slice(offset, offset + limit); + return functionComparisonResultSchema.parse({ + status: overallStatus(dimensions, match.status), + function_match: match, + left_subject_sha256: left.subject.digest.sha256, + right_subject_sha256: right.subject.digest.sha256, + summary: summarize(dimensions), + dimensions, + changes: { + items: page, + offset, + limit, + total: changes.length, + next_offset: + offset + page.length < changes.length ? offset + page.length : null, + }, + limitations: [ + ...new Set([ + ...left.limitations.map((item) => `Left: ${item}`), + ...right.limitations.map((item) => `Right: ${item}`), + ...(providersDiffer + ? [ + "Provider-specific pseudocode and assembly representations were not equated.", + ] + : []), + ]), + ].sort((a, b) => a.localeCompare(b)), + }); +}; + +const compareDimensions = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension[] => [ + compareIdentity(left, right, links, providersDiffer), + compareText( + "pseudocode", + left.pseudocode.text, + right.pseudocode.text, + left.pseudocode.complete, + right.pseudocode.complete, + links, + providersDiffer, + ), + compareText( + "assembly", + left.collections.assembly.items.join("\n"), + right.collections.assembly.items.join("\n"), + left.collections.assembly.complete, + right.collections.assembly.complete, + links, + providersDiffer, + left.collections.assembly.truncated || right.collections.assembly.truncated, + "Assembly is opaque provider text; relocation normalization is unavailable.", + ), + compareComments(left, right, links, providersDiffer), + compareCalls(left, right, links, providersDiffer), + compareReferences(left, right, links, providersDiffer), + compareStringsAndNames(left, right, links, providersDiffer), + compareCfg(left, right, links, providersDiffer), +]; + +const compareText = ( + ...[ + dimension, + left, + right, + leftComplete, + rightComplete, + links, + providersDiffer, + explicitTruncation, + limitation, + ]: readonly [ + "pseudocode" | "assembly", + string, + string, + boolean, + boolean, + readonly string[], + boolean, + boolean?, + string?, + ] +): FunctionDimension => { + const truncated = explicitTruncation ?? (!leftComplete || !rightComplete); + if (!leftComplete || !rightComplete || providersDiffer) + return unresolvedDimension( + dimension, + truncated ? "truncated" : "unknown", + links, + left.length, + right.length, + [ + ...(limitation === undefined ? [] : [limitation]), + ...(providersDiffer + ? ["Exact text comparison requires one provider identity."] + : []), + ], + ); + const changes = diffLines(left, right, { + timeout: 100, + maxEditLength: 10_000, + }); + if (changes === undefined) + return unresolvedDimension( + dimension, + "unknown", + links, + left.length, + right.length, + ["Bounded line diff exceeded its time or edit-distance limit."], + ); + const delta = changes.reduce( + (summary, change) => ({ + added_lines: + summary.added_lines + (change.added ? (change.count ?? 0) : 0), + removed_lines: + summary.removed_lines + (change.removed ? (change.count ?? 0) : 0), + hunks: summary.hunks + (change.added || change.removed ? 1 : 0), + }), + { added_lines: 0, removed_lines: 0, hunks: 0 }, + ); + return dimensionResult({ + dimension, + status: delta.hunks === 0 ? "unchanged" : "changed", + left, + right, + links, + providersDiffer, + leftCount: [...left].length, + rightCount: [...right].length, + textDelta: delta, + limitations: limitation === undefined ? [] : [limitation], + }); +}; + +const compareCollections = ( + ...[ + dimension, + left, + right, + leftCoverage, + rightCoverage, + links, + providersDiffer, + ]: readonly [ + DimensionName, + readonly unknown[], + readonly unknown[], + Pick, + Pick, + readonly string[], + boolean, + ] +): FunctionDimension => { + if (!leftCoverage.complete || !rightCoverage.complete) + return unresolvedDimension( + dimension, + leftCoverage.truncated || rightCoverage.truncated + ? "truncated" + : "unknown", + links, + left.length, + right.length, + [], + ); + return compareValues( + dimension, + sorted(left), + sorted(right), + links, + true, + true, + providersDiffer, + ); +}; + +const compareComments = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension => { + const leftValues = commentProjection(left); + const rightValues = commentProjection(right); + if (leftValues === null || rightValues === null) + return unresolvedDimension( + "comments", + "unknown", + links, + left.collections.comments.items.length, + right.collections.comments.items.length, + ["Comment locations could not be normalized relative to the function."], + ); + return compareCollections( + "comments", + leftValues, + rightValues, + left.collections.comments, + right.collections.comments, + links, + providersDiffer, + ); +}; + +const compareStringsAndNames = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension => { + const leftValues = stringAndNameProjection(left); + const rightValues = stringAndNameProjection(right); + if (leftValues === null || rightValues === null) + return unresolvedDimension("strings_names", "unknown", links, null, null, [ + "Reference source locations could not be normalized to function offsets.", + ]); + return compareCollections( + "strings_names", + leftValues, + rightValues, + combineCoverage( + left.collections.referenced_strings, + left.collections.referenced_names, + ), + combineCoverage( + right.collections.referenced_strings, + right.collections.referenced_names, + ), + links, + providersDiffer, + ); +}; + +const compareReferences = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension => { + const leftCoverage = combineCoverage( + left.collections.incoming_references, + left.collections.outgoing_references, + ); + const rightCoverage = combineCoverage( + right.collections.incoming_references, + right.collections.outgoing_references, + ); + if (!leftCoverage.complete || !rightCoverage.complete) + return unresolvedDimension( + "references", + leftCoverage.truncated || rightCoverage.truncated + ? "truncated" + : "unknown", + links, + leftCoverage.items.length, + rightCoverage.items.length, + [], + ); + const leftProjection = referenceProjection(left); + const rightProjection = referenceProjection(right); + if (leftProjection.length === 0 && rightProjection.length === 0) + return compareValues( + "references", + leftProjection, + rightProjection, + links, + true, + true, + providersDiffer, + ); + if (canonicalJson(leftProjection) !== canonicalJson(rightProjection)) + return dimensionResult({ + dimension: "references", + status: "changed", + left: leftProjection, + right: rightProjection, + links, + providersDiffer, + leftCount: leftProjection.length, + rightCount: rightProjection.length, + textDelta: null, + limitations: [ + "Reference endpoints differ; exact reference kinds remain unavailable.", + ], + }); + return unresolvedDimension( + "references", + "unknown", + links, + leftProjection.length, + rightProjection.length, + ["Reference kinds are unavailable in the current dossier contract."], + ); +}; + +const compareIdentity = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension => { + if (isAutoName(left.procedure.name) || isAutoName(right.procedure.name)) + return unresolvedDimension("identity", "unknown", links, null, null, [ + "Address-derived function names are not stable cross-version identity.", + ]); + return compareValues( + "identity", + identityProjection(left), + identityProjection(right), + links, + true, + true, + providersDiffer, + ); +}; + +const compareCalls = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension => { + const leftValues = callProjection(left); + const rightValues = callProjection(right); + if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name))) + return unresolvedDimension( + "calls", + "unknown", + links, + leftValues.length, + rightValues.length, + ["Address-derived callee or caller names cannot be matched safely."], + ); + return compareCollections( + "calls", + leftValues, + rightValues, + combineCoverage(left.collections.callers, left.collections.callees), + combineCoverage(right.collections.callers, right.collections.callees), + links, + providersDiffer, + ); +}; + +const callProjection = (snapshot: FunctionSnapshot) => [ + ...project(snapshot.collections.callers, ({ name }) => ({ + direction: "in" as const, + name, + })), + ...project(snapshot.collections.callees, ({ name }) => ({ + direction: "out" as const, + name, + })), +]; + +const compareCfg = ( + left: FunctionSnapshot, + right: FunctionSnapshot, + links: readonly string[], + providersDiffer: boolean, +): FunctionDimension => { + const leftBlocks = left.collections.basic_blocks; + const rightBlocks = right.collections.basic_blocks; + if (!leftBlocks.complete || !rightBlocks.complete) + return unresolvedDimension( + "cfg", + leftBlocks.truncated || rightBlocks.truncated ? "truncated" : "unknown", + links, + leftBlocks.items.length, + rightBlocks.items.length, + [], + ); + const leftGraph = normalizeCfg(leftBlocks.items); + const rightGraph = normalizeCfg(rightBlocks.items); + if (leftGraph === null || rightGraph === null) + return unresolvedDimension( + "cfg", + "unknown", + links, + leftBlocks.items.length, + rightBlocks.items.length, + ["CFG addresses could not be normalized to local block indices."], + ); + return compareValues( + "cfg", + leftGraph, + rightGraph, + links, + true, + true, + providersDiffer, + ); +}; + +const compareValues = ( + ...[ + dimension, + left, + right, + links, + leftComplete, + rightComplete, + providersDiffer, + ]: readonly [ + DimensionName, + unknown, + unknown, + readonly string[], + boolean, + boolean, + boolean, + ] +): FunctionDimension => { + if (!leftComplete || !rightComplete) + return unresolvedDimension(dimension, "unknown", links, null, null, []); + const leftJson = canonicalJson(left); + const rightJson = canonicalJson(right); + return dimensionResult({ + dimension, + status: leftJson === rightJson ? "unchanged" : "changed", + left, + right, + links, + providersDiffer, + leftCount: Array.isArray(left) ? left.length : null, + rightCount: Array.isArray(right) ? right.length : null, + textDelta: null, + limitations: [], + }); +}; diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts new file mode 100644 index 000000000..ea7d6cf73 --- /dev/null +++ b/src/domain/functionComparisonNormalization.ts @@ -0,0 +1,171 @@ +import canonicalize from "canonicalize"; + +import type { + FunctionCollection, + FunctionSnapshot, +} from "./functionDossierEvidence.js"; +import type { FunctionComparisonResult } from "./functionComparisonSchemas.js"; + +export const identityProjection = (snapshot: FunctionSnapshot) => ({ + name: snapshot.procedure.name, + signature: snapshot.procedure.signature, + locals: snapshot.procedure.locals + .map(({ description }) => description) + .sort(), +}); + +export const functionMatch = ( + left: FunctionSnapshot, + right: FunctionSnapshot, +): FunctionComparisonResult["function_match"] => { + if ( + !isAutoName(left.procedure.name) && + left.procedure.name === right.procedure.name + ) + return { + status: "matched", + method: "symbol", + left_name: left.procedure.name, + right_name: right.procedure.name, + }; + return { + status: + left.procedure.name === right.procedure.name ? "ambiguous" : "mismatched", + method: "explicit", + left_name: left.procedure.name, + right_name: right.procedure.name, + }; +}; + +export const normalizeCfg = ( + blocks: FunctionSnapshot["collections"]["basic_blocks"]["items"], +): readonly unknown[] | null => { + const parsed = blocks.map((block) => ({ + block, + start: parseAddress(block.start), + end: parseAddress(block.end), + })); + if (parsed.some(({ start, end }) => start === null || end === null)) + return null; + const ordered = parsed.sort((left, right) => + (left.start ?? 0n) < (right.start ?? 0n) ? -1 : 1, + ); + if (new Set(ordered.map(({ block }) => block.start)).size !== ordered.length) + return null; + if (ordered.some(({ start, end }) => (end ?? 0n) <= (start ?? 0n))) + return null; + const indices = new Map( + ordered.map(({ block }, index) => [block.start, index]), + ); + const graph = []; + for (const { block, start, end } of ordered) { + const successors = block.successors.map((address) => indices.get(address)); + if (successors.some((index) => index === undefined)) return null; + graph.push({ + size: String((end ?? 0n) - (start ?? 0n)), + successors: successors.sort((left, right) => (left ?? 0) - (right ?? 0)), + }); + } + return graph; +}; + +export const referenceProjection = (snapshot: FunctionSnapshot) => + sorted([ + ...snapshot.collections.incoming_references.items.map((item) => ({ + direction: "in", + source: item.source_procedure?.name ?? null, + target: item.target_procedure?.name ?? null, + })), + ...snapshot.collections.outgoing_references.items.map((item) => ({ + direction: "out", + source: item.source_procedure?.name ?? null, + target: item.target_procedure?.name ?? null, + })), + ]); + +export const commentProjection = ( + snapshot: FunctionSnapshot, +): readonly unknown[] | null => { + const values = snapshot.collections.comments.items.map( + ({ address, kind, text }) => ({ + offset: relativeAddress(address, snapshot.procedure.address), + kind, + text, + }), + ); + return values.some(({ offset }) => offset === null) ? null : sorted(values); +}; + +export const stringAndNameProjection = ( + snapshot: FunctionSnapshot, +): readonly unknown[] | null => { + const values = [ + ...snapshot.collections.referenced_strings.items.map( + ({ source_address: sourceAddress, value }) => ({ + kind: "string", + source_offset: relativeAddress( + sourceAddress, + snapshot.procedure.address, + ), + value, + }), + ), + ...snapshot.collections.referenced_names.items.map( + ({ source_address: sourceAddress, value }) => ({ + kind: "name", + source_offset: relativeAddress( + sourceAddress, + snapshot.procedure.address, + ), + value, + }), + ), + ]; + return values.some(({ source_offset: offset }) => offset === null) + ? null + : sorted(values); +}; + +export const sorted = (values: readonly unknown[]): readonly unknown[] => + [...values].sort((left, right) => + canonicalJson(left).localeCompare(canonicalJson(right)), + ); + +export const combineCoverage = ( + left: FunctionCollection, + right: FunctionCollection, +): FunctionCollection => ({ + items: [...left.items, ...right.items], + total: + left.total === null || right.total === null + ? null + : left.total + right.total, + complete: left.complete && right.complete, + truncated: left.truncated || right.truncated, +}); + +export const project = ( + collection: FunctionCollection, + mapper: (item: Input) => Output, +): Output[] => collection.items.map(mapper); + +export const isAutoName = (name: string): boolean => + /^(?:sub_[0-9a-f]+|fcn\.)/iu.test(name); + +const parseAddress = (value: string): bigint | null => + /^0x[0-9a-f]+$/iu.test(value) ? BigInt(value) : null; + +const relativeAddress = (value: string, base: string): string | null => { + const parsed = parseAddress(value); + const parsedBase = parseAddress(base); + return parsed === null || parsedBase === null || parsed < parsedBase + ? null + : String(parsed - parsedBase); +}; + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Function normalization could not canonicalize data"); + return encoded; +}; diff --git a/src/domain/functionComparisonResults.ts b/src/domain/functionComparisonResults.ts new file mode 100644 index 000000000..f51945c11 --- /dev/null +++ b/src/domain/functionComparisonResults.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { + DimensionName, + FunctionComparisonResult, + FunctionDimension, +} from "./functionComparisonSchemas.js"; + +export interface DimensionResultInput { + readonly dimension: DimensionName; + readonly status: "unchanged" | "changed"; + readonly left: unknown; + readonly right: unknown; + readonly links: readonly string[]; + readonly providersDiffer: boolean; + readonly leftCount: number | null; + readonly rightCount: number | null; + readonly textDelta: FunctionDimension["text_delta"]; + readonly limitations: readonly string[]; +} + +export const dimensionResult = ( + input: DimensionResultInput, +): FunctionDimension => ({ + dimension: input.dimension, + status: input.status, + left_digest: digest(input.left), + right_digest: digest(input.right), + left_count: input.leftCount, + right_count: input.rightCount, + text_delta: input.textDelta, + conclusion_kind: + input.status === "changed" && input.providersDiffer + ? "contradiction" + : "derived_relationship", + evidence_links: [...input.links], + limitations: [...input.limitations], +}); + +export const unresolvedDimension = (input: { + readonly dimension: DimensionName; + readonly status: "truncated" | "unknown"; + readonly links: readonly string[]; + readonly leftCount: number | null; + readonly rightCount: number | null; + readonly limitations: readonly string[]; +}): FunctionDimension => ({ + dimension: input.dimension, + status: input.status, + left_digest: null, + right_digest: null, + left_count: input.leftCount, + right_count: input.rightCount, + text_delta: null, + conclusion_kind: "unresolved_branch", + evidence_links: [...input.links], + limitations: [...input.limitations], +}); + +export const overallStatus = ( + dimensions: readonly FunctionDimension[], + match: FunctionComparisonResult["function_match"]["status"], +): FunctionComparisonResult["status"] => { + if (dimensions.some(({ status }) => status === "truncated")) + return "truncated"; + if ( + match === "ambiguous" || + match === "unknown" || + dimensions.some(({ status }) => status === "unknown") + ) + return "unknown"; + if (match === "mismatched") return "changed"; + return dimensions.some(({ status }) => status === "changed") + ? "changed" + : "unchanged"; +}; + +export const summarize = (dimensions: readonly FunctionDimension[]) => ({ + unchanged: dimensions.filter(({ status }) => status === "unchanged").length, + changed: dimensions.filter(({ status }) => status === "changed").length, + truncated: dimensions.filter(({ status }) => status === "truncated").length, + unknown: dimensions.filter(({ status }) => status === "unknown").length, +}); + +export const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Function comparison could not canonicalize data"); + return encoded; +}; + +const digest = (value: unknown): string => + createHash("sha256").update(canonicalJson(value)).digest("hex"); diff --git a/src/domain/functionComparisonSchemas.ts b/src/domain/functionComparisonSchemas.ts new file mode 100644 index 000000000..224414a77 --- /dev/null +++ b/src/domain/functionComparisonSchemas.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { evidenceSchema } from "./evidence.js"; + +const evidenceInput = z.union([ + evidenceSchema, + z.array(evidenceSchema).min(1).max(100), +]); +const comparisonStatusSchema = z.enum([ + "unchanged", + "added", + "removed", + "changed", + "truncated", + "unknown", +]); +const dimensionNameSchema = z.enum([ + "identity", + "pseudocode", + "assembly", + "comments", + "calls", + "references", + "strings_names", + "cfg", +]); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); + +/** Strict bounded input for explicit function-to-function comparison. */ +export const functionComparisonInputSchema = z.object({ + left: evidenceInput, + right: evidenceInput, + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + unknown_registry_approved: z.literal(true).optional(), +}); + +const textDeltaSchema = z.object({ + added_lines: z.number().int().min(0), + removed_lines: z.number().int().min(0), + hunks: z.number().int().min(0), +}); + +const functionDimensionSchema = z.object({ + dimension: dimensionNameSchema, + status: comparisonStatusSchema, + left_digest: digestSchema.nullable(), + right_digest: digestSchema.nullable(), + left_count: z.number().int().min(0).nullable(), + right_count: z.number().int().min(0).nullable(), + text_delta: textDeltaSchema.nullable(), + conclusion_kind: z.enum([ + "derived_relationship", + "contradiction", + "unresolved_branch", + ]), + evidence_links: z.array(evidenceIdSchema).min(2).max(200), + limitations: z.array(z.string()), +}); + +/** Deterministic dimension-classified function comparison Evidence payload. */ +export const functionComparisonResultSchema = z.object({ + status: comparisonStatusSchema, + function_match: z.object({ + status: z.enum(["matched", "mismatched", "ambiguous", "unknown"]), + method: z.enum(["explicit", "symbol"]), + left_name: z.string(), + right_name: z.string(), + }), + left_subject_sha256: digestSchema, + right_subject_sha256: digestSchema, + summary: z.object({ + unchanged: z.number().int().min(0), + changed: z.number().int().min(0), + truncated: z.number().int().min(0), + unknown: z.number().int().min(0), + }), + dimensions: z.array(functionDimensionSchema).length(8), + changes: z.object({ + items: z.array(functionDimensionSchema).max(100), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + limitations: z.array(z.string()), +}); + +export type FunctionComparisonResult = z.infer< + typeof functionComparisonResultSchema +>; +export type FunctionDimension = z.infer; +export type DimensionName = z.infer; diff --git a/src/domain/functionDossierEvidence.ts b/src/domain/functionDossierEvidence.ts new file mode 100644 index 000000000..8cbcc00bc --- /dev/null +++ b/src/domain/functionDossierEvidence.ts @@ -0,0 +1,261 @@ +import canonicalize from "canonicalize"; + +import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; +import { parseEvidence, type Evidence } from "./evidence.js"; +import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js"; + +const MAX_PAGES = 100; +const MAX_ITEMS = 50_000; +const MAX_PSEUDOCODE_CHARS = 2_000_000; + +const DOSSIER_COLLECTION_FIELDS = [ + "assembly", + "comments", + "callers", + "callees", + "incoming_references", + "outgoing_references", + "referenced_strings", + "referenced_names", + "basic_blocks", +] as const; + +type DossierCollectionField = (typeof DOSSIER_COLLECTION_FIELDS)[number]; +type DossierItem = + FunctionDossier[Field]["items"][number]; + +export interface FunctionCollection { + readonly items: readonly Item[]; + readonly total: number | null; + readonly complete: boolean; + readonly truncated: boolean; +} + +type FunctionCollections = { + readonly [Field in DossierCollectionField]: FunctionCollection< + DossierItem + >; +}; + +/** Verified, bounded function observation assembled from Evidence pages. */ +export interface FunctionSnapshot { + readonly evidence: readonly Evidence[]; + readonly procedure: FunctionDossier["procedure"]; + readonly provider: Evidence["provider"]; + readonly subject: NonNullable; + readonly pseudocode: { + readonly text: string; + readonly total: number; + readonly complete: boolean; + readonly truncated: boolean; + }; + readonly collections: FunctionCollections; + readonly instructionScan: FunctionDossier["instruction_scan"]; + readonly limitations: readonly string[]; +} + +interface DossierPage { + readonly evidence: Omit & { + readonly subject: NonNullable; + }; + readonly dossier: FunctionDossier; + readonly parameters: ReturnType< + typeof enhancedInputSchemas.analyze_function.parse + >; +} + +/** Parse and assemble up to 100 mutually consistent analyze_function pages. */ +export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => { + const values = Array.isArray(input) ? input : [input]; + if (values.length === 0 || values.length > MAX_PAGES) + throw new TypeError("Function comparison requires 1 to 100 Evidence pages"); + const pages = values.map(parsePage); + assertPageGroup(pages); + const first = pages[0]; + if (first === undefined) + throw new TypeError("Function comparison requires Evidence pages"); + const pseudocode = mergePseudocode(pages); + const collections: FunctionCollections = { + assembly: mergeCollection(pages, "assembly"), + comments: mergeCollection(pages, "comments"), + callers: mergeCollection(pages, "callers"), + callees: mergeCollection(pages, "callees"), + incoming_references: mergeCollection(pages, "incoming_references"), + outgoing_references: mergeCollection(pages, "outgoing_references"), + referenced_strings: mergeCollection(pages, "referenced_strings"), + referenced_names: mergeCollection(pages, "referenced_names"), + basic_blocks: mergeCollection(pages, "basic_blocks"), + }; + return { + evidence: pages.map(({ evidence }) => evidence), + procedure: first.dossier.procedure, + provider: first.evidence.provider, + subject: first.evidence.subject, + pseudocode, + collections, + instructionScan: first.dossier.instruction_scan, + limitations: [ + ...new Set(pages.flatMap(({ evidence }) => evidence.limitations)), + ].sort((left, right) => left.localeCompare(right)), + }; +}; + +const parsePage = (input: unknown): DossierPage => { + const evidence = parseEvidence(input); + if (evidence.operation !== "analyze_function") + throw new TypeError( + "Function comparison requires analyze_function Evidence", + ); + if (evidence.subject === null) + throw new TypeError("Function comparison requires artifact-bound Evidence"); + return { + evidence: { ...evidence, subject: evidence.subject }, + dossier: functionDossierSchema.parse(evidence.normalized_result), + parameters: enhancedInputSchemas.analyze_function.parse( + evidence.parameters, + ), + }; +}; + +const assertPageGroup = (pages: readonly DossierPage[]): void => { + const first = pages[0]; + if (first === undefined) return; + const invariant = pageInvariant(first); + for (const page of pages) + if (canonicalJson(pageInvariant(page)) !== canonicalJson(invariant)) + throw new TypeError( + "Function Evidence pages mix subjects, providers, or immutable analysis controls", + ); +}; + +const pageInvariant = (page: DossierPage) => ({ + subject: { + digest: page.evidence.subject?.digest, + format: page.evidence.subject?.format, + architecture: page.evidence.subject?.architecture, + }, + provider: page.evidence.provider, + procedure: page.dossier.procedure, + requested_procedure: page.parameters.procedure, + include_assembly: page.parameters.include_assembly, + max_instructions: page.parameters.max_instructions, + instruction_scan: page.dossier.instruction_scan, +}); + +const mergePseudocode = ( + pages: readonly DossierPage[], +): FunctionSnapshot["pseudocode"] => { + const totals = new Set( + pages.map(({ dossier }) => dossier.pseudocode.total_chars), + ); + if (totals.size !== 1) + throw new TypeError("Function pseudocode pages disagree on total length"); + const total = pages[0]?.dossier.pseudocode.total_chars ?? 0; + if (total > MAX_PSEUDOCODE_CHARS) + throw new TypeError("Function pseudocode exceeds comparison limit"); + const characters = new Map(); + for (const page of pages) { + const offset = page.parameters.pseudocode_offset; + for (const [index, character] of [ + ...page.dossier.pseudocode.text, + ].entries()) + mergeIndexed(characters, offset + index, character, "pseudocode"); + if (characters.size > MAX_PSEUDOCODE_CHARS) + throw new TypeError("Function pseudocode exceeds comparison limit"); + } + const complete = hasCompleteRange(characters, total); + return { + text: orderedValues(characters).join(""), + total, + complete, + truncated: !complete, + }; +}; + +const mergeCollection = ( + pages: readonly DossierPage[], + field: Field, +): FunctionCollection> => { + const totals = new Set(pages.map(({ dossier }) => dossier[field].total)); + if (totals.size !== 1) + throw new TypeError(`Function ${field} pages disagree on total count`); + const total = pages[0]?.dossier[field].total ?? null; + if (total !== null && total > MAX_ITEMS) + throw new TypeError(`Function ${field} exceeds comparison item limit`); + const items = new Map>(); + for (const page of pages) { + const offset = collectionOffset(page, field); + for (const [index, item] of page.dossier[field].items.entries()) + mergeIndexed(items, offset + index, item, field); + if (items.size > MAX_ITEMS) + throw new TypeError(`Function ${field} exceeds comparison item limit`); + } + const scanLimited = + field !== "assembly" && pageDependsOnScan(field) + ? pages.some(({ dossier }) => dossier.instruction_scan.truncated) + : false; + const unavailable = + field === "assembly" && + pages.every(({ parameters }) => !parameters.include_assembly); + const complete = + !scanLimited && + !unavailable && + total !== null && + hasCompleteRange(items, total); + return { + items: orderedValues(items), + total, + complete, + truncated: scanLimited || (!unavailable && !complete), + }; +}; + +const collectionOffset = ( + page: DossierPage, + field: DossierCollectionField, +): number => + field === "assembly" + ? page.parameters.assembly_offset + : page.parameters.collection_offset[field]; + +const pageDependsOnScan = (field: DossierCollectionField): boolean => + field !== "callers" && field !== "callees"; + +const mergeIndexed = ( + output: Map, + index: number, + value: Value, + field: string, +): void => { + const previous = output.get(index); + if ( + previous !== undefined && + canonicalJson(previous) !== canonicalJson(value) + ) + throw new TypeError( + `Function ${field} pages overlap with conflicting data`, + ); + output.set(index, value); +}; + +const hasCompleteRange = ( + values: ReadonlyMap, + total: number, +): boolean => { + if (values.size !== total) return false; + for (let index = 0; index < total; index += 1) + if (!values.has(index)) return false; + return true; +}; + +const orderedValues = (values: ReadonlyMap): Value[] => + [...values.entries()] + .sort(([left], [right]) => left - right) + .map(([, value]) => value); + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Function Evidence could not be canonicalized"); + return encoded; +}; diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index a1870e27d..79f551120 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { JsonValue } from "./jsonValue.js"; -import { AnalysisProtocolError, HopperProtocolError } from "./errors.js"; +import { AnalysisOutputError, HopperProtocolError } from "./errors.js"; import { err, ok, type Result } from "./result.js"; export interface AddressedName { @@ -13,6 +13,9 @@ export interface SegmentSummary { readonly name: string; readonly start: string; readonly end: string; + readonly readable: boolean | null; + readonly writable: boolean | null; + readonly executable: boolean | null; } export interface AddressedPage { @@ -30,6 +33,9 @@ const segmentSchema = z.object({ name: z.string().default(""), start: z.string().default(""), end: z.string().default(""), + readable: z.boolean().nullable().default(null), + writable: z.boolean().nullable().default(null), + executable: z.boolean().nullable().default(null), }); const addressedPageSchema = z.object({ items: z.array(z.object({ address: z.string(), value: z.string() })), @@ -42,6 +48,12 @@ const unavailableSchema = z const procedureIdentitySchema = z .object({ address: z.string(), name: z.string() }) .strict(); +const localVariableSchema = z + .object({ + description: z.string(), + provenance: z.literal("hopper-public-python-api"), + }) + .strict(); const boundedSchema = (item: T) => z .object({ @@ -84,14 +96,14 @@ const referenceEdgeSchema = z kind: unavailableSchema, }) .strict(); -const functionDossierSchema = z +export const functionDossierSchema = z .object({ procedure: z .object({ address: z.string(), name: z.string(), signature: z.string().nullable(), - locals: z.array(z.json()), + locals: z.array(localVariableSchema), }) .strict(), pseudocode: z @@ -104,7 +116,7 @@ const functionDossierSchema = z }) .strict() .superRefine((value, context) => { - if (value.returned_chars !== value.text.length) { + if (value.returned_chars !== [...value.text].length) { context.addIssue({ code: "custom", message: "returned_chars must equal the text length", @@ -173,17 +185,22 @@ const functionDossierSchema = z }) .strict(); +/** Strict analyzed-function dossier shared by provider and comparison boundaries. */ +export type FunctionDossier = z.infer; + /** Strictly parse a complete Hopper function dossier at the provider boundary. */ export const parseFunctionDossier = ( value: JsonValue, -): Result => { +): Result => { const parsed = functionDossierSchema.safeParse(value); return parsed.success ? ok(parsed.data) : err( - new AnalysisProtocolError("Invalid analyze_function provider output", { - cause: parsed.error, - }), + new AnalysisOutputError( + "analyze_function", + "provider output did not match the dossier contract", + { cause: parsed.error }, + ), ); }; diff --git a/src/domain/nativeInspection.ts b/src/domain/nativeInspection.ts new file mode 100644 index 000000000..7e93dc3b6 --- /dev/null +++ b/src/domain/nativeInspection.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; + +const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u); +const coverage = (item: Schema) => + z.object({ + items: z.array(item), + total: z.number().int().min(0).nullable(), + exhaustive: z.boolean(), + limitations: z.array(z.string()), + }); + +const nativeCommandInvocationSchema = z.object({ + tool: z.string().min(1), + command: z.array(z.string()), + tool_version: z.string().nullable(), + version_reason: z.string().nullable(), + executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + exit: z.object({ + code: z.number().int().nullable(), + signal: z.string().nullable(), + }), + stdout_bytes: z.number().int().min(0), + stderr_bytes: z.number().int().min(0), + stdout_truncated: z.boolean(), + stderr_truncated: z.boolean(), +}); + +const architectureSchema = z.object({ + name: z.string(), + cpu_type: z.string().nullable(), + cpu_subtype: z.string().nullable(), + file_offset: z.number().int().min(0).nullable(), + size: z.number().int().min(0).nullable(), + alignment: z.number().int().min(0).nullable(), +}); +const permissionSchema = z.object({ + read: z.boolean().nullable(), + write: z.boolean().nullable(), + execute: z.boolean().nullable(), + raw: z.string().nullable(), +}); +const sectionSchema = z.object({ + segment: z.string(), + name: z.string(), + address: hexAddress.nullable(), + size: z.number().int().min(0).nullable(), + file_offset: z.number().int().min(0).nullable(), + alignment: z.number().int().min(0).nullable(), + flags: z.array(z.string()), +}); +const segmentSchema = z.object({ + name: z.string(), + vm_address: hexAddress.nullable(), + vm_size: z.number().int().min(0).nullable(), + file_offset: z.number().int().min(0).nullable(), + file_size: z.number().int().min(0).nullable(), + maximum_permissions: permissionSchema, + initial_permissions: permissionSchema, + sections: coverage(sectionSchema), +}); +const symbolSchema = z.object({ + name: z.string(), + address: hexAddress.nullable(), + weak: z.boolean().nullable(), + reexport: z.boolean().nullable(), + source: z.string().nullable(), +}); +const dependencySchema = z.object({ + path: z.string(), + kind: z.string(), + current_version: z.string().nullable(), + compatibility_version: z.string().nullable(), +}); +const loadCommandSchema = z.object({ + index: z.number().int().min(0), + kind: z.string(), + file_offset: z.number().int().min(0).nullable(), + fields: z.record(z.string(), z.union([z.string(), z.number(), z.null()])), +}); +const buildMetadataSchema = z.object({ + platform: z.string().nullable(), + minimum_os: z.string().nullable(), + sdk: z.string().nullable(), + tools: z.array(z.object({ name: z.string(), version: z.string() })), +}); + +export const inspectMachoSchema = z.object({ + format: z.literal("mach-o"), + endian: z.enum(["little", "big"]).nullable(), + word_size: z.union([z.literal(32), z.literal(64)]).nullable(), + file_type: z.string().nullable(), + flags: z.array(z.string()), + uuid: z.string().nullable(), + entrypoints: coverage(z.object({ file_offset: z.number().int().min(0) })), + architectures: coverage(architectureSchema), + build_metadata: coverage(buildMetadataSchema), + load_commands: coverage(loadCommandSchema), + dependencies: coverage(dependencySchema), + imports: coverage(symbolSchema), + exports: coverage(symbolSchema), + segments: coverage(segmentSchema), + provenance: z.array(nativeCommandInvocationSchema), + limitations: z.array(z.string()), +}); + +export const inspectSignatureSchema = z.object({ + signed: z.boolean(), + identifier: z.string().nullable(), + team_identifier: z.string().nullable(), + format: z.string().nullable(), + cdhashes: z.array(z.string()), + hash_algorithms: z.array(z.string()), + authorities: z.array(z.string()), + designated_requirement: z.string().nullable(), + entitlements: z.unknown().nullable(), + timestamp: z.string().nullable(), + hardened_runtime: z.boolean().nullable(), + provenance: z.array(nativeCommandInvocationSchema), + limitations: z.array(z.string()), +}); + +export const inspectPlistSchema = z.object({ + format: z.enum(["xml", "binary", "json", "unknown"]), + value: z.unknown(), + bundle: z.object({ + identifier: z.string().nullable(), + executable: z.string().nullable(), + name: z.string().nullable(), + version: z.string().nullable(), + short_version: z.string().nullable(), + }), + source_path: z.string(), + provenance: z.array(nativeCommandInvocationSchema), + limitations: z.array(z.string()), +}); + +export const listArchitecturesSchema = z.object({ + architectures: coverage(architectureSchema), + provenance: z.array(nativeCommandInvocationSchema), + limitations: z.array(z.string()), +}); + +export const demangleSwiftSchema = z.object({ + symbols: z.array( + z.object({ + input: z.string(), + output: z.string(), + status: z.enum(["demangled", "unchanged", "invalid"]), + }), + ), + provenance: z.array(nativeCommandInvocationSchema), + limitations: z.array(z.string()), +}); + +export type NativeCommandInvocation = z.infer< + typeof nativeCommandInvocationSchema +>; +export type InspectSignature = z.infer; diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index a54257469..ed8554b86 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -3,6 +3,26 @@ import { z } from "zod"; const positiveBudget = z.number().int().positive(); const timedEventBase = { at_ms: z.number().int().nonnegative() }; +const environmentName = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u); +const reservedEnvironment = new Set([ + "HOME", + "TERM", + "REA_PROCESS_RUN_ID", + "REA_REPLAY_HTTP_URL", + "REA_REPLAY_WEBSOCKET_URL", +]); +const normalizationSchema = z.object({ + paths: z.boolean(), + pids: z.boolean(), + ports: z.boolean(), + time_bucket_ms: positiveBudget.max(60_000), + patterns: z.array( + z.object({ + pattern: z.string().max(500), + replacement: z.string().max(100), + }), + ), +}); /** Exact boundary schema for bounded dynamic process scenarios. */ export const processScenarioSchema = z @@ -12,12 +32,17 @@ export const processScenarioSchema = z .describe( "Explicit per-call acknowledgement that this operation launches the target", ), + unknown_registry_approved: z + .literal(true) + .optional() + .describe("Explicit approval to record capture residuals durably"), executable: z.string().startsWith("/"), arguments: z.array(z.string()).max(256).default([]), working_directory: z.string().startsWith("/"), - environment: z.record(z.string(), z.string()).default({}), - inherit_environment: z.array(z.string()).max(64).default([]), - secret_aliases: z.array(z.string()).max(64).default([]), + environment: z.record(environmentName, z.string()).default({}), + inherit_environment: z.array(environmentName).max(64).default([]), + secret_aliases: z.array(environmentName).max(64).default([]), + network_access: z.literal("host").default("host"), filesystem_roots: z.array(z.string().startsWith("/")).max(16).default([]), events: z .array( @@ -52,6 +77,10 @@ export const processScenarioSchema = z files: positiveBudget.max(100_000).default(10_000), file_bytes: positiveBudget.max(100_000_000).default(10_000_000), processes: positiveBudget.max(10_000).default(1_000), + protocol_events: positiveBudget.max(100_000).default(10_000), + protocol_body_bytes: positiveBudget.max(10_000_000).default(1_000_000), + connections: positiveBudget.max(1_000).default(100), + filesystem_depth: z.number().int().min(1).max(64).default(16), }) .default({ output_bytes: 1_000_000, @@ -59,6 +88,10 @@ export const processScenarioSchema = z files: 10_000, file_bytes: 10_000_000, processes: 1_000, + protocol_events: 10_000, + protocol_body_bytes: 1_000_000, + connections: 100, + filesystem_depth: 16, }), normalization: z .object({ @@ -92,6 +125,12 @@ export const processScenarioSchema = z path: z.string().startsWith("/"), status: z.number().int().min(100).max(599), body: z.string().max(1_000_000), + request_headers: z.record(z.string(), z.string()).default({}), + request_body: z.string().max(1_000_000).optional(), + response_headers: z.record(z.string(), z.string()).default({}), + delay_ms: z.number().int().nonnegative().max(30_000).default(0), + disconnect: z.boolean().default(false), + max_calls: z.number().int().min(1).max(100).default(1), }), ) .max(100) @@ -100,8 +139,33 @@ export const processScenarioSchema = z .array(z.string().max(1_000_000)) .max(100) .default([]), + websocket_connections: z + .array( + z.object({ + messages: z + .array( + z.object({ + data: z.string().max(1_000_000), + delay_ms: z + .number() + .int() + .nonnegative() + .max(30_000) + .default(0), + }), + ) + .max(100), + disconnect_after: z.boolean().default(false), + }), + ) + .max(100) + .default([]), }) - .default({ http: [], websocket_messages: [] }), + .default({ + http: [], + websocket_messages: [], + websocket_connections: [], + }), }) .strict() .superRefine((scenario, context) => { @@ -124,6 +188,34 @@ export const processScenarioSchema = z }); } } + const explicit = new Set(Object.keys(scenario.environment)); + for (const name of [...explicit, ...scenario.inherit_environment]) { + if (reservedEnvironment.has(name)) { + context.addIssue({ + code: "custom", + message: `${name} is reserved by the process adapter`, + path: ["environment", name], + }); + } + } + for (const name of scenario.inherit_environment) { + if (explicit.has(name)) { + context.addIssue({ + code: "custom", + message: "an environment name cannot be explicit and inherited", + path: ["inherit_environment"], + }); + } + } + for (const event of scenario.events) { + if (event.at_ms > scenario.timeout_ms) { + context.addIssue({ + code: "custom", + message: "event occurs after the scenario timeout", + path: ["events"], + }); + } + } }); /** A parsed, bounded dynamic process observation scenario. */ @@ -139,6 +231,7 @@ export interface ProcessExecutionPolicy { readonly executableRoots: readonly string[]; readonly workingRoots: readonly string[]; readonly allowedEnvironment: readonly string[]; + readonly allowExternalNetwork: boolean; } /** A safe, caller-visible process-policy decision. */ @@ -159,6 +252,11 @@ export const authorizeProcessScenario = ( ): ProcessPolicyDecision => { if (!policy.enabled) return { allowed: false, reason: "process capture is disabled" }; + if (scenario.network_access === "host" && !policy.allowExternalNetwork) + return { + allowed: false, + reason: "host network access is not approved by operator policy", + }; if ( !policy.executableRoots.some((root) => isWithin(scenario.executable, root)) ) { @@ -216,6 +314,13 @@ export interface FileState { readonly symlink_target: string | null; } +interface FileEffect { + readonly path: string; + readonly status: "created" | "modified" | "deleted" | "unchanged"; + readonly before: FileState | null; + readonly after: FileState | null; +} + /** A sampled owned-process observation; sampling cannot prove syscall completeness. */ export interface ProcessSample { readonly at_ms: number; @@ -227,32 +332,57 @@ export interface ProcessSample { /** A bounded loopback replay observation. */ export interface ProtocolEvent { readonly sequence: number; + readonly at_ms: number; readonly protocol: "http" | "websocket"; readonly direction: "request" | "response" | "received" | "sent"; readonly method: string | null; readonly path: string | null; readonly data: string; + readonly outcome: + | "matched" + | "unmatched" + | "script_exhausted" + | "disconnected"; } /** A bounded process observation with explicit incompleteness metadata. */ export interface ProcessCapture { - readonly schema_version: 1; + readonly schema_version: 2; + readonly normalization: z.infer; readonly frames: readonly TerminalFrame[]; readonly exit: { readonly code: number | null; readonly signal: number | null; + readonly reason: "exited" | "timeout" | "idle_timeout"; }; readonly process_samples: readonly ProcessSample[]; readonly protocol_events: readonly ProtocolEvent[]; readonly files_before: readonly FileState[]; readonly files_after: readonly FileState[]; + readonly filesystem_effects: readonly FileEffect[]; readonly truncated: boolean; readonly limitations: readonly string[]; + readonly residual_unknowns: readonly { + readonly scope: + | "terminal" + | "exit" + | "process" + | "filesystem" + | "protocol" + | "cleanup" + | "network"; + readonly reason: string; + }[]; + readonly cleanup: { + readonly owned_process_group: "verified"; + readonly temporary_root: "removed"; + }; } /** Exact serialized shape of a bounded process capture. */ export const processCaptureSchema: z.ZodType = z.object({ - schema_version: z.literal(1), + schema_version: z.literal(2), + normalization: normalizationSchema, frames: z.array( z.object({ sequence: z.number().int().nonnegative(), @@ -263,6 +393,7 @@ export const processCaptureSchema: z.ZodType = z.object({ exit: z.object({ code: z.number().int().nullable(), signal: z.number().int().nullable(), + reason: z.enum(["exited", "timeout", "idle_timeout"]), }), process_samples: z.array( z.object({ @@ -275,11 +406,18 @@ export const processCaptureSchema: z.ZodType = z.object({ protocol_events: z.array( z.object({ sequence: z.number().int().nonnegative(), + at_ms: z.number().int().nonnegative(), protocol: z.enum(["http", "websocket"]), direction: z.enum(["request", "response", "received", "sent"]), method: z.string().nullable(), path: z.string().nullable(), data: z.string(), + outcome: z.enum([ + "matched", + "unmatched", + "script_exhausted", + "disconnected", + ]), }), ), files_before: z.array( @@ -308,89 +446,62 @@ export const processCaptureSchema: z.ZodType = z.object({ symlink_target: z.string().nullable(), }), ), + filesystem_effects: z.array( + z.object({ + path: z.string(), + status: z.enum(["created", "modified", "deleted", "unchanged"]), + before: z + .object({ + path: z.string(), + type: z.enum(["file", "directory", "symlink", "other"]), + mode: z.number().int().nonnegative(), + size: z.number().int().nonnegative(), + sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + symlink_target: z.string().nullable(), + }) + .nullable(), + after: z + .object({ + path: z.string(), + type: z.enum(["file", "directory", "symlink", "other"]), + mode: z.number().int().nonnegative(), + size: z.number().int().nonnegative(), + sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + symlink_target: z.string().nullable(), + }) + .nullable(), + }), + ), truncated: z.boolean(), limitations: z.array(z.string()), + residual_unknowns: z.array( + z.object({ + scope: z.enum([ + "terminal", + "exit", + "process", + "filesystem", + "protocol", + "cleanup", + "network", + ]), + reason: z.string(), + }), + ), + cleanup: z.object({ + owned_process_group: z.literal("verified"), + temporary_root: z.literal("removed"), + }), }); -/** Comparison classification that never equates incomplete evidence. */ -type ComparisonStatus = - | "unchanged" - | "added" - | "removed" - | "changed" - | "truncated" - | "unknown"; - -/** Pure normalized comparison between two captures. */ -export interface ProcessCaptureComparison { - readonly status: ComparisonStatus; - readonly terminal: ComparisonStatus; - readonly exit: ComparisonStatus; - readonly filesystem: ComparisonStatus; - readonly protocol: ComparisonStatus; - readonly process: ComparisonStatus; - readonly limitations: readonly string[]; -} - -const stableFiles = (files: readonly FileState[]): string => - JSON.stringify( - [...files].sort((left, right) => left.path.localeCompare(right.path)), - ); - -/** Compare bounded captures without claiming equality for incomplete observations. */ -export const compareProcessCaptures = ( - left: ProcessCapture, - right: ProcessCapture, -): ProcessCaptureComparison => { - if (left.truncated || right.truncated) { - return { - status: "truncated", - terminal: "truncated", - exit: "truncated", - filesystem: "truncated", - protocol: "truncated", - process: "truncated", - limitations: ["At least one capture is truncated."], - }; - } - const terminal = - left.frames.map(({ data }) => data).join("") === - right.frames.map(({ data }) => data).join("") - ? "unchanged" - : "changed"; - const exit = - JSON.stringify(left.exit) === JSON.stringify(right.exit) - ? "unchanged" - : "changed"; - const filesystem = - stableFiles(left.files_after) === stableFiles(right.files_after) - ? "unchanged" - : "changed"; - const protocol = - JSON.stringify(left.protocol_events) === - JSON.stringify(right.protocol_events) - ? "unchanged" - : "changed"; - const process = - JSON.stringify(left.process_samples.map(({ command }) => command)) === - JSON.stringify(right.process_samples.map(({ command }) => command)) - ? "unchanged" - : "changed"; - const status = - terminal === "unchanged" && - exit === "unchanged" && - filesystem === "unchanged" && - protocol === "unchanged" && - process === "unchanged" - ? "unchanged" - : "changed"; - return { - status, - terminal, - exit, - filesystem, - protocol, - process, - limitations: [...left.limitations, ...right.limitations], - }; -}; +export { + compareProcessCaptures, + comparisonStatusSchema, + processCaptureComparisonSchema, +} from "./processComparison.js"; diff --git a/src/domain/processComparison.ts b/src/domain/processComparison.ts new file mode 100644 index 000000000..e7b541a68 --- /dev/null +++ b/src/domain/processComparison.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +import type { ProcessCapture } from "./processCapture.js"; + +/** Comparison classification that never equates incomplete evidence. */ +export const comparisonStatusSchema = z.enum([ + "unchanged", + "added", + "removed", + "changed", + "truncated", + "unknown", +]); +type ComparisonStatus = z.infer; + +/** Pure normalized comparison between two captures. */ +export const processCaptureComparisonSchema = z + .object({ + status: comparisonStatusSchema, + terminal: comparisonStatusSchema, + exit: comparisonStatusSchema, + filesystem: comparisonStatusSchema, + protocol: comparisonStatusSchema, + process: comparisonStatusSchema, + limitations: z.array(z.string()), + }) + .superRefine((comparison, context) => { + const dimensions = [ + comparison.terminal, + comparison.exit, + comparison.filesystem, + comparison.protocol, + comparison.process, + ]; + const expected = dimensions.includes("truncated") + ? "truncated" + : dimensions.some((status) => + ["added", "removed", "changed"].includes(status), + ) + ? "changed" + : dimensions.includes("unknown") + ? "unknown" + : "unchanged"; + if (comparison.status !== expected) + context.addIssue({ + code: "custom", + message: "Process comparison status contradicts its dimensions", + path: ["status"], + }); + }); +type ProcessCaptureComparison = z.infer; + +const stableFiles = (capture: ProcessCapture): string => + JSON.stringify( + [...capture.files_after].sort((left, right) => + left.path.localeCompare(right.path), + ), + ); + +const classifyCollection = ( + left: readonly unknown[], + right: readonly unknown[], +): ComparisonStatus => { + if (JSON.stringify(left) === JSON.stringify(right)) return "unchanged"; + if (left.length === 0) return "added"; + if (right.length === 0) return "removed"; + return "changed"; +}; + +const hasUnknown = ( + capture: ProcessCapture, + scope: ProcessCapture["residual_unknowns"][number]["scope"], +): boolean => capture.residual_unknowns.some((item) => item.scope === scope); + +const comparisonStatus = ( + dimensions: readonly ComparisonStatus[], +): ComparisonStatus => { + if ( + dimensions.some((status) => + ["changed", "added", "removed"].includes(status), + ) + ) + return "changed"; + return dimensions.includes("unknown") ? "unknown" : "unchanged"; +}; + +/** Compare bounded captures without equating missing or incompatible evidence. */ +export const compareProcessCaptures = ( + left: ProcessCapture, + right: ProcessCapture, +): ProcessCaptureComparison => { + if (left.truncated || right.truncated) { + return { + status: "truncated", + terminal: "truncated", + exit: "truncated", + filesystem: "truncated", + protocol: "truncated", + process: "truncated", + limitations: ["At least one capture is truncated."], + }; + } + const sameNormalization = + JSON.stringify(left.normalization) === JSON.stringify(right.normalization); + const classify = ( + scope: ProcessCapture["residual_unknowns"][number]["scope"], + leftValues: readonly unknown[], + rightValues: readonly unknown[], + ): ComparisonStatus => + !sameNormalization || hasUnknown(left, scope) || hasUnknown(right, scope) + ? "unknown" + : classifyCollection(leftValues, rightValues); + const terminal = classify( + "terminal", + left.frames.map(({ data }) => data), + right.frames.map(({ data }) => data), + ); + const exit = + hasUnknown(left, "exit") || hasUnknown(right, "exit") + ? "unknown" + : JSON.stringify(left.exit) === JSON.stringify(right.exit) + ? "unchanged" + : "changed"; + const filesystem = + !hasUnknown(left, "filesystem") && + !hasUnknown(right, "filesystem") && + sameNormalization && + stableFiles(left) === stableFiles(right) + ? "unchanged" + : classify( + "filesystem", + left.filesystem_effects, + right.filesystem_effects, + ); + const protocol = classify( + "protocol", + left.protocol_events, + right.protocol_events, + ); + const process = classify( + "process", + left.process_samples, + right.process_samples, + ); + return { + status: comparisonStatus([terminal, exit, filesystem, protocol, process]), + terminal, + exit, + filesystem, + protocol, + process, + limitations: [ + ...left.limitations, + ...right.limitations, + ...left.residual_unknowns.map(({ reason }) => reason), + ...right.residual_unknowns.map(({ reason }) => reason), + ...(sameNormalization ? [] : ["Capture normalization rules differ."]), + ], + }; +}; diff --git a/src/domain/reconstructionUnknowns.ts b/src/domain/reconstructionUnknowns.ts new file mode 100644 index 000000000..5ade6219b --- /dev/null +++ b/src/domain/reconstructionUnknowns.ts @@ -0,0 +1,137 @@ +import type { + ReconstructionClaim, + ReconstructionClaimResult, + ReconstructionVerificationResult, +} from "./reconstructionVerificationSchemas.js"; +import type { ResidualUnknown } from "./residualUnknown.js"; + +/** Select the latest revision of each residual unknown. */ +export const reconstructionUnknownHeads = ( + revisions: readonly ResidualUnknown[], +): ReadonlyMap => { + const heads = new Map(); + for (const item of revisions) { + const current = heads.get(item.unknown_id); + if (current === undefined || current.revision < item.revision) + heads.set(item.unknown_id, item); + } + return heads; +}; + +/** Find active unknowns that affect a comparison or one of its sources. */ +export const reconstructionClaimUnknowns = ( + heads: ReadonlyMap, + comparisonId: string, + sourceIds: readonly string[], +): ResidualUnknown[] => { + const evidenceIds = new Set([comparisonId, ...sourceIds]); + const relevantIds = new Set( + [...heads.values()] + .filter( + (item) => + item.status !== "resolved" && + [ + ...item.supporting_evidence_ids, + ...item.contradicting_evidence_ids, + ].some((id) => evidenceIds.has(id)), + ) + .map(({ unknown_id: id }) => id), + ); + const pending = [...relevantIds]; + while (pending.length > 0) { + const currentId = pending.pop(); + if (currentId === undefined) continue; + const current = heads.get(currentId); + if (current === undefined) continue; + for (const relationship of current.relationships) { + if (relationship.type === "related-to") continue; + const dependency = heads.get(relationship.unknown_id); + if ( + dependency === undefined || + dependency.status === "resolved" || + relevantIds.has(dependency.unknown_id) + ) + continue; + relevantIds.add(dependency.unknown_id); + pending.push(dependency.unknown_id); + } + } + return [...relevantIds] + .flatMap((id) => { + const unknown = heads.get(id); + return unknown === undefined ? [] : [unknown]; + }) + .sort((left, right) => + left.unknown_id.localeCompare(right.unknown_id, "en"), + ); +}; + +/** Aggregate and bound deterministic probes for unresolved claims. */ +export const reconstructionProbes = ( + results: readonly ReconstructionClaimResult[], + heads: ReadonlyMap, +): { + readonly items: ReconstructionVerificationResult["recommended_probes"]; + readonly truncated: boolean; +} => { + const output = new Map< + string, + { + operation: string; + rationale: string; + claim_ids: string[]; + unknown_ids: string[]; + } + >(); + for (const result of results.filter(({ status }) => status === "unknown")) { + const unknowns = result.unknown_ids.flatMap((id) => { + const item = heads.get(id); + return item === undefined ? [] : [item]; + }); + const probes = unknowns.flatMap(({ recommended_probes: items }) => items); + const candidates = probes.length > 0 ? probes : [defaultProbe(result.kind)]; + for (const probe of candidates) { + const key = `${probe.operation}\0${probe.rationale}`; + const current = output.get(key) ?? { + ...probe, + claim_ids: [], + unknown_ids: [], + }; + current.claim_ids = uniqueSorted([...current.claim_ids, result.claim_id]); + current.unknown_ids = uniqueSorted([ + ...current.unknown_ids, + ...result.unknown_ids, + ]); + output.set(key, current); + } + } + const sorted = [...output.values()].sort((left, right) => + `${left.operation}\0${left.rationale}`.localeCompare( + `${right.operation}\0${right.rationale}`, + "en", + ), + ); + return { items: sorted.slice(0, 2_000), truncated: sorted.length > 2_000 }; +}; + +const defaultProbe = ( + kind: ReconstructionClaim["kind"], +): { operation: string; rationale: string } => + kind === "behavioral" + ? { + operation: "capture_process_scenario", + rationale: "Repeat both scenarios under one controlled environment.", + } + : kind === "structural-function" + ? { + operation: "analyze_function", + rationale: "Capture complete function dossiers under equal limits.", + } + : { + operation: "inventory_artifact", + rationale: + "Capture complete artifact inventories under equal limits.", + }; + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right, "en")); diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts new file mode 100644 index 000000000..83c523697 --- /dev/null +++ b/src/domain/reconstructionVerification.ts @@ -0,0 +1,422 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { artifactComparisonResultSchema } from "./artifactComparison.js"; +import { artifactInventoryResultSchema } from "./artifactGraph.js"; +import { parseEvidence, type Evidence } from "./evidence.js"; +import { parseEvidenceBundle } from "./evidenceBundle.js"; +import { functionComparisonResultSchema } from "./functionComparison.js"; +import { functionDossierSchema } from "./hopperValues.js"; +import { + processCaptureComparisonSchema, + processCaptureSchema, +} from "./processCapture.js"; +import { + reconstructionClaimResultSchema, + reconstructionSpecificationSchema, + reconstructionVerificationResultSchema, + type ReconstructionClaim as Claim, + type ReconstructionClaimResult as ClaimResult, + type ReconstructionObservedStatus as ObservedStatus, + type ReconstructionVerificationResult, +} from "./reconstructionVerificationSchemas.js"; +import { + reconstructionClaimUnknowns, + reconstructionProbes, + reconstructionUnknownHeads, +} from "./reconstructionUnknowns.js"; +import type { ResidualUnknown } from "./residualUnknown.js"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); + +export { + reconstructionVerificationInputSchema, + reconstructionVerificationResultSchema, +} from "./reconstructionVerificationSchemas.js"; +export type { ReconstructionVerificationResult } from "./reconstructionVerificationSchemas.js"; + +const MAX_BUNDLE_RECORDS = 20_100; +const MAX_UNKNOWN_REVISIONS = 10_000; +const MAX_CANONICAL_BYTES = 32 * 1024 * 1024; + +const providers = { + behavioral: { + operation: "compare_process_captures", + predicate: "rea.process-comparison/v1", + id: "rea-process", + name: "REA deterministic process harness", + }, + "structural-function": { + operation: "compare_functions", + predicate: "rea.function-comparison/v1", + id: "rea-function-comparison", + name: "REA function comparison", + }, + "structural-artifact": { + operation: "compare_artifacts", + predicate: "rea.artifact-comparison/v1", + id: "rea-artifact-comparison", + name: "REA artifact comparison", + }, +} as const; + +/** Verify only explicitly declared claims; no global equivalence is inferred. */ +export const verifyReconstruction = ( + specificationInput: unknown, + bundleInput: unknown, + offset: number, + limit: number, +): ReconstructionVerificationResult => { + const specification = + reconstructionSpecificationSchema.parse(specificationInput); + enforceInputSize(bundleInput); + const bundle = parseEvidenceBundle(bundleInput); + if (bundle.records.length > MAX_BUNDLE_RECORDS) + throw new TypeError("Reconstruction Evidence record limit exceeded"); + if (bundle.unknowns.length > MAX_UNKNOWN_REVISIONS) + throw new TypeError("Reconstruction unknown revision limit exceeded"); + const records = new Map( + bundle.records.map((item) => [item.evidence_id, item]), + ); + const heads = reconstructionUnknownHeads(bundle.unknowns); + const results = specification.claims + .map((claim) => evaluateClaim(claim, records, heads)) + .sort((left, right) => left.claim_id.localeCompare(right.claim_id, "en")); + const page = results.slice(offset, offset + limit); + const failed = count(results, "fail"); + const unresolved = count(results, "unknown"); + const probes = reconstructionProbes(results, heads); + const evidenceLinks = uniqueSorted( + results.flatMap(({ evidence_links: links }) => links), + ); + if (evidenceLinks.length > 20_100) + throw new TypeError("Reconstruction Evidence closure exceeds limit"); + const output = { + status: failed > 0 ? "fail" : unresolved > 0 ? "unknown" : "pass", + specification_sha256: digest({ + ...specification, + claims: [...specification.claims].sort((left, right) => + left.claim_id.localeCompare(right.claim_id, "en"), + ), + }), + summary: { + total: results.length, + passed: count(results, "pass"), + failed, + unknown: unresolved, + behavioral: results.filter(({ kind }) => kind === "behavioral").length, + structural: results.filter(({ kind }) => kind !== "behavioral").length, + }, + claims: { + items: page, + offset, + limit, + total: results.length, + next_offset: + offset + page.length < results.length ? offset + page.length : null, + }, + recommended_probes: probes.items, + evidence_links: evidenceLinks, + limitations: [ + "Pass means every declared claim passed; it does not establish global implementation equivalence.", + ...(probes.truncated + ? ["Recommended probes were truncated at the 2,000-item limit."] + : []), + ], + }; + return reconstructionVerificationResultSchema.parse(output); +}; + +const evaluateClaim = ( + claim: Claim, + records: ReadonlyMap, + heads: ReadonlyMap, +): ClaimResult => { + const comparison = records.get(claim.comparison_evidence_id); + if (comparison === undefined) + throw new TypeError( + `Missing comparison Evidence ${claim.comparison_evidence_id}`, + ); + validateComparisonIdentity(claim, comparison); + const sides = sourceSides(claim, comparison); + const allSourceIds = [...sides.left, ...sides.right]; + if (new Set(allSourceIds).size !== allSourceIds.length) + throw new TypeError( + "Comparison source Evidence must be unique and two-sided", + ); + if (!sameSet(comparison.evidence_links, allSourceIds)) + throw new TypeError( + "Comparison Evidence closure disagrees with its parameters", + ); + const sources = allSourceIds.map((id) => { + const source = records.get(id); + if (source === undefined) + throw new TypeError(`Missing source Evidence ${id}`); + return source; + }); + validateSourceKinds(claim, sources); + const observed = observedStatus(claim, comparison); + const limitations = authorityLimitations(claim, sources, sides); + const relevantUnknowns = reconstructionClaimUnknowns( + heads, + comparison.evidence_id, + allSourceIds, + ); + const status = classify(observed, limitations, relevantUnknowns); + const displayedUnknowns = relevantUnknowns.slice(0, 100); + const hiddenUnknownCount = relevantUnknowns.length - displayedUnknowns.length; + return reconstructionClaimResultSchema.parse({ + claim_id: claim.claim_id, + kind: claim.kind, + dimension: claim.dimension, + status, + observed_status: observed, + comparison_evidence_id: comparison.evidence_id, + left_evidence_ids: sides.left, + right_evidence_ids: sides.right, + evidence_links: uniqueSorted([comparison.evidence_id, ...allSourceIds]), + unknown_ids: displayedUnknowns.map(({ unknown_id: id }) => id), + limitations: uniqueSorted([ + ...comparison.limitations, + ...limitations, + ...displayedUnknowns.map( + ({ question }) => `Residual unknown: ${question}`, + ), + ...(hiddenUnknownCount > 0 + ? [ + `${hiddenUnknownCount} additional active residual unknowns affect this claim but are omitted by the 100-item display limit.`, + ] + : []), + ]), + }); +}; + +const validateSourceKinds = ( + claim: Claim, + sources: readonly Evidence[], +): void => { + const expected = + claim.kind === "behavioral" + ? ["capture_process_scenario", "rea.process-capture/v2"] + : claim.kind === "structural-function" + ? ["analyze_function", "rea.analysis/v2"] + : ["inventory_artifact", "rea.analysis/v2"]; + if ( + sources.some( + (source) => + source.operation !== expected[0] || + source.predicate_type !== expected[1], + ) + ) + throw new TypeError( + "Comparison source Evidence has an unexpected observation type", + ); + for (const source of sources) { + if (claim.kind === "behavioral") + processCaptureSchema.parse(source.normalized_result); + else if (claim.kind === "structural-function") + functionDossierSchema.parse(source.normalized_result); + else artifactInventoryResultSchema.parse(source.normalized_result); + } +}; + +const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => { + parseEvidence(evidence); + const expected = providers[claim.kind]; + if ( + evidence.operation !== expected.operation || + evidence.predicate_type !== expected.predicate || + evidence.provider.id !== expected.id || + evidence.provider.name !== expected.name || + evidence.provider.version !== "1" || + evidence.confidence !== "derived" || + evidence.authority !== "analyst-inference" || + evidence.subject !== null + ) + throw new TypeError("Comparison Evidence identity or authority disagrees"); +}; + +const sourceSides = ( + claim: Claim, + evidence: Evidence, +): { left: string[]; right: string[] } => { + const parameters = evidence.parameters; + if (claim.kind === "behavioral") { + const parsed = z + .object({ + left_evidence_id: evidenceIdSchema, + right_evidence_id: evidenceIdSchema, + }) + .passthrough() + .parse(parameters); + return { + left: [parsed.left_evidence_id], + right: [parsed.right_evidence_id], + }; + } + const parsed = z + .object({ + left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + right_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + }) + .passthrough() + .parse(parameters); + return { left: parsed.left_evidence_ids, right: parsed.right_evidence_ids }; +}; + +const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { + if (claim.kind === "behavioral") { + const result = processCaptureComparisonSchema.parse( + evidence.normalized_result, + ); + validateProcessStatus(result); + return claim.dimension === "overall" + ? result.status + : result[claim.dimension]; + } + if (claim.kind === "structural-function") { + const result = functionComparisonResultSchema.parse( + evidence.normalized_result, + ); + if (claim.dimension === "overall") return result.status; + const dimension = result.dimensions.find( + (item) => item.dimension === claim.dimension, + ); + if (dimension === undefined) + throw new TypeError("Function comparison dimension missing"); + if ( + dimension.evidence_links.some( + (id) => !evidence.evidence_links.includes(id), + ) + ) + throw new TypeError( + "Function dimension cites Evidence outside comparison closure", + ); + return dimension.status; + } + const result = artifactComparisonResultSchema.parse( + evidence.normalized_result, + ); + if ( + result.changes.offset !== 0 || + result.changes.next_offset !== null || + result.changes.items.length !== result.changes.total + ) + return "unknown"; + if ( + result.changes.items.some((item) => + item.evidence_links.some((id) => !evidence.evidence_links.includes(id)), + ) + ) + throw new TypeError( + "Artifact change cites Evidence outside comparison closure", + ); + return result.status; +}; + +const validateProcessStatus = ( + result: z.infer, +): void => { + const dimensions = [ + result.terminal, + result.exit, + result.filesystem, + result.protocol, + result.process, + ]; + const expected = dimensions.includes("truncated") + ? "truncated" + : dimensions.some((status) => + ["added", "removed", "changed"].includes(status), + ) + ? "changed" + : dimensions.includes("unknown") + ? "unknown" + : "unchanged"; + if (result.status !== expected) + throw new TypeError("Process comparison status contradicts its dimensions"); +}; + +const authorityLimitations = ( + claim: Claim, + sources: readonly Evidence[], + sides: { + readonly left: readonly string[]; + readonly right: readonly string[]; + }, +): string[] => { + const requiredAuthority = + claim.kind === "behavioral" ? "controlled-replay" : "shipped-artifact"; + const limitations: string[] = []; + if ( + sources.some( + (source) => + source.authority !== requiredAuthority || + (claim.kind === "structural-function" + ? source.confidence === "inferred" + : source.confidence !== "observed"), + ) + ) + limitations.push( + `Source Evidence lacks qualifying ${requiredAuthority} authority.`, + ); + if ( + claim.kind !== "behavioral" && + sources.some(({ subject }) => subject === null) + ) + limitations.push("Structural source artifact identity is unavailable."); + if (claim.kind === "behavioral") { + const byId = new Map(sources.map((source) => [source.evidence_id, source])); + const left = byId.get(sides.left[0] ?? ""); + const right = byId.get(sides.right[0] ?? ""); + if ( + left?.environment === null || + right?.environment === null || + JSON.stringify(left?.environment) !== JSON.stringify(right?.environment) + ) + limitations.push( + "Controlled replay environments are unavailable or incompatible.", + ); + } + return limitations; +}; + +const classify = ( + observed: ObservedStatus, + limitations: readonly string[], + unknowns: readonly ResidualUnknown[], +): ClaimResult["status"] => { + if (limitations.length > 0) return "unknown"; + if (["changed", "added", "removed"].includes(observed)) return "fail"; + if (observed === "unknown" || observed === "truncated" || unknowns.length > 0) + return "unknown"; + return "pass"; +}; + +const digest = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Specification canonicalization failed"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const enforceInputSize = (value: unknown): void => { + const encoded = canonicalize(value); + if ( + encoded === undefined || + Buffer.byteLength(encoded, "utf8") > MAX_CANONICAL_BYTES + ) + throw new TypeError("Reconstruction Evidence bundle exceeds byte limit"); +}; + +const count = ( + items: readonly ClaimResult[], + status: ClaimResult["status"], +): number => items.filter((item) => item.status === status).length; +const sameSet = (left: readonly string[], right: readonly string[]): boolean => + left.length === right.length && + uniqueSorted(left).every((id, index) => id === uniqueSorted(right)[index]); +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right, "en")); diff --git a/src/domain/reconstructionVerificationSchemas.ts b/src/domain/reconstructionVerificationSchemas.ts new file mode 100644 index 000000000..df3ef1bfe --- /dev/null +++ b/src/domain/reconstructionVerificationSchemas.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; + +import { evidenceBundleSchema } from "./evidenceBundle.js"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const unknownIdSchema = z.string().regex(/^unk_[a-f0-9]{64}$/u); +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const claimIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,99}$/u); +const titleSchema = z.string().trim().min(1).max(500); +const verificationStatusSchema = z.enum(["pass", "fail", "unknown"]); +const commonClaim = { + claim_id: claimIdSchema, + title: titleSchema, + comparison_evidence_id: evidenceIdSchema, +}; + +const behavioralClaimSchema = z.object({ + ...commonClaim, + kind: z.literal("behavioral"), + dimension: z.enum([ + "overall", + "terminal", + "exit", + "filesystem", + "protocol", + "process", + ]), +}); +const functionClaimSchema = z.object({ + ...commonClaim, + kind: z.literal("structural-function"), + dimension: z.enum([ + "overall", + "identity", + "pseudocode", + "assembly", + "comments", + "calls", + "references", + "strings_names", + "cfg", + ]), +}); +const artifactClaimSchema = z.object({ + ...commonClaim, + kind: z.literal("structural-artifact"), + dimension: z.literal("overall"), +}); + +const reconstructionClaimSchema = z.discriminatedUnion("kind", [ + behavioralClaimSchema, + functionClaimSchema, + artifactClaimSchema, +]); + +/** Finite, typed behavioral and structural specification. */ +export const reconstructionSpecificationSchema = z + .object({ + schema_version: z.literal(1), + name: z.string().trim().min(1).max(200), + claims: z.array(reconstructionClaimSchema).min(1).max(100), + }) + .superRefine((value, context) => { + const ids = value.claims.map(({ claim_id: id }) => id); + if (new Set(ids).size !== ids.length) + context.addIssue({ code: "custom", message: "Claim IDs must be unique" }); + const selectors = value.claims.map( + ({ kind, comparison_evidence_id: id, dimension }) => + `${kind}:${id}:${dimension}`, + ); + if (new Set(selectors).size !== selectors.length) + context.addIssue({ + code: "custom", + message: "Claim comparison selectors must be unique", + }); + }); + +/** Bounded reconstruction-verification input. */ +export const reconstructionVerificationInputSchema = z.object({ + specification: reconstructionSpecificationSchema, + evidence_bundle: evidenceBundleSchema, + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + unknown_registry_approved: z.literal(true).optional(), +}); + +export const reconstructionClaimResultSchema = z.object({ + claim_id: claimIdSchema, + kind: z.enum(["behavioral", "structural-function", "structural-artifact"]), + dimension: z.string().min(1), + status: verificationStatusSchema, + observed_status: z.enum([ + "unchanged", + "added", + "removed", + "changed", + "truncated", + "unknown", + ]), + comparison_evidence_id: evidenceIdSchema, + left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + right_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + evidence_links: z.array(evidenceIdSchema).min(3).max(201), + unknown_ids: z.array(unknownIdSchema).max(100), + limitations: z.array(z.string()), +}); + +/** Evidence-cited result over every declared claim, independently paged. */ +export const reconstructionVerificationResultSchema = z.object({ + status: verificationStatusSchema, + specification_sha256: digestSchema, + summary: z.object({ + total: z.number().int().min(1).max(100), + passed: z.number().int().min(0), + failed: z.number().int().min(0), + unknown: z.number().int().min(0), + behavioral: z.number().int().min(0), + structural: z.number().int().min(0), + }), + claims: z.object({ + items: z.array(reconstructionClaimResultSchema).max(100), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(1).max(100), + next_offset: z.number().int().min(0).nullable(), + }), + recommended_probes: z + .array( + z.object({ + operation: z.string().min(1).max(200), + rationale: z.string().min(1).max(1_000), + claim_ids: z.array(claimIdSchema).min(1).max(100), + unknown_ids: z.array(unknownIdSchema).max(100), + }), + ) + .max(2_000), + evidence_links: z.array(evidenceIdSchema).min(3).max(20_100), + limitations: z.array(z.string()), +}); + +export type ReconstructionClaim = z.infer; +export type ReconstructionClaimResult = z.infer< + typeof reconstructionClaimResultSchema +>; +export type ReconstructionVerificationResult = z.infer< + typeof reconstructionVerificationResultSchema +>; +export type ReconstructionObservedStatus = + ReconstructionClaimResult["observed_status"]; diff --git a/src/domain/referenceSourceClassification.ts b/src/domain/referenceSourceClassification.ts new file mode 100644 index 000000000..25950f7ea --- /dev/null +++ b/src/domain/referenceSourceClassification.ts @@ -0,0 +1,516 @@ +import { posix } from "node:path"; + +const SOURCE_CLASSIFICATIONS = [ + "source", + "test", + "config", + "manifest", + "generated", + "vendor", + "documentation", + "unknown", +] as const; + +export type ReferenceSourceClassification = + (typeof SOURCE_CLASSIFICATIONS)[number]; + +const CODE_EXTENSIONS = new Set([ + "js", + "mjs", + "cjs", + "jsx", + "ts", + "tsx", + "mts", + "cts", + "py", + "rs", + "go", + "java", + "c", + "h", + "cpp", + "hpp", + "cc", + "cxx", + "swift", + "m", + "mm", + "sh", +]); + +const LANGUAGE_BY_EXTENSION = new Map([ + ["js", "JavaScript"], + ["mjs", "JavaScript"], + ["cjs", "JavaScript"], + ["jsx", "JSX"], + ["ts", "TypeScript"], + ["tsx", "TSX"], + ["mts", "TypeScript"], + ["cts", "TypeScript"], + ["py", "Python"], + ["rs", "Rust"], + ["go", "Go"], + ["java", "Java"], + ["c", "C"], + ["h", "C"], + ["cpp", "C++"], + ["hpp", "C++"], + ["cc", "C++"], + ["cxx", "C++"], + ["swift", "Swift"], + ["m", "Objective-C"], + ["mm", "Objective-C++"], + ["sh", "Shell"], + ["json", "JSON"], + ["jsonc", "JSON"], + ["yaml", "YAML"], + ["yml", "YAML"], + ["toml", "TOML"], + ["ini", "INI"], + ["cfg", "INI"], + ["conf", "INI"], + ["md", "Markdown"], + ["markdown", "Markdown"], + ["txt", "Text"], + ["html", "HTML"], + ["htm", "HTML"], + ["css", "CSS"], + ["scss", "SCSS"], + ["sass", "Sass"], + ["less", "Less"], + ["xml", "XML"], + ["svg", "SVG"], +]); + +const MANIFEST_FILENAMES = new Set([ + "package.json", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "npm-shrinkwrap.json", + "composer.json", + "composer.lock", + "gemfile", + "gemfile.lock", + "cargo.toml", + "cargo.lock", + "go.mod", + "go.sum", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "requirements.txt", + "setup.py", + "pyproject.toml", + "pipfile", + "pipfile.lock", + "setup.cfg", + "manifest.in", + "CMakeLists.txt", + "meson.build", + "configure", + "configure.ac", + "makefile", + "makefile.am", + "dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + "kubernetes.yml", + "kubernetes.yaml", + "kustomization.yml", + "kustomization.yaml", + ".releaserc", + ".releaserc.json", + ".releaserc.yml", + ".releaserc.yaml", + "release-please-config.json", + "release-please-manifest.json", +]); + +const CONFIG_FILENAMES = new Set([ + ".gitignore", + ".gitattributes", + ".gitmodules", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".yarnrc", + ".yarnrc.yml", + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.ts", + ".prettierignore", + ".prettierrc", + ".prettierrc.json", + ".oxlintrc.json", + "tsconfig.json", + "tsconfig.build.json", + "jsconfig.json", + "vitest.config.ts", + "wrangler.json", + "wrangler.jsonc", + "dependabot.yml", + ".github", + ".husky", +]); + +const DOCUMENTATION_EXTENSIONS = new Set([ + "md", + "markdown", + "mdx", + "rst", + "txt", + "adoc", + "asciidoc", +]); + +const DOCUMENTATION_FILENAMES = new Set([ + "readme", + "readme.md", + "readme.markdown", + "readme.txt", + "changelog", + "changelog.md", + "changes.md", + "history.md", + "news.md", + "license", + "license.md", + "license.txt", + "copying", + "copying.md", + "authors", + "authors.md", + "contributing", + "contributing.md", + "code_of_conduct", + "code_of_conduct.md", + "security", + "security.md", + "support", + "support.md", + "faq", + "faq.md", +]); + +const GENERATED_DIRECTORY_NAMES = new Set([ + "dist", + "build", + "out", + "target", + "coverage", + "htmlcov", + "site", + "public", + ".next", + ".nuxt", + ".svelte-kit", + ".astro", + "storybook-static", + "docs/api", + ".codex", + ".devin", + ".factory", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".tox", + ".nyc_output", +]); + +const VENDOR_DIRECTORY_NAMES = new Set([ + "node_modules", + "vendor", + "bower_components", + "third_party", + "third-party", + "thirdparty", + "libs", + "external", +]); + +const SOURCE_DIRECTORY_NAMES = new Set([ + "src", + "source", + "sources", + "lib", + "libs", + "packages", + "pkg", + "app", + "apps", + "bin", + "scripts", + "bridge", + "components", + "domain", + "application", + "server", + "contracts", + "hopper", + "tests", + "test", + "__tests__", + "spec", + "specs", + "testing", + "fixtures", +]); + +const TEST_DIRECTORY_NAMES = new Set([ + "tests", + "test", + "__tests__", + "spec", + "specs", + "testing", +]); + +const DOCUMENTATION_DIRECTORY_NAMES = new Set([ + "docs", + "doc", + "documentation", + "guides", + "examples", + "tutorials", + "wiki", + "man", +]); + +const SECRET_BASENAMES = new Set([ + "id_rsa", + "id_ed25519", + "pgpass", + ".netrc", + ".npmrc", + ".pypirc", + ".envrc", + ".env.local", + ".env.production", + ".env.development", + ".env.test", + ".env.staging", + ".env.example", + "auth.json", + "auth.yaml", + "auth.yml", + "token.json", + "tokens.json", + "credentials.json", + "secrets.json", + "google-services.json", + "service-account.json", + ".htpasswd", +]); + +const SECRET_BASENAME_PREFIXES = [ + ".env", + ".env.", + "private", + "secret", + "secrets", + "credential", + "credentials", + "token", + "tokens", + "password", + "passwords", + "auth", + "apikey", + "api-key", + "api_key", + "service-account", + "kubeconfig", +]; + +const SECRET_BASENAME_SUFFIXES = [ + ".pem", + ".key", + ".p12", + ".gpg", + ".pgp", + ".keystore", +]; + +const SECRET_PATH_SEGMENTS = new Set([ + ".ssh", + ".aws", + ".gnupg", + ".pki", + ".docker", + ".secrets", + "secrets", + "secret", + "private", + "credentials", + "tokens", + "passwords", + "passphrase", + "kubernetes", + "k8s", +]); + +const filenameIsTest = (filename: string): boolean => { + const lower = filename.toLowerCase(); + if (lower.includes(".test.") || lower.includes(".spec.")) return true; + if (lower.startsWith("test_") || lower.startsWith("spec_")) return true; + if (lower.endsWith("_test") || lower.endsWith("_spec")) return true; + return false; +}; + +const filenameIsGenerated = (filename: string): boolean => { + const lower = filename.toLowerCase(); + if (lower.endsWith(".min.js") || lower.endsWith(".min.css")) return true; + if (lower.endsWith(".map")) return true; + if (lower.endsWith(".lock")) return true; + if (lower.endsWith(".d.ts") || lower.endsWith(".d.ts.map")) return true; + if (lower.endsWith(".d.mts") || lower.endsWith(".d.mts.map")) return true; + if (lower.endsWith(".d.cts") || lower.endsWith(".d.cts.map")) return true; + return false; +}; + +const basenameParts = (filename: string): { base: string; ext: string } => { + const lastDot = filename.lastIndexOf("."); + if (lastDot <= 0) return { base: filename, ext: "" }; + return { base: filename.slice(0, lastDot), ext: filename.slice(lastDot + 1) }; +}; + +const basenameMatch = (filename: string, set: ReadonlySet): boolean => { + const lower = filename.toLowerCase(); + return set.has(lower); +}; + +const isPathUnderAny = (path: string, names: ReadonlySet): boolean => { + for (const segment of path.split("/")) { + if (segment === "") continue; + if (names.has(segment.toLowerCase())) return true; + } + return false; +}; + +const secretPrefixMatches = (filename: string): boolean => { + const lower = filename.toLowerCase(); + for (const prefix of SECRET_BASENAME_PREFIXES) { + const prefixLower = prefix.toLowerCase(); + if (!lower.startsWith(prefixLower)) continue; + const nextIndex = prefixLower.length; + if (nextIndex < lower.length) { + const next = lower[nextIndex]; + if (next !== "." && next !== "_" && next !== "-") continue; + } + const extension = basenameParts(filename).ext.toLowerCase(); + if (CODE_EXTENSIONS.has(extension)) continue; + return true; + } + return false; +}; + +/** Return true when a path or any parent segment matches common secret patterns. */ +export const isSecretLikePath = (path: string): boolean => { + const parts = path.split("/").filter((segment) => segment !== ""); + for (const segment of parts) { + const lower = segment.toLowerCase(); + if (SECRET_PATH_SEGMENTS.has(lower)) return true; + } + + const filename = parts[parts.length - 1]; + if (filename === undefined) return false; + const lower = filename.toLowerCase(); + + if (SECRET_BASENAMES.has(lower)) return true; + if (secretPrefixMatches(filename)) return true; + for (const suffix of SECRET_BASENAME_SUFFIXES) { + if (lower.endsWith(suffix.toLowerCase())) return true; + } + + return false; +}; + +/** Detect a source language from a POSIX path. */ +export const detectReferenceSourceLanguage = (path: string): string | null => { + const filename = posix.basename(path); + if (filename === "Dockerfile" || filename.startsWith("Dockerfile.")) + return "Dockerfile"; + if (filename.toLowerCase().startsWith("dockerfile")) return "Dockerfile"; + + const lower = filename.toLowerCase(); + if (lower.endsWith(".d.ts")) return "TypeScript"; + if (lower.endsWith(".d.mts")) return "TypeScript"; + if (lower.endsWith(".d.cts")) return "TypeScript"; + + const parts = basenameParts(filename); + const extension = parts.ext.toLowerCase(); + return LANGUAGE_BY_EXTENSION.get(extension) ?? null; +}; + +/** Classify a reference source path deterministically. */ +export const classifyReferenceSourcePath = ( + path: string, +): ReferenceSourceClassification[] => { + const classifications: Set = new Set(); + const parts = path.split("/").filter((segment) => segment !== ""); + const filename = parts[parts.length - 1] ?? ""; + const lower = filename.toLowerCase(); + + if (parts.length === 0) { + classifications.add("source"); + return [...classifications].sort(); + } + addDirectoryClassifications(path, classifications); + addFilenameClassifications(filename, classifications); + + if ( + lower.endsWith(".json") || + lower.endsWith(".yaml") || + lower.endsWith(".yml") + ) { + if (!classifications.has("manifest") && !classifications.has("config")) + classifications.add("config"); + } + + if (isPathUnderAny(path, SOURCE_DIRECTORY_NAMES)) { + if (!classifications.has("vendor") && !classifications.has("generated")) + classifications.add("source"); + } + + if (classifications.size === 0) { + if (lower === "license" || lower === "copying") + classifications.add("documentation"); + else classifications.add("unknown"); + } + + return [...classifications].sort(); +}; + +const addDirectoryClassifications = ( + path: string, + classifications: Set, +): void => { + if (isPathUnderAny(path, TEST_DIRECTORY_NAMES)) classifications.add("test"); + if (isPathUnderAny(path, DOCUMENTATION_DIRECTORY_NAMES)) + classifications.add("documentation"); + if (isPathUnderAny(path, VENDOR_DIRECTORY_NAMES)) + classifications.add("vendor"); + if (isPathUnderAny(path, GENERATED_DIRECTORY_NAMES)) + classifications.add("generated"); +}; + +const addFilenameClassifications = ( + filename: string, + classifications: Set, +): void => { + const extension = basenameParts(filename).ext.toLowerCase(); + if (filenameIsTest(filename)) classifications.add("test"); + if (filenameIsGenerated(filename)) classifications.add("generated"); + if (basenameMatch(filename, MANIFEST_FILENAMES)) + classifications.add("manifest"); + if (basenameMatch(filename, CONFIG_FILENAMES)) classifications.add("config"); + if (DOCUMENTATION_EXTENSIONS.has(extension)) + classifications.add("documentation"); + if (DOCUMENTATION_FILENAMES.has(filename.toLowerCase())) + classifications.add("documentation"); + if (CODE_EXTENSIONS.has(extension)) classifications.add("source"); +}; diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts new file mode 100644 index 000000000..3df3170fc --- /dev/null +++ b/src/domain/referenceSourceGraph.ts @@ -0,0 +1,524 @@ +import { createHash } from "node:crypto"; +import { posix } from "node:path"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +const MAX_PATH = 4_096; +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const boundedTextSchema = z.string().min(1).max(1_024); +const relativePathSchema = z + .string() + .min(1) + .max(MAX_PATH) + .refine( + (path) => + !path.startsWith("/") && + !path.includes("\\") && + path + .split("/") + .every((part) => part !== "" && part !== "." && part !== ".."), + "Expected a normalized relative POSIX path without traversal", + ); + +const classificationSchema = z.enum([ + "source", + "test", + "config", + "manifest", + "generated", + "vendor", + "documentation", + "unknown", +]); + +const entryBaseShape = { + path: relativePathSchema, + classifications: z.array(classificationSchema).min(1).max(8), + limitations: z.array(boundedTextSchema).max(100), +}; + +const sourceFileSchema = z + .strictObject({ + ...entryBaseShape, + kind: z.literal("file"), + sha256: digestSchema.nullable(), + size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(), + language: z.string().min(1).max(100).nullable(), + content_state: z.enum([ + "hashed", + "redacted-secret", + "excluded", + "unreadable", + "too-large", + "unknown", + ]), + }) + .superRefine((file, context) => { + checkSortedUnique(file.classifications, "classifications", context); + if ((file.content_state === "hashed") !== (file.sha256 !== null)) + context.addIssue({ + code: "custom", + message: "Only hashed files have a content digest", + path: ["sha256"], + }); + if (file.content_state === "hashed" && file.size === null) + context.addIssue({ + code: "custom", + message: "Hashed files require a byte size", + path: ["size"], + }); + }); + +const sourceDirectorySchema = z.strictObject({ + ...entryBaseShape, + kind: z.literal("directory"), + tree_state: z.enum([ + "enumerated", + "partial", + "excluded", + "unreadable", + "unknown", + ]), +}); + +const symlinkTargetSchema = z + .string() + .min(1) + .max(MAX_PATH) + .refine( + (target) => + target === "" || + (!target.startsWith("/") && + !target.includes("\\") && + posix.normalize(target) === target), + "Expected a normalized POSIX symlink target", + ); + +const sourceSymlinkSchema = z.strictObject({ + ...entryBaseShape, + kind: z.literal("symlink"), + target: symlinkTargetSchema, + target_state: z.enum([ + "internal", + "external", + "missing", + "unreadable", + "unknown", + ]), +}); + +const sourceEntrySchema = z.discriminatedUnion("kind", [ + sourceFileSchema, + sourceDirectorySchema, + sourceSymlinkSchema, +]); + +const sourceRelationshipSchema = z.strictObject({ + from_path: relativePathSchema, + to: z.string().min(1).max(MAX_PATH), + kind: z.enum(["imports", "requires", "references", "declares-module"]), + resolution: z.enum(["internal", "external", "unresolved", "unknown"]), + parse_state: z.enum(["parsed", "partial", "unknown"]), +}); + +const parseFailureSchema = z.strictObject({ + path: relativePathSchema, + parser: z.string().min(1).max(100), + reason: boundedTextSchema, +}); + +const exclusionSchema = z.strictObject({ + path: relativePathSchema, + reason: z.enum([ + "configured-secret", + "symlink-escape", + "size-limit", + "inventory-limit", + "unreadable", + "caller-excluded", + ]), +}); + +const vcsSchema = z + .discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("git"), + head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u), + dirty: z.boolean(), + }), + z.strictObject({ + kind: z.literal("none"), + head: z.null(), + dirty: z.null(), + }), + z.strictObject({ + kind: z.literal("unknown"), + head: z.null(), + dirty: z.null(), + }), + ]) + .nullable(); + +const graphShape = { + schema: z.literal("HistoricalSourceGraph/v1"), + authority: z.literal("historical-reference"), + root_alias: z.literal("$REFERENCE_ROOT"), + root_sha256: digestSchema, + inventory_state: z.enum(["complete", "partial", "unknown"]), + entries: z.array(sourceEntrySchema).max(100_000), + relationships: z.array(sourceRelationshipSchema).max(200_000), + parse_failures: z.array(parseFailureSchema).max(10_000), + exclusions: z.array(exclusionSchema).max(100_000), + languages: z.array(z.string().min(1).max(100)).max(1_000), + manifests: z.array(relativePathSchema).max(10_000), + vcs: vcsSchema, + provenance: z.strictObject({ + importer: z.string().min(1).max(100), + importer_version: z.string().min(1).max(100).nullable(), + caller: z + .string() + .min(1) + .max(200) + .regex(/^[\w .:@/+-]+$/u), + }), + limitations: z.array(boundedTextSchema).max(1_000), +}; + +const historicalSourceGraphStructureSchema = z + .strictObject(graphShape) + .superRefine(checkGraphInvariants); + +export const historicalSourceGraphSchema = + historicalSourceGraphStructureSchema.superRefine((graph, context) => { + if ( + computeRootSha256(graph.entries, graph.exclusions) !== graph.root_sha256 + ) + context.addIssue({ + code: "custom", + message: "Historical source root commitment does not match entries", + path: ["root_sha256"], + }); + }); + +const historicalSourceGraphInputSchema = z.strictObject({ + ...graphShape, + root_sha256: z.never().optional(), +}); + +const historicalSourceManifestBaseSchema = z.strictObject({ + schema: z.literal("HistoricalSourceManifest/v1"), + authority: z.literal("historical-reference"), + manifest_id: z.string().regex(/^hsm_[a-f0-9]{64}$/u), + graph_sha256: digestSchema, + root_sha256: digestSchema, + inventory_state: z.enum(["complete", "partial", "unknown"]), + entry_count: z.number().int().min(0).max(100_000), + relationship_count: z.number().int().min(0).max(200_000), +}); + +export type HistoricalSourceGraph = z.infer; +export type HistoricalSourceGraphInput = z.infer< + typeof historicalSourceGraphInputSchema +>; +export type HistoricalSourceManifest = z.infer< + typeof historicalSourceManifestBaseSchema +>; + +const canonicalJson = (value: unknown): string => { + const serialized = canonicalize(value); + if (serialized === undefined) + throw new TypeError("RFC 8785 canonicalization rejected a graph value"); + return serialized; +}; + +const digestCanonical = (value: unknown): string => + createHash("sha256").update(canonicalJson(value)).digest("hex"); + +const relationshipKey = ( + relationship: z.infer, +): string => + `${relationship.from_path}\u0000${relationship.to}\u0000${relationship.kind}\u0000${relationship.resolution}\u0000${relationship.parse_state}`; + +const compareCodePoints = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const checkSortedUnique = ( + values: readonly string[], + field: string, + context: z.RefinementCtx, +): void => { + for (let index = 1; index < values.length; index += 1) + if (compareCodePoints(values[index - 1] ?? "", values[index] ?? "") >= 0) { + context.addIssue({ + code: "custom", + message: `${field} must be sorted and unique by Unicode code point`, + path: [field, index], + }); + return; + } +}; + +const expectedLanguages = ( + entries: readonly z.infer[], +): string[] => + [ + ...new Set( + entries.flatMap((entry) => + entry.kind === "file" && entry.language !== null + ? [entry.language] + : [], + ), + ), + ].sort(compareCodePoints); + +const expectedManifests = ( + entries: readonly z.infer[], +): string[] => + entries + .flatMap((entry) => + entry.kind === "file" && entry.classifications.includes("manifest") + ? [entry.path] + : [], + ) + .sort(compareCodePoints); + +type GraphStructure = z.infer; + +const checkRelationships = ( + graph: GraphStructure, + paths: ReadonlySet, + context: z.RefinementCtx, +): void => { + const files = new Set( + graph.entries.flatMap((entry) => + entry.kind === "file" ? [entry.path] : [], + ), + ); + for (const [index, relationship] of graph.relationships.entries()) { + if (!files.has(relationship.from_path)) + context.addIssue({ + code: "custom", + message: "Relationship source must name an inventoried file", + path: ["relationships", index, "from_path"], + }); + if (relationship.resolution !== "internal") continue; + const parsed = relativePathSchema.safeParse(relationship.to); + if (!parsed.success || !paths.has(parsed.data)) + context.addIssue({ + code: "custom", + message: + "Internal relationship target must be normalized and inventoried", + path: ["relationships", index, "to"], + }); + } +}; + +const checkSymlinks = ( + graph: GraphStructure, + paths: ReadonlySet, + context: z.RefinementCtx, +): void => { + for (const [index, entry] of graph.entries.entries()) { + if (entry.kind !== "symlink") continue; + if ( + (entry.target_state === "external") !== + (entry.target === "") + ) + context.addIssue({ + code: "custom", + message: + "External symlink targets must use the sanitized sentinel", + path: ["entries", index, "target"], + }); + if (entry.target_state !== "internal") continue; + const resolved = posix.normalize( + posix.join(posix.dirname(entry.path), entry.target), + ); + if (resolved.startsWith("../") || resolved === ".." || !paths.has(resolved)) + context.addIssue({ + code: "custom", + message: "Internal symlink target must resolve to an inventoried entry", + path: ["entries", index, "target"], + }); + } +}; + +const checkCompleteInventory = ( + graph: GraphStructure, + context: z.RefinementCtx, +): void => { + if (graph.inventory_state !== "complete") return; + const partialEntry = graph.entries.some( + (entry) => + entry.limitations.length > 0 || + (entry.kind === "file" && entry.content_state !== "hashed") || + (entry.kind === "directory" && entry.tree_state !== "enumerated") || + (entry.kind === "symlink" && entry.target_state !== "internal"), + ); + const partialRelationship = graph.relationships.some( + ({ parse_state, resolution }) => + parse_state !== "parsed" || + ["unresolved", "unknown"].includes(resolution), + ); + if ( + !partialEntry && + !partialRelationship && + graph.parse_failures.length === 0 && + graph.exclusions.length === 0 && + graph.limitations.length === 0 + ) + return; + context.addIssue({ + code: "custom", + message: + "Complete inventory cannot contain partial states, exclusions, failures, or limitations", + path: ["inventory_state"], + }); +}; + +function checkGraphInvariants( + graph: GraphStructure, + context: z.RefinementCtx, +): void { + checkSortedUnique( + graph.entries.map(({ path }) => path), + "entries", + context, + ); + for (const [index, entry] of graph.entries.entries()) + checkSortedUnique( + entry.classifications, + `entries.${index}.classifications`, + context, + ); + checkSortedUnique(graph.languages, "languages", context); + checkSortedUnique(graph.manifests, "manifests", context); + checkSortedUnique( + graph.relationships.map(relationshipKey), + "relationships", + context, + ); + checkSortedUnique( + graph.parse_failures.map(({ path, parser }) => `${path}\u0000${parser}`), + "parse_failures", + context, + ); + checkSortedUnique( + graph.exclusions.map(({ path, reason }) => `${path}\u0000${reason}`), + "exclusions", + context, + ); + + if ( + canonicalJson(graph.languages) !== + canonicalJson(expectedLanguages(graph.entries)) + ) + context.addIssue({ + code: "custom", + message: "languages must be derived from file entries", + path: ["languages"], + }); + if ( + canonicalJson(graph.manifests) !== + canonicalJson(expectedManifests(graph.entries)) + ) + context.addIssue({ + code: "custom", + message: "manifests must be derived from classified file entries", + path: ["manifests"], + }); + + const paths = new Set(graph.entries.map(({ path }) => path)); + checkRelationships(graph, paths, context); + checkSymlinks(graph, paths, context); + checkCompleteInventory(graph, context); +} + +const rootCommitment = ( + entries: readonly z.infer[], + exclusions: readonly z.infer[], +): unknown => ({ + entries: entries.map((entry) => { + switch (entry.kind) { + case "file": + return { + kind: entry.kind, + path: entry.path, + sha256: entry.sha256, + size: entry.size, + content_state: entry.content_state, + }; + case "directory": + return { + kind: entry.kind, + path: entry.path, + tree_state: entry.tree_state, + }; + case "symlink": + return { + kind: entry.kind, + path: entry.path, + target: entry.target, + target_state: entry.target_state, + }; + } + }), + exclusions, +}); + +const computeRootSha256 = ( + entries: readonly z.infer[], + exclusions: readonly z.infer[], +): string => digestCanonical(rootCommitment(entries, exclusions)); + +/** Build and internally commit a normalized historical source graph. */ +export const createHistoricalSourceGraph = ( + input: unknown, +): HistoricalSourceGraph => { + const parsed = historicalSourceGraphInputSchema.parse(input); + return historicalSourceGraphSchema.parse({ + ...parsed, + root_sha256: computeRootSha256(parsed.entries, parsed.exclusions), + }); +}; + +/** Parse a graph and reject stale or caller-invented root commitments. */ +export const parseHistoricalSourceGraph = ( + input: unknown, +): HistoricalSourceGraph => historicalSourceGraphSchema.parse(input); + +/** Compute a deterministic graph commitment containing no absolute root path. */ +export const computeHistoricalSourceGraphSha256 = (input: unknown): string => + digestCanonical(parseHistoricalSourceGraph(input)); + +/** Build a deterministic, relocation-independent manifest for a source graph. */ +export const createHistoricalSourceManifest = ( + input: unknown, +): HistoricalSourceManifest => { + const graph = parseHistoricalSourceGraph(input); + const semantic = { + schema: "HistoricalSourceManifest/v1" as const, + authority: "historical-reference" as const, + graph_sha256: computeHistoricalSourceGraphSha256(graph), + root_sha256: graph.root_sha256, + inventory_state: graph.inventory_state, + entry_count: graph.entries.length, + relationship_count: graph.relationships.length, + }; + return historicalSourceManifestBaseSchema.parse({ + ...semantic, + manifest_id: `hsm_${digestCanonical(semantic)}`, + }); +}; + +/** Parse and verify a historical source manifest's semantic identifier. */ +export const parseHistoricalSourceManifest = ( + input: unknown, +): HistoricalSourceManifest => { + const manifest = historicalSourceManifestBaseSchema.parse(input); + const { manifest_id: manifestId, ...semantic } = manifest; + if (`hsm_${digestCanonical(semantic)}` !== manifestId) + throw new TypeError("Historical source manifest identifier does not match"); + return manifest; +}; diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts new file mode 100644 index 000000000..f05c9aeda --- /dev/null +++ b/src/domain/referenceSourceImportParsing.ts @@ -0,0 +1,350 @@ +import { parse, type ParserPlugin } from "@babel/parser"; +import type { CallExpression, File, Node, StringLiteral } from "@babel/types"; +import { + isCallExpression, + isExportAllDeclaration, + isExportNamedDeclaration, + isIdentifier, + isImport, + isImportDeclaration, + isMemberExpression, + isNode, + isStringLiteral, + isTSExternalModuleReference, + isTSImportEqualsDeclaration, + isTSModuleDeclaration, +} from "@babel/types"; + +type ReferenceSourceImportKind = + | "imports" + | "requires" + | "references" + | "declares-module"; + +type ReferenceSourceImportResolution = + | "internal" + | "external" + | "unresolved" + | "unknown"; + +type ReferenceSourceImportParseState = "parsed" | "partial" | "unknown"; + +interface ReferenceSourceImportRelationship { + readonly from_path: string; + readonly to: string; + readonly kind: ReferenceSourceImportKind; + readonly resolution: ReferenceSourceImportResolution; + readonly parse_state: ReferenceSourceImportParseState; +} + +interface ReferenceSourceParseFailure { + readonly path: string; + readonly parser: string; + readonly reason: string; +} + +export interface ReferenceSourceImportParseResult { + readonly relationships: readonly ReferenceSourceImportRelationship[]; + readonly parse_failures: readonly ReferenceSourceParseFailure[]; +} + +const MAX_REASON_LENGTH = 1_024; + +const codeSourceLanguages = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]); + +const truncateReason = (reason: string): string => + reason.length > MAX_REASON_LENGTH + ? `${reason.slice(0, MAX_REASON_LENGTH - 3)}...` + : reason; + +const safeModuleName = (node: Node | null | undefined): string | undefined => { + if (isStringLiteral(node)) return node.value; + return undefined; +}; + +const resolveSpecifier = ( + specifier: string, +): ReferenceSourceImportResolution => { + if (specifier.startsWith(".") || specifier.startsWith("/")) return "internal"; + if (specifier.startsWith("node:") || specifier.startsWith("data:")) + return "external"; + return "external"; +}; + +const isModuleExpression = ( + expression: Node | null | undefined, +): expression is StringLiteral | CallExpression => + isStringLiteral(expression) || isCallExpression(expression); + +const moduleSpecifierFromExpression = ( + expression: Node | null | undefined, +): + | { specifier: string; parseState: ReferenceSourceImportParseState } + | undefined => { + if (isStringLiteral(expression)) + return { specifier: expression.value, parseState: "parsed" }; + if ( + isCallExpression(expression) && + isImport(expression.callee) && + expression.arguments.length > 0 + ) { + const first = expression.arguments[0]; + if (isStringLiteral(first)) { + return { specifier: first.value, parseState: "parsed" }; + } + return { specifier: "", parseState: "partial" }; + } + return undefined; +}; + +const appendRelationship = ( + relationships: ReferenceSourceImportRelationship[], + input: { + readonly fromPath: string; + readonly to: string | undefined; + readonly kind: ReferenceSourceImportKind; + readonly parseState: ReferenceSourceImportParseState; + readonly resolution?: ReferenceSourceImportResolution; + }, +): void => { + if (input.to === undefined) return; + relationships.push({ + from_path: input.fromPath, + to: input.to, + kind: input.kind, + resolution: input.resolution ?? resolveSpecifier(input.to), + parse_state: input.parseState, + }); +}; + +const extractImportDeclarations = ( + body: readonly Node[], + from_path: string, + relationships: ReferenceSourceImportRelationship[], +): void => { + for (const statement of body) { + if (isImportDeclaration(statement)) { + appendRelationship(relationships, { + fromPath: from_path, + to: safeModuleName(statement.source), + kind: "imports", + parseState: "parsed", + }); + continue; + } + + if (isExportNamedDeclaration(statement)) { + if (statement.source !== null && statement.source !== undefined) { + appendRelationship(relationships, { + fromPath: from_path, + to: safeModuleName(statement.source), + kind: "imports", + parseState: "parsed", + }); + } + continue; + } + + if (isExportAllDeclaration(statement)) { + appendRelationship(relationships, { + fromPath: from_path, + to: safeModuleName(statement.source), + kind: "imports", + parseState: "parsed", + }); + continue; + } + + if (isTSImportEqualsDeclaration(statement)) { + const reference = statement.moduleReference; + if (isTSExternalModuleReference(reference)) { + appendRelationship(relationships, { + fromPath: from_path, + to: safeModuleName(reference.expression), + kind: "requires", + parseState: "parsed", + }); + } + continue; + } + + if (isTSModuleDeclaration(statement)) { + if (statement.declare === true && isStringLiteral(statement.id)) { + appendRelationship(relationships, { + fromPath: from_path, + to: statement.id.value, + kind: "declares-module", + parseState: "parsed", + resolution: "unknown", + }); + } + } + } +}; + +const isRequireCallee = (callee: Node | null | undefined): boolean => { + if (isIdentifier(callee) && callee.name === "require") return true; + if ( + isMemberExpression(callee) && + isIdentifier(callee.object) && + callee.object.name === "require" && + (isIdentifier(callee.property) || isStringLiteral(callee.property)) + ) { + const propertyName = isIdentifier(callee.property) + ? callee.property.name + : isStringLiteral(callee.property) + ? callee.property.value + : undefined; + return propertyName === "resolve" || propertyName === "main"; + } + return false; +}; + +const isImportCallee = (callee: Node | null | undefined): boolean => + isImport(callee); + +const collectCallExpressions = ( + node: unknown, + targets: CallExpression[], +): void => { + if (node === null || node === undefined) return; + if (typeof node !== "object") return; + if (Array.isArray(node)) { + for (const item of node) collectCallExpressions(item, targets); + return; + } + if (!isNode(node)) return; + if (isCallExpression(node)) { + targets.push(node); + } + for (const value of Object.values(node)) { + if ( + value !== null && + value !== undefined && + typeof value === "object" && + !isSourceLocation(value) + ) { + collectCallExpressions(value, targets); + } + } +}; + +const isSourceLocation = (value: unknown): boolean => + typeof value === "object" && + value !== null && + "start" in value && + "end" in value && + !("type" in value); + +const extractRequireAndDynamicImports = ( + body: readonly Node[], + from_path: string, + relationships: ReferenceSourceImportRelationship[], +): void => { + const calls: CallExpression[] = []; + for (const statement of body) collectCallExpressions(statement, calls); + + for (const call of calls) { + const first = call.arguments[0]; + if (isRequireCallee(call.callee) && isModuleExpression(first)) { + const result = moduleSpecifierFromExpression(first); + if (result !== undefined) { + appendRelationship(relationships, { + fromPath: from_path, + to: result.specifier, + kind: "requires", + parseState: result.parseState, + }); + } + continue; + } + + if (isImportCallee(call.callee) && isModuleExpression(first)) { + const result = moduleSpecifierFromExpression(first); + if (result !== undefined) { + appendRelationship(relationships, { + fromPath: from_path, + to: result.specifier, + kind: "imports", + parseState: result.parseState, + }); + } + } + } +}; + +const parseWithBabel = ( + path: string, + source: string, + language: string | null, +): { + ast: File | undefined; + parseState: ReferenceSourceImportParseState; + reason?: string; +} => { + const plugins: ParserPlugin[] = ["jsx"]; + if (language === "TypeScript" || language === "TSX") { + plugins.push([ + "typescript", + { + dts: + path.endsWith(".d.ts") || + path.endsWith(".d.mts") || + path.endsWith(".d.cts"), + }, + ]); + } + + try { + const ast = parse(source, { + sourceType: "unambiguous", + allowImportExportEverywhere: false, + allowReturnOutsideFunction: true, + allowUndeclaredExports: true, + errorRecovery: true, + plugins, + }); + return { ast, parseState: "parsed" }; + } catch (error) { + return { + ast: undefined, + parseState: "unknown", + reason: error instanceof Error ? error.message : "Parse failed", + }; + } +}; + +/** Parse ESM/CommonJS imports and dynamic imports from source bytes. */ +export const parseReferenceSourceImports = ( + path: string, + bytes: Uint8Array, + language: string | null, +): ReferenceSourceImportParseResult => { + if (language !== null && !codeSourceLanguages.has(language)) + return { relationships: [], parse_failures: [] }; + + const source = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + const { ast, parseState, reason } = parseWithBabel(path, source, language); + + if (ast === undefined || parseState === "unknown") { + return { + relationships: [], + parse_failures: [ + { + path, + parser: "babel", + reason: truncateReason(reason ?? "Unknown parse failure"), + }, + ], + }; + } + + const relationships: ReferenceSourceImportRelationship[] = []; + extractImportDeclarations(ast.program.body, path, relationships); + extractRequireAndDynamicImports(ast.program.body, path, relationships); + + return { + relationships, + parse_failures: [], + }; +}; diff --git a/src/domain/referenceSourcePolicy.ts b/src/domain/referenceSourcePolicy.ts new file mode 100644 index 000000000..baf57bd8f --- /dev/null +++ b/src/domain/referenceSourcePolicy.ts @@ -0,0 +1,9 @@ +/** Operator-approved roots, exclusions, and hard caps for source imports. */ +export interface ReferenceSourcePolicy { + readonly roots: readonly string[]; + readonly secretPatterns: readonly string[]; + readonly maxBytes: number; + readonly maxEntries: number; + readonly maxDepth: number; + readonly maxPathBytes: number; +} diff --git a/src/domain/residualUnknown.ts b/src/domain/residualUnknown.ts new file mode 100644 index 000000000..8d0b4b964 --- /dev/null +++ b/src/domain/residualUnknown.ts @@ -0,0 +1,304 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const unknownIdSchema = z.string().regex(/^unk_[a-f0-9]{64}$/u); +const boundedText = z.string().trim().min(1).max(1_000); +const environmentRequirementSchema = z.object({ + id: z.string().min(1).max(200).nullable(), + platform: z.string().min(1).max(100).nullable(), + architecture: z.string().min(1).max(100).nullable(), + isolation: z + .enum(["none", "process", "container", "virtual-machine"]) + .nullable(), +}); +const probeSchema = z.object({ + operation: z.string().min(1).max(200), + rationale: boundedText, +}); +const relationshipSchema = z.object({ + type: z.enum(["depends-on", "related-to", "contradicts"]), + unknown_id: unknownIdSchema, +}); +const resolutionSchema = z.discriminatedUnion("disposition", [ + z.object({ + disposition: z.literal("verified"), + rationale: boundedText, + evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + }), + z.object({ + disposition: z.enum(["withdrawn", "out-of-scope"]), + rationale: boundedText, + evidence_ids: z.array(evidenceIdSchema).max(100), + }), +]); + +/** Strict durable record for one unresolved or evidence-qualified question. */ +const residualUnknownObjectSchema = z.object({ + registry_version: z.literal(1), + unknown_id: unknownIdSchema, + revision: z.number().int().min(1), + previous_revision_digest: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + revision_digest: z.string().regex(/^[a-f0-9]{64}$/u), + scope_digest: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + question: boundedText, + status: z.enum([ + "open", + "investigating", + "blocked", + "contradicted", + "resolved", + ]), + severity: z.enum(["low", "medium", "high", "critical"]), + domain: z.string().trim().min(1).max(100), + supporting_evidence_ids: z.array(evidenceIdSchema).max(100), + contradicting_evidence_ids: z.array(evidenceIdSchema).max(100), + required_authority: z + .enum([ + "shipped-artifact", + "controlled-replay", + "historical-reference", + "external-service", + "analyst-inference", + ]) + .nullable(), + required_confidence: z.enum(["observed", "derived", "inferred"]), + required_environment: environmentRequirementSchema.nullable(), + recommended_probes: z.array(probeSchema).max(20), + relationships: z.array(relationshipSchema).max(100), + resolution: resolutionSchema.nullable(), + mutation_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), +}); + +export const residualUnknownSchema = residualUnknownObjectSchema.superRefine( + (value, context) => { + if (computeRevisionDigest(value) !== value.revision_digest) + context.addIssue({ + code: "custom", + message: "Residual unknown revision digest does not match", + path: ["revision_digest"], + }); + if ((value.revision === 1) !== (value.previous_revision_digest === null)) + context.addIssue({ + code: "custom", + message: "Only the first revision has no predecessor", + path: ["previous_revision_digest"], + }); + if ((value.status === "resolved") !== (value.resolution !== null)) + context.addIssue({ + code: "custom", + message: "Only resolved unknowns carry a resolution", + path: ["resolution"], + }); + if ( + value.status === "contradicted" && + value.contradicting_evidence_ids.length === 0 + ) + context.addIssue({ + code: "custom", + message: "Contradicted status requires contradicting evidence", + path: ["contradicting_evidence_ids"], + }); + if ( + value.relationships.some( + (relationship) => relationship.unknown_id === value.unknown_id, + ) + ) + context.addIssue({ + code: "custom", + message: "An unknown cannot relate to itself", + path: ["relationships"], + }); + for (const ids of [ + value.supporting_evidence_ids, + value.contradicting_evidence_ids, + value.mutation_evidence_ids, + ]) + if (new Set(ids).size !== ids.length) + context.addIssue({ + code: "custom", + message: "Evidence IDs must be unique", + }); + if ( + value.supporting_evidence_ids.some((id) => + value.contradicting_evidence_ids.includes(id), + ) + ) + context.addIssue({ + code: "custom", + message: "Evidence cannot simultaneously support and contradict", + }); + const relationships = value.relationships.map( + (relationship) => `${relationship.type}:${relationship.unknown_id}`, + ); + if (new Set(relationships).size !== relationships.length) + context.addIssue({ + code: "custom", + message: "Relationships must be unique", + path: ["relationships"], + }); + }, +); + +export type ResidualUnknown = z.infer; +export type UnknownStatus = ResidualUnknown["status"]; + +/** Input for deterministic creation of one residual unknown. */ +export const recordUnknownInputSchema = residualUnknownObjectSchema + .omit({ + registry_version: true, + unknown_id: true, + revision: true, + previous_revision_digest: true, + revision_digest: true, + scope_digest: true, + status: true, + supporting_evidence_ids: true, + contradicting_evidence_ids: true, + resolution: true, + mutation_evidence_ids: true, + }) + .extend({ + supporting_evidence_ids: z.array(evidenceIdSchema).max(100).default([]), + contradicting_evidence_ids: z.array(evidenceIdSchema).max(100).default([]), + approved: z.literal(true), + }); + +export type RecordUnknownInput = z.infer; + +/** Explicit optimistic-concurrency update command for one unknown. */ +export const updateUnknownInputSchema = z.object({ + unknown_id: unknownIdSchema, + expected_revision: z.number().int().min(1), + approved: z.literal(true), + status: residualUnknownObjectSchema.shape.status, + severity: residualUnknownObjectSchema.shape.severity, + supporting_evidence_ids: z.array(evidenceIdSchema).max(100), + contradicting_evidence_ids: z.array(evidenceIdSchema).max(100), + required_authority: residualUnknownObjectSchema.shape.required_authority, + required_confidence: residualUnknownObjectSchema.shape.required_confidence, + required_environment: environmentRequirementSchema.nullable(), + recommended_probes: z.array(probeSchema).max(20), + relationships: z.array(relationshipSchema).max(100), + resolution: resolutionSchema.nullable(), +}); + +export type UpdateUnknownInput = z.infer; + +/** Create stable identity from immutable question and domain fields. */ +export const createResidualUnknown = ( + input: RecordUnknownInput, + mutationEvidenceId: string, + scopeDigest: string | null, +): ResidualUnknown => { + const question = input.question.replace(/\s+/gu, " "); + const identity = canonicalJson({ + domain: input.domain, + question, + scope_digest: scopeDigest, + }); + const revision = { + registry_version: 1, + unknown_id: `unk_${createHash("sha256").update(identity).digest("hex")}`, + revision: 1, + previous_revision_digest: null, + scope_digest: scopeDigest, + question, + status: + input.contradicting_evidence_ids.length > 0 ? "contradicted" : "open", + domain: input.domain, + ...projectRevisionDetails(input), + resolution: null, + mutation_evidence_ids: [mutationEvidenceId], + } satisfies Omit; + return residualUnknownSchema.parse({ + ...revision, + revision_digest: computeRevisionDigest(revision), + }); +}; + +/** Apply a complete, revision-guarded replacement while preserving identity. */ +export const updateResidualUnknown = ( + current: ResidualUnknown, + input: UpdateUnknownInput, + mutationEvidenceId: string, +): ResidualUnknown => { + const revision = { + registry_version: 1, + unknown_id: current.unknown_id, + revision: current.revision + 1, + previous_revision_digest: current.revision_digest, + scope_digest: current.scope_digest, + question: current.question, + status: input.status, + domain: current.domain, + ...projectRevisionDetails(input), + resolution: input.resolution, + mutation_evidence_ids: sortedUnique([ + ...current.mutation_evidence_ids, + mutationEvidenceId, + ]), + } satisfies Omit; + return residualUnknownSchema.parse({ + ...revision, + revision_digest: computeRevisionDigest(revision), + }); +}; + +type RevisionDetailsInput = Pick< + ResidualUnknown, + | "severity" + | "supporting_evidence_ids" + | "contradicting_evidence_ids" + | "required_authority" + | "required_confidence" + | "required_environment" + | "recommended_probes" + | "relationships" +>; + +const projectRevisionDetails = (input: RevisionDetailsInput) => ({ + severity: input.severity, + supporting_evidence_ids: sortedUnique(input.supporting_evidence_ids), + contradicting_evidence_ids: sortedUnique(input.contradicting_evidence_ids), + required_authority: input.required_authority, + required_confidence: input.required_confidence, + required_environment: input.required_environment, + recommended_probes: [...input.recommended_probes], + relationships: sortedRelationships(input.relationships), +}); + +const computeRevisionDigest = ( + value: Omit | ResidualUnknown, +): string => { + const { revision_digest: _revisionDigest, ...semantic } = + "revision_digest" in value ? value : { ...value, revision_digest: "" }; + void _revisionDigest; + return createHash("sha256").update(canonicalJson(semantic)).digest("hex"); +}; + +const canonicalJson = (value: unknown): string => { + const serialized = canonicalize(value); + if (serialized === undefined) throw new TypeError("Unknown identity failed"); + return serialized; +}; + +const sortedUnique = (values: readonly string[]): string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right)); + +const sortedRelationships = ( + values: readonly z.infer[], +): z.infer[] => + [...values].sort((left, right) => + `${left.type}:${left.unknown_id}`.localeCompare( + `${right.type}:${right.unknown_id}`, + ), + ); diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts new file mode 100644 index 000000000..70f1157df --- /dev/null +++ b/src/domain/staticRuntimeCorrelation.ts @@ -0,0 +1,513 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { artifactComparisonResultSchema } from "./artifactComparison.js"; +import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; +import { functionComparisonResultSchema } from "./functionComparison.js"; +import type { JsonValue } from "./jsonValue.js"; +import { + comparisonStatusSchema, + processCaptureComparisonSchema, +} from "./processCapture.js"; + +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const functionDimensionSchema = z.enum([ + "identity", + "pseudocode", + "assembly", + "comments", + "calls", + "references", + "strings_names", + "cfg", +]); +const runtimeDimensionSchema = z.enum([ + "terminal", + "exit", + "filesystem", + "protocol", + "process", +]); +const expectedPatternSchema = z.enum([ + "cochanged", + "static_only", + "runtime_only", + "both_unchanged", +]); +const observedPatternSchema = z.enum([ + ...expectedPatternSchema.options, + "unknown", + "truncated", +]); + +const artifactSelectorSchema = z + .object({ + kind: z.literal("artifact"), + logical_path: z.string().min(1).max(4_096), + }) + .strict(); +const functionSelectorSchema = z + .object({ kind: z.literal("function"), dimension: functionDimensionSchema }) + .strict(); +const staticSelectorSchema = z.discriminatedUnion("kind", [ + artifactSelectorSchema, + functionSelectorSchema, +]); + +const mappingSchema = z + .object({ + static: z + .object({ + comparison_evidence_id: evidenceIdSchema, + selector: staticSelectorSchema, + }) + .strict(), + runtime: z + .object({ + comparison_evidence_id: evidenceIdSchema, + dimension: runtimeDimensionSchema, + }) + .strict(), + side_alignment: z.enum(["left_to_left", "left_to_right"]), + hypothesis: z + .object({ + statement: z.string().trim().min(1).max(500), + expected_pattern: expectedPatternSchema, + }) + .strict(), + }) + .strict(); + +/** Strict bounded input for explicit static/runtime hypothesis correlation. */ +export const staticRuntimeCorrelationInputSchema = z + .object({ + static_comparisons: z.array(evidenceSchema).min(1).max(100), + runtime_comparisons: z.array(evidenceSchema).min(1).max(100), + mappings: z.array(mappingSchema).min(1).max(500), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + unknown_registry_approved: z.literal(true).optional(), + }) + .strict(); + +const correlationItemSchema = z.object({ + correlation_id: z.string().regex(/^cor_[a-f0-9]{64}$/u), + static: z.object({ + comparison_evidence_id: evidenceIdSchema, + selector: staticSelectorSchema, + status: comparisonStatusSchema, + conclusion_kind: z.enum([ + "derived_relationship", + "contradiction", + "unresolved_branch", + ]), + }), + runtime: z.object({ + comparison_evidence_id: evidenceIdSchema, + dimension: runtimeDimensionSchema, + status: comparisonStatusSchema, + }), + side_alignment: z.enum(["left_to_left", "left_to_right"]), + hypothesis: z.object({ + statement: z.string().min(1).max(500), + expected_pattern: expectedPatternSchema, + }), + observed_pattern: observedPatternSchema, + classification: z.enum(["hypothesis", "contradiction", "unresolved_branch"]), + evidence_links: z.array(evidenceIdSchema).min(2).max(20_100), + limitations: z.array(z.string()), +}); + +/** Deterministic, paginated static/runtime correlation payload. */ +export const staticRuntimeCorrelationResultSchema = z.object({ + status: z.enum(["correlated", "contradicted", "unknown", "truncated"]), + summary: z.object({ + hypotheses: z.number().int().min(0), + contradictions: z.number().int().min(0), + unresolved: z.number().int().min(0), + }), + correlations: z.object({ + items: z.array(correlationItemSchema).max(100), + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + evidence_links: z.array(evidenceIdSchema).min(2).max(20_100), + limitations: z.array(z.string()), +}); + +export type StaticRuntimeCorrelationResult = z.infer< + typeof staticRuntimeCorrelationResultSchema +>; +type Mapping = z.infer; +type CorrelationItem = z.infer; +type ComparisonStatus = z.infer; + +const EXPECTED = { + compare_artifacts: [ + "rea.artifact-comparison/v1", + "rea-artifact-comparison", + "REA artifact comparison", + ], + compare_functions: [ + "rea.function-comparison/v1", + "rea-function-comparison", + "REA function comparison", + ], + compare_process_captures: [ + "rea.process-comparison/v1", + "rea-process", + "REA deterministic process harness", + ], +} as const; + +/** Correlate only caller-declared static/runtime hypotheses. */ +export const correlateStaticAndRuntime = ( + input: unknown, +): StaticRuntimeCorrelationResult => { + const parsed = staticRuntimeCorrelationInputSchema.parse(input); + const staticEvidence = parsed.static_comparisons.map(parseStatic); + const runtimeEvidence = parsed.runtime_comparisons.map(parseRuntime); + assertUnique(staticEvidence, "static comparison Evidence"); + assertUnique(runtimeEvidence, "runtime comparison Evidence"); + const staticById = byId(staticEvidence); + const runtimeById = byId(runtimeEvidence); + const correlations = parsed.mappings + .map((mapping) => correlate(mapping, staticById, runtimeById)) + .sort((left, right) => + left.correlation_id.localeCompare(right.correlation_id, "en"), + ); + if ( + new Set(correlations.map(({ correlation_id: id }) => id)).size !== + correlations.length + ) + throw new TypeError( + "Static/runtime correlation rejects duplicate mappings", + ); + const page = correlations.slice(parsed.offset, parsed.offset + parsed.limit); + const links = uniqueSorted( + correlations.flatMap(({ evidence_links: evidenceLinks }) => evidenceLinks), + ); + if (links.length > 20_100) + throw new TypeError("Static/runtime Evidence closure exceeds limit"); + const unresolved = correlations.filter( + ({ classification }) => classification === "unresolved_branch", + ); + const contradictions = correlations.filter( + ({ classification }) => classification === "contradiction", + ); + return staticRuntimeCorrelationResultSchema.parse({ + status: correlations.some( + ({ observed_pattern: pattern }) => pattern === "truncated", + ) + ? "truncated" + : unresolved.length > 0 + ? "unknown" + : contradictions.length > 0 + ? "contradicted" + : "correlated", + summary: { + hypotheses: + correlations.length - contradictions.length - unresolved.length, + contradictions: contradictions.length, + unresolved: unresolved.length, + }, + correlations: { + items: page, + offset: parsed.offset, + limit: parsed.limit, + total: correlations.length, + next_offset: + parsed.offset + page.length < correlations.length + ? parsed.offset + page.length + : null, + }, + evidence_links: links, + limitations: [ + "Mappings are caller-declared hypotheses; consistency does not establish causality.", + "Only explicit mappings were evaluated; similar unmapped observations were not correlated.", + ], + }); +}; + +const correlate = ( + mapping: Mapping, + staticById: ReadonlyMap, + runtimeById: ReadonlyMap, +): CorrelationItem => { + const staticEvidence = required( + staticById, + mapping.static.comparison_evidence_id, + "static comparison", + ); + const runtimeEvidence = required( + runtimeById, + mapping.runtime.comparison_evidence_id, + "runtime comparison", + ); + const selectedStatic = selectStatic(staticEvidence, mapping.static.selector); + const process = processCaptureComparisonSchema.parse( + runtimeEvidence.normalized_result, + ); + const runtimeStatus = process[mapping.runtime.dimension]; + const pattern = observedPattern(selectedStatic.status, runtimeStatus); + const classification = + pattern === "unknown" || pattern === "truncated" + ? "unresolved_branch" + : pattern === mapping.hypothesis.expected_pattern + ? "hypothesis" + : "contradiction"; + const projection = { + static: mapping.static, + runtime: mapping.runtime, + side_alignment: mapping.side_alignment, + hypothesis: mapping.hypothesis, + } satisfies JsonValue; + return correlationItemSchema.parse({ + correlation_id: `cor_${sha256(canonicalJson(projection))}`, + static: { ...mapping.static, ...selectedStatic }, + runtime: { ...mapping.runtime, status: runtimeStatus }, + side_alignment: mapping.side_alignment, + hypothesis: mapping.hypothesis, + observed_pattern: pattern, + classification, + evidence_links: uniqueSorted([ + staticEvidence.evidence_id, + ...staticEvidence.evidence_links, + runtimeEvidence.evidence_id, + ...runtimeEvidence.evidence_links, + ]), + limitations: uniqueSorted([ + ...staticEvidence.limitations, + ...runtimeEvidence.limitations, + ...process.limitations, + "This correlation tests an explicit hypothesis and does not establish causality.", + "Side alignment is caller-declared and was not independently validated against source subjects or environments.", + ]), + }); +}; + +const selectStatic = ( + evidence: Evidence, + selector: z.infer, +): Pick => { + if (selector.kind === "function") { + if (evidence.operation !== "compare_functions") + throw new TypeError( + "Function selector requires function comparison Evidence", + ); + const result = functionComparisonResultSchema.parse( + evidence.normalized_result, + ); + const matches = result.dimensions.filter( + ({ dimension }) => dimension === selector.dimension, + ); + if (matches.length !== 1) + throw new TypeError("Function selector must resolve exactly once"); + return { + status: matches[0]!.status, + conclusion_kind: matches[0]!.conclusion_kind, + }; + } + if (evidence.operation !== "compare_artifacts") + throw new TypeError( + "Artifact selector requires artifact comparison Evidence", + ); + const result = artifactComparisonResultSchema.parse( + evidence.normalized_result, + ); + if ( + result.changes.offset !== 0 || + result.changes.next_offset !== null || + result.changes.items.length !== result.changes.total + ) + throw new TypeError( + "Static/runtime correlation requires complete artifact comparison pagination", + ); + const matches = result.changes.items.filter( + ({ logical_path: path }) => path === selector.logical_path, + ); + if (matches.length !== 1) + throw new TypeError( + "Artifact selector must resolve exactly one reported delta", + ); + const status = matches[0]!.classification; + return { + status, + conclusion_kind: + status === "unknown" ? "unresolved_branch" : "derived_relationship", + }; +}; + +const parseStatic = (input: unknown): Evidence => { + const evidence = parseEvidence(input); + if ( + evidence.operation !== "compare_artifacts" && + evidence.operation !== "compare_functions" + ) + throw new TypeError( + "Static/runtime correlation requires artifact or function comparison Evidence", + ); + assertComparisonIdentity(evidence); + if (evidence.operation === "compare_artifacts") { + const result = artifactComparisonResultSchema.parse( + evidence.normalized_result, + ); + assertCompleteArtifactResult(result); + assertNestedLinks( + evidence, + result.changes.items.flatMap(({ evidence_links: links }) => links), + ); + } else { + const result = functionComparisonResultSchema.parse( + evidence.normalized_result, + ); + assertNestedLinks( + evidence, + result.dimensions.flatMap(({ evidence_links: links }) => links), + ); + } + return evidence; +}; + +const assertNestedLinks = ( + evidence: Evidence, + nestedLinks: readonly string[], +): void => { + const closure = new Set(evidence.evidence_links); + if (nestedLinks.some((evidenceId) => !closure.has(evidenceId))) + throw new TypeError( + "Comparison finding cites Evidence outside its top-level closure", + ); +}; + +const parseRuntime = (input: unknown): Evidence => { + const evidence = parseEvidence(input); + if (evidence.operation !== "compare_process_captures") + throw new TypeError( + "Static/runtime correlation requires process comparison Evidence", + ); + assertComparisonIdentity(evidence); + processCaptureComparisonSchema.parse(evidence.normalized_result); + return evidence; +}; + +const assertComparisonIdentity = (evidence: Evidence): void => { + const expected = EXPECTED[evidence.operation as keyof typeof EXPECTED]; + if ( + expected === undefined || + evidence.predicate_type !== expected[0] || + evidence.provider.id !== expected[1] || + evidence.provider.name !== expected[2] || + evidence.provider.version !== "1" || + evidence.confidence !== "derived" || + evidence.authority !== "analyst-inference" || + evidence.subject !== null || + evidence.evidence_links.length < 2 || + new Set(evidence.evidence_links).size !== evidence.evidence_links.length + ) + throw new TypeError( + "Comparison Evidence operation, predicate, provider, or citations disagree", + ); + const parameterLinks = comparisonParameterLinks(evidence); + if (!sameSet(parameterLinks, evidence.evidence_links)) + throw new TypeError( + "Comparison Evidence closure disagrees with its source parameters", + ); +}; + +const comparisonParameterLinks = (evidence: Evidence): string[] => { + if (evidence.operation === "compare_process_captures") { + const parameters = z + .object({ + left_evidence_id: evidenceIdSchema, + right_evidence_id: evidenceIdSchema, + }) + .passthrough() + .parse(evidence.parameters); + return [parameters.left_evidence_id, parameters.right_evidence_id]; + } + const parameters = z + .object({ + left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + right_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), + }) + .passthrough() + .parse(evidence.parameters); + return [...parameters.left_evidence_ids, ...parameters.right_evidence_ids]; +}; + +const sameSet = (left: readonly string[], right: readonly string[]): boolean => + left.length === right.length && + new Set(left).size === left.length && + left.every((item) => right.includes(item)); + +const assertCompleteArtifactResult = ( + result: z.infer, +): void => { + if ( + result.changes.offset !== 0 || + result.changes.next_offset !== null || + result.changes.items.length !== result.changes.total + ) + throw new TypeError( + "Static/runtime correlation requires complete artifact comparison pagination", + ); +}; + +const observedPattern = ( + staticStatus: ComparisonStatus, + runtimeStatus: ComparisonStatus, +): z.infer => { + if (staticStatus === "truncated" || runtimeStatus === "truncated") + return "truncated"; + if (staticStatus === "unknown" || runtimeStatus === "unknown") + return "unknown"; + const staticChanged = staticStatus !== "unchanged"; + const runtimeChanged = runtimeStatus !== "unchanged"; + if (staticChanged && runtimeChanged) return "cochanged"; + if (staticChanged) return "static_only"; + if (runtimeChanged) return "runtime_only"; + return "both_unchanged"; +}; + +const required = ( + values: ReadonlyMap, + id: string, + label: string, +): Evidence => { + const value = values.get(id); + if (value === undefined) + throw new TypeError(`Mapping references absent ${label} Evidence`); + return value; +}; + +const byId = (evidence: readonly Evidence[]): Map => + new Map(evidence.map((item) => [item.evidence_id, item])); + +const assertUnique = (evidence: readonly Evidence[], label: string): void => { + if ( + new Set(evidence.map(({ evidence_id: id }) => id)).size !== evidence.length + ) + throw new TypeError( + `Static/runtime correlation rejects duplicate ${label}`, + ); +}; + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right, "en")); + +const sha256 = (value: string): string => + createHash("sha256").update(value).digest("hex"); + +const canonicalJson = (value: JsonValue): string => { + const serialized = canonicalize(value); + if (serialized === undefined) + throw new TypeError( + "RFC 8785 canonicalization rejected correlation mapping", + ); + return serialized; +}; diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 9a1d6f40b..2f7d1f448 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -6,6 +6,11 @@ import { promisify } from "node:util"; import { HopperCancelledError, HopperStartError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + cleanupOwnedProcessGroup, + type ProcessCleanupResult, +} from "../application/ProcessOwnership.js"; +import writeFileAtomic from "write-file-atomic"; const execFileAsync = promisify(execFile); const HOPPER_BACKGROUND_STARTUP_MS = 5_000; @@ -15,12 +20,14 @@ export interface BridgeSession { readonly directory: string; readonly socketPath: string; readonly token: string; + readonly runId: string; } /** Process handle returned by a bridge launcher. */ export interface BridgeLaunch { readonly process: ChildProcess; readonly ownsProcessLifetime: boolean; + readonly cleanup?: () => Promise; } /** Application-owned capability that starts the in-Hopper bridge. */ @@ -49,8 +56,9 @@ export interface HopperApplicationLauncherOptions { * supported launcher; callers must treat possible foreground UI as an upstream * Hopper constraint, not as evidence that analysis failed. * - * The returned process is the short-lived launcher helper, not the Hopper app, - * so REA does not claim ownership of the application's lifetime. + * REA owns only the short-lived launcher helper's run-token-authenticated + * process group. It does not claim ownership of the Hopper GUI process that + * macOS LaunchServices may create or reuse. */ export class HopperApplicationLauncher implements BridgeLauncher { constructor(readonly options: HopperApplicationLauncherOptions) {} @@ -63,6 +71,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { const source = [ `REA_SOCKET = ${JSON.stringify(session.socketPath)}`, `REA_TOKEN = ${JSON.stringify(session.token)}`, + `REA_RUN_ID = ${JSON.stringify(session.runId)}`, `exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`, "", ].join("\n"); @@ -93,6 +102,8 @@ export class HopperApplicationLauncher implements BridgeLauncher { if (!prepared) return err(new HopperCancelledError()); const child = spawn(this.options.launcherPath, args, { stdio: ["ignore", "ignore", "pipe"], + detached: process.platform !== "win32", + env: { ...process.env, REA_PROCESS_RUN_ID: session.runId }, }); const started = await new Promise>( (resolve) => { @@ -109,7 +120,44 @@ export class HopperApplicationLauncher implements BridgeLauncher { }, ); if (!started.ok) return started; - return ok({ process: child, ownsProcessLifetime: false }); + const pid = child.pid; + if (pid === undefined) + return err( + new HopperStartError({ cause: new Error("missing launcher PID") }), + ); + const ownership = { + schema_version: 1, + run_id: session.runId, + pid, + process_group_id: pid, + parent_pid: process.pid, + launcher: this.options.launcherPath, + created_at: new Date().toISOString(), + }; + try { + await writeFileAtomic( + join(session.directory, "ownership.json"), + `${JSON.stringify(ownership)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + } catch (cause: unknown) { + await cleanupOwnedProcessGroup({ + runId: session.runId, + leaderPid: pid, + processGroupId: pid, + }); + return err(new HopperStartError({ cause })); + } + return ok({ + process: child, + ownsProcessLifetime: true, + cleanup: () => + cleanupOwnedProcessGroup({ + runId: session.runId, + leaderPid: pid, + processGroupId: pid, + }), + }); } catch (cause: unknown) { return err(new HopperStartError({ cause })); } diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 1bfd9101a..04aa73ec7 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { access, mkdtemp, rm } from "node:fs/promises"; import { createConnection, type Socket } from "node:net"; import { tmpdir } from "node:os"; @@ -101,11 +101,13 @@ export class HopperClient { } const socketPath = join(this.#directory, "bridge.sock"); this.#token = randomBytes(32).toString("hex"); + const runId = randomUUID(); const launched = await this.#options.launcher.launch( { directory: this.#directory, socketPath, token: this.#token, + runId, }, signal === undefined ? {} : { signal }, ); @@ -132,7 +134,7 @@ export class HopperClient { await this.close(); return health; } - const parsed = parseServerInfo(health.value); + const parsed = parseServerInfo(health.value, runId); if (!parsed.ok) await this.close(); return parsed; } @@ -170,7 +172,14 @@ export class HopperClient { socket?.destroy(); this.#socket = undefined; if (this.#launch?.ownsProcessLifetime === true) { - this.#launch.process.kill("SIGTERM"); + if (this.#launch.cleanup !== undefined) { + const cleanup = await this.#launch.cleanup(); + if (!cleanup.cleaned) + this.#logger.warn( + { reason: cleanup.reason }, + "Owned launcher cleanup failed closed", + ); + } else this.#launch.process.kill("SIGTERM"); } this.#launch = undefined; if (this.#directory !== undefined) { @@ -388,13 +397,15 @@ const connectOnce = async ( const parseServerInfo = ( value: JsonValue, + expectedRunId: string, ): Result => { if ( typeof value === "object" && value !== null && !Array.isArray(value) && value.name === "REA Hopper bridge" && - typeof value.version === "string" + typeof value.version === "string" && + value.run_id === expectedRunId ) { return ok({ name: value.name, version: value.version }); } diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 7f014c32b..9adae876e 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from "node:url"; +import { createAnalysisExecution } from "../application/AnalysisProvider.js"; import type { AnalysisClient, AnalysisProvider, @@ -9,42 +10,66 @@ import type { import type { AppConfig } from "../config.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import type { Logger } from "../logger.js"; +import { AnalysisCapabilityUnavailableError } from "../domain/errors.js"; +import { err } from "../domain/result.js"; +import { + ENHANCED_TOOL_CONTRACTS, + OFFICIAL_TOOL_CONTRACTS, +} from "../contracts/toolContracts.js"; import { HopperApplicationLauncher } from "./BridgeLauncher.js"; import { HopperClient } from "./HopperClient.js"; -const IDENTITY: ProviderIdentity = { +const IDENTITY: ProviderIdentity = Object.freeze({ id: "hopper", name: "Hopper Disassembler", version: null, -}; +}); +const MUTATING_OPERATIONS = new Set([ + "set_address_name", + "set_addresses_names", + "set_bookmark", + "set_comment", + "set_inline_comment", + "unset_bookmark", +]); -const OPERATIONS = [ - "direct-analysis", - "decompilation", - "disassembly", - "cross-references", - "containing-procedure-resolution", - "procedure-references", - "analysis-metadata-mutation", -] as const; +const PROVIDER_TOOL_CONTRACTS = [ + ...OFFICIAL_TOOL_CONTRACTS, + ...ENHANCED_TOOL_CONTRACTS.filter( + (contract) => contract.name === "analyze_function", + ), +]; -const CAPABILITIES: readonly CapabilityDescriptor[] = OPERATIONS.map( - (operation) => ({ - operation, - version: 1, - available: true, - pagination: "none", - exhaustive: false, - effects: { - mutatesArtifact: operation === "analysis-metadata-mutation", - launchesProcess: true, - mayShowUi: true, - mayAccessNetwork: false, - mayWriteFilesystem: operation === "analysis-metadata-mutation", - requiresPrivileges: false, - }, - limitations: ["Results depend on Hopper's completed static analysis."], - }), +const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( + PROVIDER_TOOL_CONTRACTS.map((contract) => + Object.freeze({ + provider: IDENTITY, + operation: contract.name, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "offset" in contract.inputSchema.shape ? "offset" : "none", + exhaustive: false, + effects: Object.freeze({ + mutatesArtifact: MUTATING_OPERATIONS.has(contract.name), + launchesProcess: true, + mayShowUi: true, + mayAccessNetwork: false, + mayWriteFilesystem: MUTATING_OPERATIONS.has(contract.name), + changesPermissions: false, + requiresRoot: false, + }), + limits: Object.freeze({ + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }), + limitations: Object.freeze([ + "Results depend on Hopper's completed static analysis.", + ]), + }), + ), ); /** Concrete analysis provider backed by REA's private Hopper bridge. */ @@ -63,6 +88,20 @@ export class HopperProvider implements AnalysisProvider { } createClient(target: BinaryTarget): AnalysisClient { + if (target.kind !== "executable" && target.kind !== "database") + return { + execute: (operation) => + Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + operation, + `Hopper cannot open ${target.kind} targets directly. Inventory or extract the artifact first.`, + ), + ), + ), + close: () => Promise.resolve(), + }; const client = new HopperClient({ launcher: new HopperApplicationLauncher({ launcherPath: this.config.hopperLauncherPath, @@ -79,8 +118,19 @@ export class HopperProvider implements AnalysisProvider { logger: this.logger.child({ layer: "bridge" }), }); return { - execute: (operation, parameters, options) => - client.callTool(operation, parameters, options), + execute: async (operation, parameters, options) => { + const result = await client.callTool(operation, parameters, options); + return result.ok + ? { + ok: true, + value: createAnalysisExecution(result.value, IDENTITY, { + limitations: [ + "Results depend on Hopper's completed static analysis.", + ], + }), + } + : result; + }, close: () => client.close(), }; } diff --git a/src/identity.ts b/src/identity.ts index d30931711..dc9889101 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -10,5 +10,6 @@ export const PRODUCT_IDENTITY = { mcpCommand: "npx -y rea-agents mcp", mcpServerKey: "rea", skillName: "rea-analysis", + skillVersion: "10", configFileName: "rea.json", } as const; diff --git a/src/main.ts b/src/main.ts index 0538c2e44..4956551cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,12 +46,11 @@ export const run = async (): Promise => { try { handle = serveStdio( () => - createServer( - session, - session, + createServer(session, session, { logger, - config.value.processExecutionPolicy, - ), + processPolicy: config.value.processExecutionPolicy, + evidenceFilePolicy: config.value.evidenceFilePolicy, + }), { onerror: () => { serverLogger.error("MCP stdio transport error"); diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts new file mode 100644 index 000000000..42e8b5a7d --- /dev/null +++ b/src/native/CommandRunner.ts @@ -0,0 +1,254 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { realpath } from "node:fs/promises"; +import { spawn } from "node:child_process"; + +import { err, ok, type Result } from "../domain/result.js"; + +export interface NativeCommandCapture { + readonly tool: string; + readonly executable: string; + readonly executableSha256: string; + readonly toolVersion: string | null; + readonly versionReason: string | null; + readonly arguments: readonly string[]; + readonly stdout: string; + readonly stderr: string; + readonly stdoutBytes: number; + readonly stderrBytes: number; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; + readonly exitCode: number | null; + readonly signal: string | null; +} + +export class NativeCommandFailure extends Error { + constructor( + readonly tool: string, + readonly reason: + | "unavailable" + | "cancelled" + | "timeout" + | "output-limit" + | "nonzero-exit" + | "io", + readonly exitCode: number | null = null, + options?: ErrorOptions, + ) { + super(`Native command ${tool} failed: ${reason}`, options); + } +} + +export interface NativeCommandRunner { + run( + tool: string, + arguments_: readonly string[], + options: { + readonly signal?: AbortSignal; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly acceptNonZero?: boolean; + }, + ): Promise>; +} + +/** Run allowlisted Xcode tools directly with bounded output and no shell. */ +export class XcrunCommandRunner implements NativeCommandRunner { + readonly #resolved = new Map< + string, + Promise> + >(); + + run( + tool: string, + arguments_: readonly string[], + options: { + readonly signal?: AbortSignal; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly acceptNonZero?: boolean; + }, + ): Promise> { + if (!ALLOWED_TOOLS.has(tool)) + return Promise.resolve( + err(new NativeCommandFailure(tool, "unavailable")), + ); + return this.#resolve(tool).then(async (resolved) => { + if (!resolved.ok) return resolved; + const captured = await captureProcess( + resolved.value.path, + arguments_, + tool, + options, + ); + return captured.ok + ? ok({ + ...captured.value, + tool, + executable: resolved.value.path, + executableSha256: resolved.value.sha256, + toolVersion: null, + versionReason: + "Tool exposes no uniform stable version flag; executable digest identifies it.", + arguments: [...arguments_], + }) + : captured; + }); + } + + #resolve(tool: string): Promise> { + const existing = this.#resolved.get(tool); + if (existing !== undefined) return existing; + const pending = resolveTool(tool); + this.#resolved.set(tool, pending); + return pending; + } +} + +interface ResolvedTool { + readonly path: string; + readonly sha256: string; +} + +const ALLOWED_TOOLS = new Set([ + "codesign", + "dyld_info", + "dwarfdump", + "file", + "lipo", + "nm", + "otool", + "plutil", + "strings", + "swift-demangle", + "vtool", +]); + +const resolveTool = async ( + tool: string, +): Promise> => { + const found = await captureProcess( + "/usr/bin/xcrun", + ["--find", tool], + "xcrun", + { timeoutMs: 5_000, maxOutputBytes: 64 * 1024 }, + ); + if (!found.ok || found.value.exitCode !== 0) + return err(new NativeCommandFailure(tool, "unavailable")); + const candidate = found.value.stdout.trim(); + if (!candidate.startsWith("/")) + return err(new NativeCommandFailure(tool, "unavailable")); + try { + const path = await realpath(candidate); + return ok({ path, sha256: await hashFile(path) }); + } catch (cause: unknown) { + return err(new NativeCommandFailure(tool, "io", null, { cause })); + } +}; + +type ProcessCapture = Omit< + NativeCommandCapture, + | "tool" + | "executable" + | "executableSha256" + | "toolVersion" + | "versionReason" + | "arguments" +>; + +const captureProcess = ( + executable: string, + arguments_: readonly string[], + tool: string, + options: { + readonly signal?: AbortSignal; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly acceptNonZero?: boolean; + }, +): Promise> => + new Promise((resolve) => { + if (options.signal?.aborted === true) { + resolve(err(new NativeCommandFailure(tool, "cancelled"))); + return; + } + const runToken = randomUUID(); + const child = spawn(executable, [...arguments_], { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + env: { + PATH: "/usr/bin:/bin:/usr/sbin:/sbin", + LC_ALL: "C", + LANG: "C", + REA_NATIVE_RUN_TOKEN: runToken, + }, + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let reason: NativeCommandFailure["reason"] | undefined; + const finish = ( + result: Result, + ): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + const stop = (failure: NativeCommandFailure["reason"]): void => { + reason ??= failure; + child.kill("SIGKILL"); + }; + const onAbort = (): void => stop("cancelled"); + options.signal?.addEventListener("abort", onAbort, { once: true }); + const timeout = setTimeout(() => stop("timeout"), options.timeoutMs); + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes + stderrBytes > options.maxOutputBytes) + stop("output-limit"); + else stdout.push(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.length; + if (stdoutBytes + stderrBytes > options.maxOutputBytes) + stop("output-limit"); + else stderr.push(chunk); + }); + child.once("error", (cause: unknown) => + finish(err(new NativeCommandFailure(tool, "io", null, { cause }))), + ); + child.once("close", (code, signal) => { + if (reason !== undefined) { + finish(err(new NativeCommandFailure(tool, reason, code))); + return; + } + if (code !== 0 && options.acceptNonZero !== true) { + finish(err(new NativeCommandFailure(tool, "nonzero-exit", code))); + return; + } + finish( + ok({ + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + stdoutBytes, + stderrBytes, + stdoutTruncated: false, + stderrTruncated: false, + exitCode: code, + signal, + }), + ); + }); + }); + +const hashFile = (path: string): Promise => + new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(path); + stream.on("data", (chunk) => hash.update(chunk)); + stream.once("error", reject); + stream.once("end", () => resolve(hash.digest("hex"))); + }); diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts new file mode 100644 index 000000000..130505ff9 --- /dev/null +++ b/src/native/NativeMacOSProvider.ts @@ -0,0 +1,490 @@ +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { realpath, stat } from "node:fs/promises"; + +import { parse as parseXmlPlist } from "plist"; +import { z } from "zod"; + +import { + createAnalysisExecution, + type AnalysisClient, + type AnalysisOperation, + type AnalysisProvider, + type CapabilityDescriptor, + type ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { + NATIVE_TOOL_CONTRACTS, + type NativeToolName, +} from "../contracts/nativeToolContracts.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { EvidenceLocation } from "../domain/evidence.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, + AnalysisTimeoutError, + ProviderAdapterError, + type AnalysisError, +} from "../domain/errors.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + demangleSwiftSchema, + inspectPlistSchema, + inspectSignatureSchema, + listArchitecturesSchema, + type NativeCommandInvocation, +} from "../domain/nativeInspection.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + NativeCommandFailure, + XcrunCommandRunner, + type NativeCommandCapture, + type NativeCommandRunner, +} from "./CommandRunner.js"; +import { parseCodeSignature } from "./parsers/codesign.js"; +import { parseDemangledSymbols } from "./parsers/demangle.js"; +import { parseLipoArchitectures } from "./parsers/lipo.js"; +import { parsePlistJson } from "./parsers/plist.js"; +import { + architectureLocations, + inspectNativeMacho, +} from "./NativeMachoInspection.js"; + +const IDENTITY: ProviderIdentity = Object.freeze({ + id: "native-macos", + name: "macOS native inspection utilities", + version: null, +}); +const TIMEOUT_MS = 30_000; +const OUTPUT_LIMIT = 4 * 1024 * 1024; + +/** Read-only semantic provider composed from Xcode command-line utilities. */ +export class NativeMacOSProvider implements AnalysisProvider { + readonly #capabilities: readonly CapabilityDescriptor[]; + + constructor( + private readonly runner: NativeCommandRunner = new XcrunCommandRunner(), + platform: NodeJS.Platform = process.platform, + ) { + const available = platform === "darwin"; + this.#capabilities = Object.freeze( + NATIVE_TOOL_CONTRACTS.map((contract): CapabilityDescriptor => { + const availability = available + ? ({ available: true, reason: null } as const) + : ({ + available: false, + reason: "Native macOS utilities require macOS.", + } as const); + return Object.freeze({ + provider: IDENTITY, + operation: contract.name, + inputContractVersion: 1, + outputContractVersion: 1, + ...availability, + pagination: "none" as const, + exhaustive: contract.name !== "inspect_macho", + effects: Object.freeze({ + mutatesArtifact: false, + launchesProcess: true, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }), + limits: Object.freeze({ + maxResults: contract.name === "demangle_swift" ? 500 : null, + maxPayloadBytes: OUTPUT_LIMIT, + timeoutMs: TIMEOUT_MS, + }), + limitations: Object.freeze([ + "Availability and textual formats depend on the installed macOS/Xcode toolchain.", + ]), + }); + }), + ); + } + + identity(): ProviderIdentity { + return IDENTITY; + } + + capabilities(): readonly CapabilityDescriptor[] { + return this.#capabilities; + } + + createClient(target: BinaryTarget): AnalysisClient { + return new NativeMacOSClient(target, this.runner); + } +} + +class NativeMacOSClient implements AnalysisClient { + constructor( + private readonly target: BinaryTarget, + private readonly runner: NativeCommandRunner, + ) {} + + async execute( + operation: AnalysisOperation, + parameters: Readonly>, + options?: { readonly signal?: AbortSignal }, + ) { + if (operation === "health") + return ok(createAnalysisExecution(null, IDENTITY)); + if (!isNativeOperation(operation)) + return err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + operation, + "Operation is not implemented by native macOS tools.", + ), + ); + try { + const observation = await this.#dispatch( + operation, + parameters, + options?.signal, + ); + return observation.ok + ? ok( + createAnalysisExecution(observation.value.result, IDENTITY, { + rawResult: { provenance: observation.value.provenance }, + limitations: observation.value.limitations, + locations: observation.value.locations, + }), + ) + : observation; + } catch (cause: unknown) { + return err( + new AnalysisOutputError(operation, "Native output parsing failed", { + cause, + }), + ); + } + } + + close(): Promise { + return Promise.resolve(); + } + + #dispatch( + operation: NativeToolName, + parameters: Readonly>, + signal?: AbortSignal, + ): Promise> { + switch (operation) { + case "inspect_macho": + return this.#inspectMacho(signal); + case "inspect_signature": + return this.#inspectSignature(signal); + case "inspect_plist": + return this.#inspectPlist(parameters, signal); + case "list_architectures": + return this.#listArchitectures(signal); + case "demangle_swift": + return this.#demangle(parameters, signal); + } + } + + async #listArchitectures( + signal?: AbortSignal, + ): Promise> { + const capture = await this.#run( + "list_architectures", + "lipo", + ["-detailed_info", this.target.path], + { signal }, + ); + if (!capture.ok) return capture; + const provenance = [invocation(capture.value, this.target.path)]; + const result = listArchitecturesSchema.parse({ + architectures: { + items: parseLipoArchitectures(capture.value.stdout), + total: parseLipoArchitectures(capture.value.stdout).length, + exhaustive: true, + limitations: [], + }, + provenance, + limitations: [], + }); + return ok({ + result: jsonValueSchema.parse(result), + provenance, + limitations: [], + locations: architectureLocations(result.architectures.items), + }); + } + + async #demangle( + parameters: Readonly>, + signal?: AbortSignal, + ): Promise> { + const symbols = z.array(z.string()).safeParse(parameters.symbols); + if (!symbols.success) + return err( + new AnalysisOutputError( + "demangle_swift", + "symbols were not parsed strings", + ), + ); + const capture = await this.#run( + "demangle_swift", + "swift-demangle", + ["--compact", ...symbols.data], + { signal }, + ); + if (!capture.ok) return capture; + const provenance = [invocation(capture.value, this.target.path)]; + const result = demangleSwiftSchema.parse({ + symbols: parseDemangledSymbols(symbols.data, capture.value.stdout), + provenance, + limitations: [], + }); + return ok({ + result: jsonValueSchema.parse(result), + provenance, + limitations: [], + locations: [], + }); + } + + async #inspectSignature( + signal?: AbortSignal, + ): Promise> { + const display = await this.#run( + "inspect_signature", + "codesign", + ["-d", "--verbose=4", this.target.path], + { signal, acceptNonZero: true }, + ); + if (!display.ok) return display; + const requirements = await this.#run( + "inspect_signature", + "codesign", + ["-d", "-r-", this.target.path], + { signal, acceptNonZero: true }, + ); + if (!requirements.ok) return requirements; + const entitlements = await this.#run( + "inspect_signature", + "codesign", + ["-d", "--entitlements", ":-", this.target.path], + { signal, acceptNonZero: true }, + ); + if (!entitlements.ok) return entitlements; + const unsigned = /not signed at all|code object is not signed/iu.test( + `${display.value.stdout}\n${display.value.stderr}`, + ); + const parsed = parseCodeSignature( + `${display.value.stdout}\n${display.value.stderr}`, + unsigned, + ); + const requirementText = + /designated\s*=>\s*(.+)$/mu.exec( + `${requirements.value.stdout}\n${requirements.value.stderr}`, + )?.[1] ?? null; + const entitlementValue = parseEntitlements( + `${entitlements.value.stdout}\n${entitlements.value.stderr}`, + ); + const provenance = [ + display.value, + requirements.value, + entitlements.value, + ].map((capture) => invocation(capture, this.target.path)); + const result = inspectSignatureSchema.parse({ + ...parsed, + designated_requirement: requirementText, + entitlements: entitlementValue, + provenance, + }); + return ok({ + result: jsonValueSchema.parse(result), + provenance, + limitations: result.limitations, + locations: [], + }); + } + + async #inspectPlist( + parameters: Readonly>, + signal?: AbortSignal, + ): Promise> { + const requested = parameters.relative_path; + if (typeof requested !== "string") + return err( + new AnalysisOutputError( + "inspect_plist", + "relative_path was not a string", + ), + ); + const plist = await resolvePlistPath(this.target, requested); + if (!plist.ok) return plist; + const classified = await this.#run( + "inspect_plist", + "file", + ["-b", plist.value], + { signal }, + ); + if (!classified.ok) return classified; + const capture = await this.#run( + "inspect_plist", + "plutil", + ["-convert", "json", "-o", "-", "--", plist.value], + { signal }, + ); + if (!capture.ok) return capture; + const parsed = parsePlistJson(capture.value.stdout); + const provenance = [classified.value, capture.value].map((item) => + invocation(item, plist.value, "$PLIST"), + ); + const result = inspectPlistSchema.parse({ + format: /binary property list/iu.test(classified.value.stdout) + ? "binary" + : /XML|text/iu.test(classified.value.stdout) + ? "xml" + : "unknown", + ...parsed, + source_path: requested, + provenance, + limitations: [], + }); + return ok({ + result: jsonValueSchema.parse(result), + provenance, + limitations: [], + locations: [{ kind: "artifact-path", path: requested }], + }); + } + + async #inspectMacho( + signal?: AbortSignal, + ): Promise> { + if (this.target.format !== "mach-o") + return err( + new AnalysisCapabilityUnavailableError( + IDENTITY.id, + "inspect_macho", + "Active artifact is not Mach-O.", + ), + ); + return inspectNativeMacho({ + target: this.target, + ...(signal === undefined ? {} : { signal }), + run: (tool, arguments_, commandSignal) => + this.#run("inspect_macho", tool, arguments_, { + signal: commandSignal, + }), + invocation: (capture) => invocation(capture, this.target.path), + }); + } + + async #run( + operation: NativeToolName, + tool: string, + arguments_: readonly string[], + options: { + readonly signal?: AbortSignal | undefined; + readonly acceptNonZero?: boolean; + } = {}, + ): Promise> { + const captured = await this.runner.run(tool, arguments_, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + timeoutMs: TIMEOUT_MS, + maxOutputBytes: OUTPUT_LIMIT, + acceptNonZero: options.acceptNonZero ?? false, + }); + if (captured.ok) return captured; + return err(translateCommandFailure(operation, captured.error)); + } +} + +interface NativeObservation { + readonly result: JsonValue; + readonly provenance: readonly NativeCommandInvocation[]; + readonly limitations: readonly string[]; + readonly locations: readonly EvidenceLocation[]; +} + +const isNativeOperation = ( + operation: AnalysisOperation, +): operation is NativeToolName => + NATIVE_TOOL_CONTRACTS.some(({ name }) => name === operation); + +const translateCommandFailure = ( + operation: NativeToolName, + failure: NativeCommandFailure, +): AnalysisError => { + if (failure.reason === "unavailable") + return new AnalysisCapabilityUnavailableError( + IDENTITY.id, + operation, + `${failure.tool} is unavailable through xcrun.`, + ); + if (failure.reason === "cancelled") + return new AnalysisCancelledError(operation); + if (failure.reason === "timeout") + return new AnalysisTimeoutError(operation, TIMEOUT_MS); + return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure }); +}; + +const invocation = ( + capture: NativeCommandCapture, + artifactPath: string, + alias = "$ARTIFACT", +): NativeCommandInvocation => ({ + tool: capture.tool, + command: [ + capture.executable, + ...capture.arguments.map((argument) => + argument === artifactPath ? alias : argument, + ), + ], + tool_version: capture.toolVersion, + version_reason: capture.versionReason, + executable_sha256: capture.executableSha256, + exit: { code: capture.exitCode, signal: capture.signal }, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + stdout_truncated: capture.stdoutTruncated, + stderr_truncated: capture.stderrTruncated, +}); + +const parseEntitlements = (output: string): JsonValue | null => { + const start = output.indexOf(""); + if (start < 0 || end < start) return null; + return jsonValueSchema.parse( + parseXmlPlist(output.slice(start, end + "".length)), + ); +}; + +const resolvePlistPath = async ( + target: BinaryTarget, + requested: string, +): Promise> => { + try { + const source = target.sourcePath ?? target.path; + const sourceMetadata = await stat(source); + const root = sourceMetadata.isDirectory() ? source : dirname(target.path); + const candidate = resolve(root, requested); + if (!within(root, candidate)) + return err(new ProviderAdapterError(IDENTITY.id, "inspect_plist")); + const canonicalRoot = await realpath(root); + const canonical = await realpath(candidate); + if (!within(canonicalRoot, canonical)) + return err(new ProviderAdapterError(IDENTITY.id, "inspect_plist")); + return ok(canonical); + } catch (cause: unknown) { + return err( + new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), + ); + } +}; + +const within = (root: string, candidate: string): boolean => { + const child = relative(root, candidate); + return ( + child === "" || + (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child)) + ); +}; diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts new file mode 100644 index 000000000..0fcfe07a3 --- /dev/null +++ b/src/native/NativeMachoInspection.ts @@ -0,0 +1,206 @@ +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { EvidenceLocation } from "../domain/evidence.js"; +import type { AnalysisError } from "../domain/errors.js"; +import { + inspectMachoSchema, + type NativeCommandInvocation, +} from "../domain/nativeInspection.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { NativeCommandCapture } from "./CommandRunner.js"; +import { parseDyldSymbols } from "./parsers/dyldInfo.js"; +import { parseLipoArchitectures } from "./parsers/lipo.js"; +import { parseOtoolLoadCommands } from "./parsers/otool.js"; + +interface NativeMachoObservation { + readonly result: JsonValue; + readonly provenance: readonly NativeCommandInvocation[]; + readonly limitations: readonly string[]; + readonly locations: readonly EvidenceLocation[]; +} + +interface NativeMachoInspectionContext { + readonly target: BinaryTarget; + readonly signal?: AbortSignal; + readonly run: ( + tool: string, + arguments_: readonly string[], + signal?: AbortSignal, + ) => Promise>; + readonly invocation: ( + capture: NativeCommandCapture, + ) => NativeCommandInvocation; +} + +const COMMANDS = [ + ["file", ["-b"]], + ["lipo", ["-detailed_info"]], + ["otool", ["-l"]], + ["nm", ["-gjU"]], + ["dyld_info", ["-imports"]], + ["dyld_info", ["-exports"]], + ["dwarfdump", ["--uuid"]], + ["vtool", ["-show-build"]], +] as const; + +/** Inspect one Mach-O with bounded native commands and normalized output. */ +export const inspectNativeMacho = async ( + context: NativeMachoInspectionContext, +): Promise> => { + const captures: NativeCommandCapture[] = []; + for (const [tool, prefix] of COMMANDS) { + const captured = await context.run( + tool, + [...prefix, context.target.path], + context.signal, + ); + if (!captured.ok) return err(captured.error); + captures.push(captured.value); + } + const result = normalizeMacho(captures, context.invocation); + return ok({ + result: jsonValueSchema.parse(result), + provenance: result.provenance, + limitations: result.limitations, + locations: fileOffsetLocations(result), + }); +}; + +const normalizeMacho = ( + captures: readonly NativeCommandCapture[], + toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation, +) => { + const byTool = captureLookup(captures); + const architectures = parseLipoArchitectures(byTool("lipo").stdout); + const load = parseOtoolLoadCommands(byTool("otool").stdout); + const imports = parseDyldSymbols(byTool("dyld_info", 0).stdout, "imports"); + const dyldExports = parseDyldSymbols( + byTool("dyld_info", 1).stdout, + "exports", + ); + const exports = uniqueSymbols([ + ...dyldExports, + ...parseNmExports(byTool("nm").stdout), + ]); + const uuid = + /UUID:\s*([A-Fa-f0-9-]+)/u.exec(byTool("dwarfdump").stdout)?.[1] ?? + load.uuid; + const provenance = captures.map(toInvocation); + const limitations = [ + "Imports and exports combine dyld_info and nm; stripped or toolchain-hidden symbols may be absent.", + "vtool output is retained as provenance but only otool build metadata is normalized.", + ]; + return inspectMachoSchema.parse({ + format: "mach-o", + endian: parseEndian(byTool("file").stdout), + word_size: parseWordSize(byTool("file").stdout), + file_type: load.fileType, + flags: load.flags, + uuid: uuid ?? null, + entrypoints: covered(load.entrypoints, true), + architectures: covered(architectures, true), + build_metadata: covered(load.builds, true), + load_commands: covered(load.commands, true), + dependencies: covered(load.dependencies, true), + imports: covered(imports, false, [ + "dyld_info textual imports may omit chained or toolchain-unsupported metadata.", + ]), + exports: covered(exports, false, [ + "Merged nm/dyld_info results may be incomplete for stripped binaries.", + ]), + segments: covered( + load.segments.map((segment) => ({ + ...segment, + sections: covered(segment.sections, true), + })), + true, + ), + provenance, + limitations, + }); +}; + +const captureLookup = + (captures: readonly NativeCommandCapture[]) => + (tool: string, occurrence = 0): NativeCommandCapture => { + const capture = captures.filter((item) => item.tool === tool)[occurrence]; + if (capture === undefined) throw new TypeError(`Missing ${tool} capture`); + return capture; + }; + +const parseNmExports = (output: string) => + output + .split(/\r?\n/u) + .filter((name) => name.length > 0) + .map((name) => ({ + name, + address: null, + weak: null, + reexport: null, + source: "nm", + })); + +const parseEndian = (output: string): "little" | "big" | null => + /little-endian/iu.test(output) + ? "little" + : /big-endian/iu.test(output) + ? "big" + : null; + +const parseWordSize = (output: string): 32 | 64 | null => + /64-bit/iu.test(output) ? 64 : /32-bit/iu.test(output) ? 32 : null; + +const covered = ( + items: readonly Value[], + exhaustive: boolean, + limitations: readonly string[] = [], +) => ({ + items: [...items], + total: exhaustive ? items.length : null, + exhaustive, + limitations: [...limitations], +}); + +const uniqueSymbols = ( + items: readonly Value[], +): Value[] => { + const unique = new Map(); + for (const item of items) unique.set(item.name, item); + return [...unique.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); +}; + +const fileOffsetLocations = ( + macho: ReturnType, +): EvidenceLocation[] => { + const locations = architectureLocations(macho.architectures.items); + 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, + }); + } + return locations; +}; + +/** Project architecture slices into evidence file-offset locations. */ +export const architectureLocations = ( + architectures: readonly { + readonly file_offset: number | null; + readonly size: number | null; + }[], +): EvidenceLocation[] => { + const locations: EvidenceLocation[] = []; + for (const { file_offset: offset, size } of architectures) { + if (offset === null) continue; + locations.push( + size === null + ? { kind: "file-offset", offset } + : { kind: "file-offset-range", start: offset, end: offset + size }, + ); + } + return locations; +}; diff --git a/src/native/parsers/codesign.ts b/src/native/parsers/codesign.ts new file mode 100644 index 000000000..b00d72f35 --- /dev/null +++ b/src/native/parsers/codesign.ts @@ -0,0 +1,62 @@ +import { + inspectSignatureSchema, + type InspectSignature, +} from "../../domain/nativeInspection.js"; + +/** Parse bounded `codesign -d` diagnostics, which Apple emits on stderr. */ +export const parseCodeSignature = ( + output: string, + unsigned: boolean, +): Omit => { + const values = new Map(); + const authorities: string[] = []; + const cdhashes: string[] = []; + for (const line of output.split(/\r?\n/u)) { + if (line.startsWith("CodeDirectory ")) { + values.set("CodeDirectory", line.slice("CodeDirectory ".length)); + continue; + } + const separator = line.indexOf("="); + if (separator < 1) continue; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key === "Authority") authorities.push(value); + else if (key === "CDHash") cdhashes.push(value); + else values.set(key, value); + } + const parsed = inspectSignatureSchema.omit({ provenance: true }).parse({ + signed: !unsigned, + identifier: values.get("Identifier") ?? null, + team_identifier: nullableCodeSignValue(values.get("TeamIdentifier")), + format: values.get("Format") ?? null, + cdhashes, + hash_algorithms: splitAlgorithms(values.get("Hash choices")), + authorities, + designated_requirement: values.get("designated") ?? null, + entitlements: null, + timestamp: values.get("Timestamp") ?? null, + hardened_runtime: parseRuntime(values.get("CodeDirectory")), + limitations: unsigned + ? ["Artifact is not signed."] + : [ + "Entitlements and designated requirements require separate bounded commands.", + ], + }); + return parsed; +}; + +const nullableCodeSignValue = (value: string | undefined): string | null => + value === undefined || value === "not set" ? null : value; + +const splitAlgorithms = (value: string | undefined): string[] => + value === undefined + ? [] + : value + .split(/[,+\s]+/u) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + +const parseRuntime = (value: string | undefined): boolean | null => { + if (value === undefined) return null; + return /\bruntime\b/u.test(value); +}; diff --git a/src/native/parsers/demangle.ts b/src/native/parsers/demangle.ts new file mode 100644 index 000000000..c72b312dd --- /dev/null +++ b/src/native/parsers/demangle.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const resultSchema = z.object({ + input: z.string(), + output: z.string(), + status: z.enum(["demangled", "unchanged", "invalid"]), +}); + +/** Preserve input ordering while parsing `swift-demangle --compact` lines. */ +export const parseDemangledSymbols = ( + inputs: readonly string[], + output: string, +) => { + const lines = output.trimEnd().split(/\r?\n/u); + if (lines.length !== inputs.length) + throw new TypeError("swift-demangle output count does not match input"); + return inputs.map((input, index) => { + const value = lines[index]; + if (value === undefined) + throw new TypeError("swift-demangle omitted an output line"); + return resultSchema.parse({ + input, + output: value, + status: + value === input + ? input.startsWith("$s") || input.startsWith("_$s") + ? "invalid" + : "unchanged" + : "demangled", + }); + }); +}; diff --git a/src/native/parsers/dyldInfo.ts b/src/native/parsers/dyldInfo.ts new file mode 100644 index 000000000..04f1817d7 --- /dev/null +++ b/src/native/parsers/dyldInfo.ts @@ -0,0 +1,29 @@ +/** Parse bounded imports/exports from dyld_info's line-oriented tables. */ +export const parseDyldSymbols = (output: string, mode: "imports" | "exports") => + output.split(/\r?\n/u).flatMap((rawLine) => { + const line = rawLine.trim(); + if ( + line.length === 0 || + /^(?:imports|exports|binding|address|segment|ordinal)\b/iu.test(line) + ) + return []; + const tokens = line.split(/\s+/u); + const name = tokens.at(-1); + if (name === undefined || !/^(?:_|\$s|objc_|swift_)/u.test(name)) return []; + const addressToken = tokens.find((token) => + /^0x[a-fA-F0-9]+$/u.test(token), + ); + return [ + { + name, + address: addressToken ?? null, + weak: /\bweak\b/iu.test(line) ? true : null, + reexport: /\bre-?export\b/iu.test(line) + ? true + : mode === "exports" + ? false + : null, + source: mode === "imports" ? (tokens.at(-2) ?? null) : null, + }, + ]; + }); diff --git a/src/native/parsers/lipo.ts b/src/native/parsers/lipo.ts new file mode 100644 index 000000000..263affada --- /dev/null +++ b/src/native/parsers/lipo.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +const architectureSchema = z.object({ + name: z.string().min(1), + cpu_type: z.string().nullable(), + cpu_subtype: z.string().nullable(), + file_offset: z.number().int().min(0).nullable(), + size: z.number().int().min(0).nullable(), + alignment: z.number().int().min(0).nullable(), +}); + +export type LipoArchitecture = z.infer; + +/** Parse `lipo -detailed_info` into deterministic slice metadata. */ +export const parseLipoArchitectures = (output: string): LipoArchitecture[] => { + const architectures: LipoArchitecture[] = []; + let current: Record | undefined; + const flush = (): void => { + if (current === undefined) return; + const name = current.architecture ?? current["Non-fat file"]; + if (name !== undefined) + architectures.push( + architectureSchema.parse({ + name: name.includes(" is architecture: ") + ? (name.split(" is architecture: ").at(-1) ?? name) + : name, + cpu_type: current.cputype ?? null, + cpu_subtype: current.cpusubtype ?? null, + file_offset: integer(current.offset), + size: integer(current.size), + alignment: alignment(current.align), + }), + ); + current = undefined; + }; + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line.startsWith("Non-fat file:")) { + flush(); + current = { "Non-fat file": line }; + continue; + } + if (line.startsWith("architecture ")) { + flush(); + current = { architecture: line.slice("architecture ".length).trim() }; + continue; + } + const match = /^(cputype|cpusubtype|offset|size|align)\s+(.+)$/u.exec(line); + if (match?.[1] !== undefined && match[2] !== undefined) { + current ??= {}; + current[match[1]] = match[2].trim(); + } + } + flush(); + if (architectures.length === 0) + throw new TypeError("lipo output contained no architectures"); + return architectures; +}; + +const integer = (value: string | undefined): number | null => { + if (value === undefined) return null; + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +}; + +const alignment = (value: string | undefined): number | null => { + if (value === undefined) return null; + const exponent = /2\^(\d+)/u.exec(value)?.[1]; + if (exponent !== undefined) return 2 ** Number.parseInt(exponent, 10); + return integer(value); +}; diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts new file mode 100644 index 000000000..879f78396 --- /dev/null +++ b/src/native/parsers/otool.ts @@ -0,0 +1,296 @@ +interface ParsedSection { + readonly segment: string; + readonly name: string; + readonly address: string | null; + readonly size: number | null; + readonly file_offset: number | null; + readonly alignment: number | null; + readonly flags: string[]; +} + +interface ParsedSegment { + readonly name: string; + readonly vm_address: string | null; + readonly vm_size: number | null; + readonly file_offset: number | null; + readonly file_size: number | null; + readonly maximum_permissions: ReturnType; + readonly initial_permissions: ReturnType; + readonly sections: ParsedSection[]; +} + +interface ParsedLoadCommands { + readonly commands: Array<{ + index: number; + kind: string; + file_offset: number | null; + fields: Record; + }>; + readonly segments: ParsedSegment[]; + readonly dependencies: Array<{ + path: string; + kind: string; + current_version: string | null; + compatibility_version: string | null; + }>; + readonly entrypoints: Array<{ file_offset: number }>; + readonly builds: Array<{ + platform: string | null; + minimum_os: string | null; + sdk: string | null; + tools: Array<{ name: string; version: string }>; + }>; + uuid: string | null; + readonly flags: Set; +} + +/** Parse stable fields from `otool -l`, preserving unknown command fields. */ +export const parseOtoolLoadCommands = (output: string) => { + const headerTokens = parseHeaderTokens(output); + const state = createLoadCommandState(headerTokens); + for (const block of output.split(/(?=Load command \d+)/u)) + parseLoadCommand(block, state); + return { + fileType: headerTokens?.[4] ?? null, + flags: [...state.flags].sort(), + uuid: state.uuid, + commands: state.commands, + segments: state.segments, + dependencies: state.dependencies, + entrypoints: state.entrypoints, + builds: state.builds, + }; +}; + +const parseHeaderTokens = (output: string): string[] | undefined => + output + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => /^0x[a-fA-F0-9]+\s/u.test(line)) + ?.split(/\s+/u); + +const createLoadCommandState = ( + headerTokens: readonly string[] | undefined, +): ParsedLoadCommands => ({ + commands: [], + segments: [], + dependencies: [], + entrypoints: [], + builds: [], + uuid: null, + flags: new Set(headerTokens?.slice(7) ?? []), +}); + +const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { + const indexText = /^Load command (\d+)/u.exec(block)?.[1]; + if (indexText === undefined) return; + const header = block.split(/\n\s*Section\n/u)[0] ?? block; + const fields = parseFields(header); + const kind = stringField(fields, "cmd") ?? "unknown"; + state.commands.push({ + index: Number.parseInt(indexText, 10), + kind, + file_offset: null, + fields, + }); + collectUuid(kind, fields, state); + collectEntrypoint(kind, fields, state); + collectBuild(kind, block, fields, state); + collectDependency(kind, fields, state); + collectSegment(kind, block, fields, state); + collectFlags(fields, state.flags); +}; + +const collectUuid = ( + kind: string, + fields: Readonly>, + state: ParsedLoadCommands, +): void => { + if (kind === "LC_UUID") + state.uuid = stringField(fields, "uuid") ?? state.uuid; +}; + +const collectEntrypoint = ( + kind: string, + fields: Readonly>, + state: ParsedLoadCommands, +): void => { + if (kind !== "LC_MAIN") return; + const entry = numberField(fields, "entryoff"); + if (entry !== null) state.entrypoints.push({ file_offset: entry }); +}; + +const collectBuild = ( + kind: string, + block: string, + fields: Readonly>, + state: ParsedLoadCommands, +): void => { + if (kind !== "LC_BUILD_VERSION") return; + state.builds.push({ + platform: stringField(fields, "platform"), + minimum_os: stringField(fields, "minos"), + sdk: stringField(fields, "sdk"), + tools: parseBuildTools(block), + }); +}; + +const collectDependency = ( + kind: string, + fields: Readonly>, + state: ParsedLoadCommands, +): void => { + if (!kind.startsWith("LC_LOAD_") && kind !== "LC_ID_DYLIB") return; + state.dependencies.push({ + path: stripOffsetSuffix(stringField(fields, "name")), + kind, + current_version: stringField(fields, "current version"), + compatibility_version: stringField(fields, "compatibility version"), + }); +}; + +const collectSegment = ( + kind: string, + block: string, + fields: Readonly>, + state: ParsedLoadCommands, +): void => { + if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64") + state.segments.push(parseSegment(block, fields)); +}; + +const collectFlags = ( + fields: Readonly>, + flags: Set, +): void => { + const rawFlags = stringField(fields, "flags"); + if (rawFlags === null) return; + for (const flag of rawFlags.split(/\s+/u)) + if (flag.length > 0) flags.add(flag); +}; + +const parseFields = (block: string): Record => { + const fields: Record = {}; + for (const rawLine of block.split(/\r?\n/u).slice(1)) { + const line = rawLine.trim(); + const match = /^(\S+(?:\s+version)?)\s+(.+)$/u.exec(line); + if (match?.[1] === undefined || match[2] === undefined) continue; + const value = match[2].trim(); + fields[match[1]] = numeric(value) ?? value; + } + return fields; +}; + +const parseSegment = ( + block: string, + fields: Readonly>, +): ParsedSegment => ({ + name: stringField(fields, "segname") ?? "unknown", + vm_address: hexField(fields, "vmaddr"), + vm_size: numberField(fields, "vmsize"), + file_offset: numberField(fields, "fileoff"), + file_size: numberField(fields, "filesize"), + maximum_permissions: permissions(stringField(fields, "maxprot")), + initial_permissions: permissions(stringField(fields, "initprot")), + sections: parseSections(block), +}); + +const parseSections = (block: string): ParsedSection[] => + block + .split(/(?=\n\s*Section\n)/u) + .slice(1) + .map((sectionBlock) => { + const fields = parseFields(`Section${sectionBlock}`); + return { + segment: stringField(fields, "segname") ?? "unknown", + name: stringField(fields, "sectname") ?? "unknown", + address: hexField(fields, "addr"), + size: numberField(fields, "size"), + file_offset: numberField(fields, "offset"), + alignment: sectionAlignment(numberField(fields, "align")), + flags: (stringField(fields, "flags") ?? "") + .split(/\s+/u) + .filter((flag) => flag.length > 0), + }; + }); + +const parseBuildTools = ( + block: string, +): Array<{ name: string; version: string }> => { + const tools: Array<{ name: string; version: string }> = []; + const lines = block.split(/\r?\n/u).map((line) => line.trim()); + for (let index = 0; index < lines.length - 1; index += 1) { + const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1]; + const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1]; + if (tool !== undefined && version !== undefined) + tools.push({ name: tool, version }); + } + return tools; +}; + +const permissions = (raw: string | null) => { + if (raw === null) + return { read: null, write: null, execute: null, raw: null }; + const numericValue = numeric(raw); + if (numericValue !== null) + return { + read: (numericValue & 4) !== 0, + write: (numericValue & 2) !== 0, + execute: (numericValue & 1) !== 0, + raw, + }; + if (/^[r-][w-][x-]$/u.test(raw)) + return { + read: raw[0] === "r", + write: raw[1] === "w", + execute: raw[2] === "x", + raw, + }; + return { read: null, write: null, execute: null, raw }; +}; + +const numeric = (value: string): number | null => { + const token = value.split(/\s+/u)[0]; + if (token === undefined || !/^(?:0x[a-fA-F0-9]+|\d+)$/u.test(token)) + return null; + const parsed = Number.parseInt(token, token.startsWith("0x") ? 16 : 10); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +}; + +const stringField = ( + fields: Readonly>, + name: string, +): string | null => { + const value = fields[name]; + return typeof value === "string" + ? value + : value === undefined || value === null + ? null + : String(value); +}; + +const numberField = ( + fields: Readonly>, + name: string, +): number | null => { + const value = fields[name]; + return typeof value === "number" + ? value + : typeof value === "string" + ? numeric(value) + : null; +}; + +const hexField = ( + fields: Readonly>, + name: string, +): string | null => { + const value = numberField(fields, name); + return value === null ? null : `0x${value.toString(16)}`; +}; + +const stripOffsetSuffix = (value: string | null): string => + value?.replace(/\s+\(offset\s+\d+\)$/u, "") ?? "unknown"; + +const sectionAlignment = (exponent: number | null): number | null => + exponent === null || exponent > 52 ? null : 2 ** exponent; diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts new file mode 100644 index 000000000..b07709024 --- /dev/null +++ b/src/native/parsers/plist.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +const plistObject = z.record(z.string(), z.unknown()); + +/** Parse plutil JSON output and project stable bundle metadata. */ +export const parsePlistJson = (output: string) => { + const value: unknown = JSON.parse(output); + const object = plistObject.safeParse(value); + const field = (name: string): string | null => { + if (!object.success) return null; + const candidate = object.data[name]; + return typeof candidate === "string" ? candidate : null; + }; + return { + value, + bundle: { + identifier: field("CFBundleIdentifier"), + executable: field("CFBundleExecutable"), + name: field("CFBundleName"), + version: field("CFBundleVersion"), + short_version: field("CFBundleShortVersionString"), + }, + }; +}; diff --git a/src/reference/ReferenceSourceReader.ts b/src/reference/ReferenceSourceReader.ts new file mode 100644 index 000000000..c345f46fe --- /dev/null +++ b/src/reference/ReferenceSourceReader.ts @@ -0,0 +1,96 @@ +import { err, ok } from "../domain/result.js"; +import { traverseDirectory } from "./ReferenceSourceReaderEntries.js"; +import { compareNames } from "./ReferenceSourceReaderPaths.js"; +import { + type ReferenceSourceLimits, + type ReferenceSourceReaderOptions, + type ReferenceSourceRead, + type ReferenceSourceResult, + type TraversalState, +} from "./ReferenceSourceReaderTypes.js"; +import { + isAborted, + noFollowOpenSupported, + prepareRoot, +} from "./ReferenceSourceReaderValidate.js"; + +export type { + ReferenceSourceEntry, + ReferenceSourceLimits, + ReferenceSourceRead, + ReferenceSourceReaderOptions, +} from "./ReferenceSourceReaderTypes.js"; + +const DEFAULT_LIMITS: ReferenceSourceLimits = { + maxBytes: 16 * 1024 * 1024, + maxEntries: 10_000, + maxDepth: 32, + maxPathBytes: 4_096, +}; +const PATH_RACE_LIMITATION = + "Path identity is revalidated around operations; Node lacks portable descriptor-relative openat traversal, so a syscall-boundary pathname race remains."; + +/** Read a bounded source tree without intentionally following symbolic links. */ +export const readReferenceSource = async ( + root: string, + limits: ReferenceSourceLimits = DEFAULT_LIMITS, + options: ReferenceSourceReaderOptions = {}, +): Promise> => { + if (!noFollowOpenSupported()) + return err({ + tag: "reference-source-reader", + code: "unsupported", + message: "Safe no-follow file opens are unavailable", + }); + const prepared = await prepareRoot(root, limits, options.signal); + if (!prepared.ok) return prepared; + const { canonicalRoot, rootIdentity } = prepared.value; + const state: TraversalState = { + root: canonicalRoot, + rootIdentity, + limits, + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.shouldExclude === undefined + ? {} + : { shouldExclude: options.shouldExclude }), + entries: [], + pending: [{ path: canonicalRoot, depth: 0 }], + bytesRead: 0, + filesSeen: 0, + truncated: false, + stopped: false, + }; + const traversal = await traverse(state); + if (!traversal.ok) return traversal; + state.entries.sort((left, right) => compareNames(left.path, right.path)); + return ok({ + root: canonicalRoot, + entries: state.entries, + bytesRead: state.bytesRead, + truncated: state.truncated, + limitations: [ + PATH_RACE_LIMITATION, + ...(state.stopped + ? ["Traversal stopped because the entry limit was reached."] + : []), + ], + }); +}; + +const traverse = async ( + state: TraversalState, +): Promise> => { + while (state.pending.length > 0 && !state.stopped) { + if (isAborted(state.signal)) + return err({ + tag: "reference-source-reader", + code: "cancelled", + message: "Reference source traversal cancelled", + }); + const current = state.pending.pop(); + if (current === undefined) break; + const result = await traverseDirectory(state, current); + if (!result.ok) return result; + } + return ok(undefined); +}; diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts new file mode 100644 index 000000000..8d7f8cc99 --- /dev/null +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -0,0 +1,302 @@ +import { readdir, readlink, realpath } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +import { entryFailure, safeSize } from "./ReferenceSourceReaderErrors.js"; +import { + compareNames, + pathFromRoot, + withinRoot, +} from "./ReferenceSourceReaderPaths.js"; +import { readStableFile } from "./ReferenceSourceReaderFile.js"; +import { + isAborted, + sameFile, + validateDirectory, + bigLstat, +} from "./ReferenceSourceReaderValidate.js"; +import { + type BigStats, + type PendingDirectory, + type ReferenceSourceEntry, + type ReferenceSourceResult, + type TraversalState, +} from "./ReferenceSourceReaderTypes.js"; + +export const traverseDirectory = async ( + state: TraversalState, + current: PendingDirectory, +): Promise> => { + const before = await validateDirectory( + state.root, + state.rootIdentity, + current.path, + ); + if (!before.ok) { + state.entries.push( + entryFailure( + pathFromRoot(state.root, current.path), + "directory", + before.code, + before.message, + ), + ); + return { ok: true, value: undefined }; + } + const names = await readDirectoryNames(current.path); + if (!names.ok) { + state.entries.push( + entryFailure( + pathFromRoot(state.root, current.path), + "directory", + "io", + names.message, + ), + ); + return { ok: true, value: undefined }; + } + if (isAborted(state.signal)) + return { + ok: false, + error: { + tag: "reference-source-reader", + code: "cancelled", + message: "Reference source traversal cancelled", + }, + }; + const after = await validateDirectory( + state.root, + state.rootIdentity, + current.path, + ); + if (!after.ok || !sameFile(before.stats, after.stats)) { + state.entries.push( + entryFailure( + pathFromRoot(state.root, current.path), + "directory", + "changed", + "Directory changed while it was read", + ), + ); + return { ok: true, value: undefined }; + } + const directories: PendingDirectory[] = []; + for (const name of names.value) { + const result = await processEntry(state, current, name, directories); + if (!result.ok) return result; + if (state.stopped) break; + } + directories.reverse(); + state.pending.push(...directories); + return { ok: true, value: undefined }; +}; + +const readDirectoryNames = async ( + path: string, +): Promise< + | { readonly ok: true; readonly value: string[] } + | { readonly ok: false; readonly message: string } +> => { + try { + return { ok: true, value: (await readdir(path)).sort(compareNames) }; + } catch { + return { ok: false, message: "Directory could not be read" }; + } +}; + +const readMetadata = async ( + path: string, +): Promise< + | { readonly ok: true; readonly value: BigStats } + | { readonly ok: false; readonly message: string } +> => { + try { + return { ok: true, value: await bigLstat(path) }; + } catch { + return { ok: false, message: "Entry metadata could not be read" }; + } +}; + +const processEntry = async ( + state: TraversalState, + current: PendingDirectory, + name: string, + directories: PendingDirectory[], +): Promise> => { + if (isAborted(state.signal)) + return { + ok: false, + error: { + tag: "reference-source-reader", + code: "cancelled", + message: "Reference source traversal cancelled", + }, + }; + const absolute = join(current.path, name); + const path = pathFromRoot(state.root, absolute); + const excluded = applyExclusion(state.shouldExclude, path); + if (!excluded.ok) + return { + ok: false, + error: { + tag: "reference-source-reader", + code: "io", + message: "Reference source exclusion check failed", + }, + }; + if (excluded.value) return { ok: true, value: undefined }; + if (!reserveEntry(state)) return { ok: true, value: undefined }; + if (Buffer.byteLength(path) > state.limits.maxPathBytes) { + state.entries.push( + entryFailure(path, "unknown", "limit", "Path exceeds maxPathBytes"), + ); + state.truncated = true; + return { ok: true, value: undefined }; + } + const metadata = await readMetadata(absolute); + if (!metadata.ok) { + state.entries.push(entryFailure(path, "unknown", "io", metadata.message)); + return { ok: true, value: undefined }; + } + if (isAborted(state.signal)) + return { + ok: false, + error: { + tag: "reference-source-reader", + code: "cancelled", + message: "Reference source traversal cancelled", + }, + }; + if (metadata.value.isSymbolicLink()) + state.entries.push(await describeSymlink(state.root, absolute, path)); + else if (metadata.value.isDirectory()) { + if (current.depth < state.limits.maxDepth) { + state.entries.push({ status: "read", kind: "directory", path }); + directories.push({ path: absolute, depth: current.depth + 1 }); + } else { + state.entries.push( + entryFailure(path, "directory", "limit", "Directory exceeds maxDepth"), + ); + state.truncated = true; + } + } else if (!metadata.value.isFile()) + state.entries.push( + entryFailure( + path, + "other", + "unsupported", + "Entry is not a regular file", + safeSize(metadata.value.size), + ), + ); + else await processFileEntry(state, absolute, path, metadata.value); + return { ok: true, value: undefined }; +}; + +const processFileEntry = async ( + state: TraversalState, + absolute: string, + path: string, + metadata: BigStats, +): Promise => { + const remaining = state.limits.maxBytes - state.bytesRead; + if (metadata.size > BigInt(remaining)) { + state.entries.push( + entryFailure( + path, + "file", + "limit", + "File exceeds remaining maxBytes", + safeSize(metadata.size), + ), + ); + state.truncated = true; + return; + } + const result = await readStableFile({ + root: state.root, + rootIdentity: state.rootIdentity, + absolute, + path, + expected: metadata, + remaining, + ...(state.signal === undefined ? {} : { signal: state.signal }), + }); + if (result.status === "read" && result.kind === "file") + state.bytesRead += result.bytes.byteLength; + else if (result.status === "failed" && result.code === "limit") + state.truncated = true; + state.entries.push(result); +}; + +const describeSymlink = async ( + root: string, + absolute: string, + path: string, +): Promise => { + try { + const rawTarget = await readlink(absolute); + const lexicalTarget = resolve(dirname(absolute), rawTarget); + if (!withinRoot(root, lexicalTarget)) + return { + status: "read", + kind: "symlink", + path, + target: "", + targetState: "external", + }; + try { + const canonicalTarget = await realpath(lexicalTarget); + return withinRoot(root, canonicalTarget) + ? { + status: "read", + kind: "symlink", + path, + target: pathFromRoot(root, canonicalTarget), + targetState: "internal", + } + : { + status: "read", + kind: "symlink", + path, + target: "", + targetState: "external", + }; + } catch { + return { + status: "read", + kind: "symlink", + path, + target: pathFromRoot(root, lexicalTarget), + targetState: "missing", + }; + } + } catch { + return entryFailure( + path, + "symlink", + "io", + "Symbolic link target could not be read", + ); + } +}; + +const applyExclusion = ( + shouldExclude: ((path: string) => boolean) | undefined, + path: string, +): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => { + try { + return { ok: true, value: shouldExclude?.(path) === true }; + } catch { + return { ok: false }; + } +}; + +const reserveEntry = (state: TraversalState): boolean => { + if (state.filesSeen < state.limits.maxEntries) { + state.filesSeen += 1; + return true; + } + state.truncated = true; + state.stopped = true; + return false; +}; diff --git a/src/reference/ReferenceSourceReaderErrors.ts b/src/reference/ReferenceSourceReaderErrors.ts new file mode 100644 index 000000000..94c6cec11 --- /dev/null +++ b/src/reference/ReferenceSourceReaderErrors.ts @@ -0,0 +1,39 @@ +import { + type ReferenceSourceEntry, + type ReferenceSourceFailureCode, + type ReferenceSourceReaderError, +} from "./ReferenceSourceReaderTypes.js"; + +export const failure = ( + code: ReferenceSourceReaderError["code"], + message: string, +): ReferenceSourceReaderError => ({ + tag: "reference-source-reader", + code, + message, +}); + +export const cancelled = (): ReferenceSourceReaderError => + failure("cancelled", "Reference source traversal cancelled"); + +export const safeSize = (size: bigint): number | undefined => { + const value = Number(size); + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; +}; + +export const entryFailure = ( + ...[path, kind, code, message, size]: readonly [ + string, + Extract["kind"], + ReferenceSourceFailureCode, + string, + size?: number, + ] +): ReferenceSourceEntry => ({ + status: "failed", + kind, + path, + code, + message, + ...(size === undefined ? {} : { size }), +}); diff --git a/src/reference/ReferenceSourceReaderFile.ts b/src/reference/ReferenceSourceReaderFile.ts new file mode 100644 index 000000000..64bffdb38 --- /dev/null +++ b/src/reference/ReferenceSourceReaderFile.ts @@ -0,0 +1,246 @@ +import { constants } from "node:fs"; +import { open, type FileHandle } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { entryFailure, safeSize } from "./ReferenceSourceReaderErrors.js"; +import { + isAborted, + sameFile, + validateDirectory, +} from "./ReferenceSourceReaderValidate.js"; +import { + type BigStats, + type ReferenceSourceEntry, + type StableFileRequest, +} from "./ReferenceSourceReaderTypes.js"; + +const READ_CHUNK_BYTES = 64 * 1024; + +type PreparedFileRead = + | { + readonly status: "ready"; + readonly handle: FileHandle; + readonly before: BigStats; + readonly parentBefore: { readonly ok: true; readonly stats: BigStats }; + } + | { readonly status: "failed"; readonly entry: ReferenceSourceEntry }; + +type FileContentsRead = + | { readonly status: "ok"; readonly chunks: Buffer[]; readonly total: number } + | { readonly status: "failed"; readonly entry: ReferenceSourceEntry }; + +type FinalizeFileReadRequest = { + readonly root: string; + readonly rootIdentity: BigStats; + readonly absolute: string; + readonly path: string; + readonly handle: FileHandle; + readonly before: BigStats; + readonly parentBefore: { readonly ok: true; readonly stats: BigStats }; + readonly chunks: Buffer[]; + readonly total: number; +}; + +const prepareFileRead = async ( + request: StableFileRequest, +): Promise => { + const { root, rootIdentity, absolute, path, expected, signal } = request; + const parentBefore = await validateDirectory( + root, + rootIdentity, + dirname(absolute), + ); + if (!parentBefore.ok) + return { + status: "failed", + entry: entryFailure( + path, + "file", + parentBefore.code, + parentBefore.message, + safeSize(expected.size), + ), + }; + const handle = await open( + absolute, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + if (isAborted(signal)) { + await handle.close().catch(() => undefined); + return { + status: "failed", + entry: entryFailure( + path, + "file", + "cancelled", + "File read cancelled", + safeSize(expected.size), + ), + }; + } + const before = await handle.stat({ bigint: true }); + if (!sameFile(expected, before)) { + await handle.close().catch(() => undefined); + return { + status: "failed", + entry: entryFailure( + path, + "file", + "changed", + "File changed before it was read", + safeSize(before.size), + ), + }; + } + return { status: "ready", handle, before, parentBefore }; +}; + +const readFileContents = async (request: { + readonly handle: FileHandle; + readonly path: string; + readonly remaining: number; + readonly signal?: AbortSignal; +}): Promise => { + const { handle, path, remaining, signal } = request; + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + if (isAborted(signal)) + return { + status: "failed", + entry: entryFailure( + path, + "file", + "cancelled", + "File read cancelled", + total, + ), + }; + const capacity = Math.min(READ_CHUNK_BYTES, remaining + 1 - total); + if (capacity <= 0) + return { + status: "failed", + entry: entryFailure( + path, + "file", + "limit", + "File exceeds remaining maxBytes", + total, + ), + }; + const chunk = Buffer.allocUnsafe(capacity); + const read = await handle.read(chunk, 0, capacity, null); + if (isAborted(signal)) + return { + status: "failed", + entry: entryFailure( + path, + "file", + "cancelled", + "File read cancelled", + total, + ), + }; + if (read.bytesRead === 0) break; + total += read.bytesRead; + if (total > remaining) + return { + status: "failed", + entry: entryFailure( + path, + "file", + "limit", + "File exceeds remaining maxBytes", + total, + ), + }; + chunks.push(chunk.subarray(0, read.bytesRead)); + } + return { status: "ok", chunks, total }; +}; + +const finalizeFileRead = async ( + request: FinalizeFileReadRequest, +): Promise => { + const { + root, + rootIdentity, + absolute, + path, + handle, + before, + parentBefore, + chunks, + total, + } = request; + const after = await handle.stat({ bigint: true }); + const parentAfter = await validateDirectory( + root, + rootIdentity, + dirname(absolute), + ); + if (!parentAfter.ok || !sameFile(parentBefore.stats, parentAfter.stats)) + return entryFailure( + path, + "file", + "changed", + "Parent directory changed while file was read", + total, + ); + if (!sameFile(before, after) || BigInt(total) !== after.size) + return entryFailure( + path, + "file", + "changed", + "File changed while it was read", + total, + ); + return { + status: "read", + kind: "file", + path, + bytes: Buffer.concat(chunks, total), + size: total, + }; +}; + +export const readStableFile = async ( + request: StableFileRequest, +): Promise => { + const { root, rootIdentity, absolute, path, expected, remaining, signal } = + request; + let handle: FileHandle | undefined; + try { + const prepared = await prepareFileRead(request); + if (prepared.status === "failed") return prepared.entry; + handle = prepared.handle; + const contents = await readFileContents({ + handle, + path, + remaining, + ...(signal === undefined ? {} : { signal }), + }); + if (contents.status === "failed") return contents.entry; + return await finalizeFileRead({ + root, + rootIdentity, + absolute, + path, + handle, + before: prepared.before, + parentBefore: prepared.parentBefore, + chunks: contents.chunks, + total: contents.total, + }); + } catch { + return entryFailure( + path, + "file", + isAborted(signal) ? "cancelled" : "io", + "File could not be read safely", + safeSize(expected.size), + ); + } finally { + await handle?.close().catch(() => undefined); + } +}; diff --git a/src/reference/ReferenceSourceReaderPaths.ts b/src/reference/ReferenceSourceReaderPaths.ts new file mode 100644 index 000000000..968d98e82 --- /dev/null +++ b/src/reference/ReferenceSourceReaderPaths.ts @@ -0,0 +1,27 @@ +import { isAbsolute, relative, sep } from "node:path"; + +export const withinRoot = (root: string, path: string): boolean => { + const value = relative(root, path); + return ( + value === "" || + (value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value)) + ); +}; + +export const pathFromRoot = (root: string, path: string): string => { + const value = relative(root, path); + return withinRoot(root, path) + ? value.split(sep).join("/") || "." + : ""; +}; + +export const compareNames = (left: string, right: string): number => { + const leftPoints = [...left].map((value) => value.codePointAt(0) ?? 0); + const rightPoints = [...right].map((value) => value.codePointAt(0) ?? 0); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftPoints[index] ?? 0) - (rightPoints[index] ?? 0); + if (difference !== 0) return difference; + } + return leftPoints.length - rightPoints.length; +}; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts new file mode 100644 index 000000000..6d601e851 --- /dev/null +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -0,0 +1,105 @@ +import { type BigIntStats } from "node:fs"; + +import { type Result } from "../domain/result.js"; + +export interface ReferenceSourceLimits { + readonly maxBytes: number; + readonly maxEntries: number; + readonly maxDepth: number; + readonly maxPathBytes: number; +} + +export interface ReferenceSourceReaderOptions { + readonly signal?: AbortSignal; + readonly shouldExclude?: (path: string) => boolean; +} + +export type ReferenceSourceFailureCode = + | "cancelled" + | "changed" + | "io" + | "limit" + | "symlink" + | "unsupported"; + +export type ReferenceSourceEntry = + | { + readonly status: "read"; + readonly kind: "file"; + readonly path: string; + readonly bytes: Uint8Array; + readonly size: number; + } + | { + readonly status: "read"; + readonly kind: "directory"; + readonly path: string; + } + | { + readonly status: "read"; + readonly kind: "symlink"; + readonly path: string; + readonly target: string; + readonly targetState: "internal" | "external" | "missing"; + } + | { + readonly status: "failed"; + readonly kind: "file" | "directory" | "symlink" | "other" | "unknown"; + readonly path: string; + readonly code: ReferenceSourceFailureCode; + readonly message: string; + readonly size?: number; + }; + +export interface ReferenceSourceRead { + readonly root: string; + readonly entries: readonly ReferenceSourceEntry[]; + readonly bytesRead: number; + readonly truncated: boolean; + /** Node exposes no portable openat traversal; pathname identity is checked around each operation. */ + readonly limitations: readonly string[]; +} + +export interface ReferenceSourceReaderError { + readonly tag: "reference-source-reader"; + readonly code: + | "cancelled" + | "invalid-limits" + | "invalid-root" + | "io" + | "unsupported"; + readonly message: string; +} + +export type BigStats = BigIntStats; + +export type PendingDirectory = { + readonly path: string; + readonly depth: number; +}; + +export type TraversalState = { + readonly root: string; + readonly rootIdentity: BigStats; + readonly limits: ReferenceSourceLimits; + readonly signal?: AbortSignal; + readonly shouldExclude?: (path: string) => boolean; + readonly entries: ReferenceSourceEntry[]; + readonly pending: PendingDirectory[]; + bytesRead: number; + filesSeen: number; + truncated: boolean; + stopped: boolean; +}; + +export type StableFileRequest = { + readonly root: string; + readonly rootIdentity: BigStats; + readonly absolute: string; + readonly path: string; + readonly expected: BigStats; + readonly remaining: number; + readonly signal?: AbortSignal; +}; + +export type ReferenceSourceResult = Result; diff --git a/src/reference/ReferenceSourceReaderValidate.ts b/src/reference/ReferenceSourceReaderValidate.ts new file mode 100644 index 000000000..45986b678 --- /dev/null +++ b/src/reference/ReferenceSourceReaderValidate.ts @@ -0,0 +1,118 @@ +import { constants } from "node:fs"; +import { lstat, realpath } from "node:fs/promises"; + +import { err, ok } from "../domain/result.js"; +import { cancelled, failure } from "./ReferenceSourceReaderErrors.js"; +import { withinRoot } from "./ReferenceSourceReaderPaths.js"; +import { + type BigStats, + type ReferenceSourceFailureCode, + type ReferenceSourceLimits, + type ReferenceSourceResult, +} from "./ReferenceSourceReaderTypes.js"; + +export const bigLstat = (path: string): Promise => + lstat(path, { bigint: true }); + +export const sameFile = (left: BigStats, right: BigStats): boolean => + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs; + +export const isAborted = (signal?: AbortSignal): boolean => + signal?.aborted === true; + +const validLimits = (limits: ReferenceSourceLimits): boolean => + Object.values(limits).every( + (value) => Number.isSafeInteger(value) && value > 0, + ); + +export const noFollowOpenSupported = (): boolean => + Number.isSafeInteger(constants.O_NOFOLLOW) && constants.O_NOFOLLOW !== 0; + +export const validateDirectory = async ( + root: string, + rootIdentity: BigStats, + path: string, +): Promise< + | { readonly ok: true; readonly stats: BigStats } + | { + readonly ok: false; + readonly code: ReferenceSourceFailureCode; + readonly message: string; + } +> => { + try { + const rootNow = await bigLstat(root); + if (!sameFile(rootIdentity, rootNow)) + return { ok: false, code: "changed", message: "Reference root changed" }; + const stats = await bigLstat(path); + if (stats.isSymbolicLink()) + return { + ok: false, + code: "symlink", + message: "Symbolic links are not followed", + }; + if (!stats.isDirectory()) + return { + ok: false, + code: "changed", + message: "Directory identity changed", + }; + const canonical = await realpath(path); + if (canonical !== path || !withinRoot(root, canonical)) + return { + ok: false, + code: "changed", + message: "Directory escaped the reference root", + }; + return { ok: true, stats }; + } catch { + return { + ok: false, + code: "io", + message: "Directory identity could not be verified", + }; + } +}; + +export const prepareRoot = async ( + root: string, + limits: ReferenceSourceLimits, + signal?: AbortSignal, +): Promise< + ReferenceSourceResult<{ + readonly canonicalRoot: string; + readonly rootIdentity: BigStats; + }> +> => { + if (!validLimits(limits)) + return err( + failure("invalid-limits", "Reader limits must be positive integers"), + ); + if (isAborted(signal)) return err(cancelled()); + try { + const metadata = await bigLstat(root); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) + return err( + failure("invalid-root", "Reference source root must be a directory"), + ); + const canonicalRoot = await realpath(root); + if (isAborted(signal)) return err(cancelled()); + const canonicalMetadata = await bigLstat(canonicalRoot); + if (!sameFile(metadata, canonicalMetadata)) + return err( + failure( + "invalid-root", + "Reference source root changed during resolution", + ), + ); + return ok({ canonicalRoot, rootIdentity: canonicalMetadata }); + } catch { + return err( + failure("invalid-root", "Reference source root could not be resolved"), + ); + } +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index bad8e9ecb..b1020dbd2 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -6,8 +6,17 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { registerEnhancedTools } from "./registerEnhancedTools.js"; import { registerOfficialTools } from "./registerOfficialTools.js"; import { registerSessionTools } from "./registerSessionTools.js"; +import { registerNativeTools } from "./registerNativeTools.js"; +import { registerArtifactTools } from "./registerArtifactTools.js"; import { silentLogger, type Logger } from "../logger.js"; import type { ProcessExecutionPolicy } from "../domain/processCapture.js"; +import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; + +export interface CreateServerOptions { + readonly logger?: Logger; + readonly processPolicy?: ProcessExecutionPolicy; + readonly evidenceFilePolicy?: EvidenceFilePolicy; +} /** * Construct one MCP server without acquiring subprocess resources. @@ -17,9 +26,9 @@ import type { ProcessExecutionPolicy } from "../domain/processCapture.js"; export const createServer = ( analysis: AnalysisOperationPort, session?: BinarySessionPort, - logger: Logger = silentLogger, - processPolicy?: ProcessExecutionPolicy, + options: CreateServerOptions = {}, ): McpServer => { + const logger = options.logger ?? silentLogger; const server = new McpServer( { name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" }, { @@ -33,29 +42,40 @@ export const createServer = ( const toolLogger = logger.child({ layer: "server" }); const activeTarget = session === undefined ? undefined : () => session.activeTarget(); - const provider = session?.providerIdentity(); const recordEvidence = session === undefined ? undefined : (evidence: Parameters[0]) => session.recordEvidence(evidence); - registerOfficialTools( - server, - analysis, - toolLogger, + registerOfficialTools(server, analysis, { + logger: toolLogger, activeTarget, - provider, recordEvidence, - ); - registerEnhancedTools( - server, - analysis, - toolLogger, + recordUnknown: + session === undefined + ? undefined + : (input) => session.recordUnknown(input), + }); + registerEnhancedTools(server, analysis, { + logger: toolLogger, activeTarget, - provider, recordEvidence, - ); + recordUnknown: + session === undefined + ? undefined + : (input) => session.recordUnknown(input), + }); + registerNativeTools(server, analysis, { + logger: toolLogger, + activeTarget, + recordEvidence, + }); + registerArtifactTools(server, analysis, { + logger: toolLogger, + activeTarget, + recordEvidence, + }); if (session !== undefined) - registerSessionTools(server, session, toolLogger, processPolicy); + registerSessionTools(server, session, toolLogger, options); return server; }; diff --git a/src/server/recordDerivedEvidence.ts b/src/server/recordDerivedEvidence.ts new file mode 100644 index 000000000..99a761c42 --- /dev/null +++ b/src/server/recordDerivedEvidence.ts @@ -0,0 +1,19 @@ +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { AnalysisError } from "../domain/errors.js"; +import type { Evidence } from "../domain/evidence.js"; +import type { RecordUnknownInput } from "../domain/residualUnknown.js"; +import { ok, type Result } from "../domain/result.js"; + +/** Atomically record derived Evidence with an optional approved unknown. */ +export const recordDerivedEvidence = ( + session: BinarySessionPort, + evidence: Evidence, + unknown: RecordUnknownInput | undefined, +): Result => { + if (unknown !== undefined) { + const recorded = session.recordEvidenceWithUnknown(evidence, unknown); + return recorded.ok ? ok(evidence) : recorded; + } + const recorded = session.recordEvidence(evidence); + return recorded.ok ? ok(evidence) : recorded; +}; diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts new file mode 100644 index 000000000..3f85545c6 --- /dev/null +++ b/src/server/registerArtifactComparisonTool.ts @@ -0,0 +1,122 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; +import { + artifactComparisonInputSchema, + compareArtifacts, +} from "../domain/artifactComparison.js"; +import { createEvidence } from "../domain/evidence.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { RecordUnknownInput } from "../domain/residualUnknown.js"; +import { err } from "../domain/result.js"; +import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { ARTIFACT_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; +import { toCallToolResult } from "./toolResult.js"; + +/** Register Evidence-backed deterministic artifact comparison. */ +export const registerArtifactComparisonTool = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"compare_artifacts">, +): void => { + server.registerTool( + contract.name, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, + (input) => { + const parsed = artifactComparisonInputSchema.parse(input); + const comparison = compareArtifacts( + parsed.left, + parsed.right, + parsed.offset, + parsed.limit, + ); + const leftEvidenceIds = evidenceIds(parsed.left); + const rightEvidenceIds = evidenceIds(parsed.right); + if ( + [...leftEvidenceIds, ...rightEvidenceIds].some( + (evidenceId) => !session.hasEvidence(evidenceId), + ) + ) + return toCallToolResult( + err( + new EvidenceIntegrityError( + "Artifact comparison input Evidence is not present in this session", + ), + ), + contract, + ); + const evidence = createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { + predicateType: "rea.artifact-comparison/v1", + operation: contract.name, + parameters: { + left_evidence_ids: leftEvidenceIds, + right_evidence_ids: rightEvidenceIds, + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: [...leftEvidenceIds, ...rightEvidenceIds], + }); + return toCallToolResult( + recordDerivedEvidence( + session, + evidence, + artifactUnknownInput(parsed, comparison.status), + ), + contract, + ); + }, + ); +}; + +const artifactUnknownInput = ( + input: { + readonly left: + | { readonly evidence_id: string } + | readonly { readonly evidence_id: string }[]; + readonly right: + | { readonly evidence_id: string } + | readonly { readonly evidence_id: string }[]; + readonly unknown_registry_approved?: true | undefined; + }, + status: ReturnType["status"], +): RecordUnknownInput | undefined => { + if (input.unknown_registry_approved !== true || status === "unchanged") + return undefined; + return { + approved: true, + question: `Artifact comparison is ${status}`, + severity: + status === "unknown" || status === "truncated" ? "high" : "medium", + domain: "artifact-comparison", + supporting_evidence_ids: evidenceIds(input.left), + contradicting_evidence_ids: evidenceIds(input.right), + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: "inventory_artifact", + rationale: "Capture both complete artifact graphs under equal limits.", + }, + ], + relationships: [], + }; +}; + +const evidenceIds = ( + input: + | { readonly evidence_id: string } + | readonly { readonly evidence_id: string }[], +): string[] => + (Array.isArray(input) ? input : [input]).map(({ evidence_id: id }) => id); diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts new file mode 100644 index 000000000..f56069dce --- /dev/null +++ b/src/server/registerArtifactTools.ts @@ -0,0 +1,21 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; +import type { BinarySessionPort } from "../application/BinarySession.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { Logger } from "../logger.js"; +import { registerEvidenceTools } from "./registerEvidenceTools.js"; + +/** Register deterministic artifact inventory and safe extraction operations. */ +export const registerArtifactTools = ( + server: McpServer, + analysis: AnalysisOperationPort, + options: { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; + }, +): void => { + registerEvidenceTools(server, analysis, ARTIFACT_TOOL_CONTRACTS, options); +}; diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts new file mode 100644 index 000000000..5f95a45dc --- /dev/null +++ b/src/server/registerBundleComparisonTool.ts @@ -0,0 +1,92 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; +import { + bundleComparisonInputSchema, + compareBundles, +} from "../domain/bundleComparison.js"; +import { createEvidence } from "../domain/evidence.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { err, ok } from "../domain/result.js"; +import { BUNDLE_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; +import { toCallToolResult } from "./toolResult.js"; + +/** Register canonical Evidence bundle comparison. */ +export const registerBundleComparisonTool = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"compare_bundles">, +): void => { + server.registerTool( + contract.name, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, + (input) => { + const parsed = bundleComparisonInputSchema.parse(input); + const sourceIds = [ + ...new Set([ + ...parsed.left.records.map(({ evidence_id: id }) => id), + ...parsed.right.records.map(({ evidence_id: id }) => id), + ]), + ].sort(); + if (sourceIds.some((evidenceId) => !session.hasEvidence(evidenceId))) + return toCallToolResult( + err( + new EvidenceIntegrityError( + "Bundle comparison input Evidence is not present in this session", + ), + ), + contract, + ); + const ownedUnknownDigests = new Set( + session + .exportEvidenceBundle() + .unknowns.map(({ revision_digest: digest }) => digest), + ); + if ( + [...parsed.left.unknowns, ...parsed.right.unknowns].some( + ({ revision_digest: digest }) => !ownedUnknownDigests.has(digest), + ) + ) + return toCallToolResult( + err( + new EvidenceIntegrityError( + "Bundle comparison input unknown history is not present in this session", + ), + ), + contract, + ); + const comparison = compareBundles( + parsed.left, + parsed.right, + parsed.record_pairs, + parsed.offset, + parsed.limit, + ); + const evidence = createEvidence(undefined, BUNDLE_COMPARISON_PROVIDER, { + predicateType: "rea.bundle-comparison/v1", + operation: contract.name, + parameters: { + left_bundle_sha256: comparison.left_bundle_sha256, + right_bundle_sha256: comparison.right_bundle_sha256, + record_pairs: parsed.record_pairs, + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: sourceIds, + }); + const recorded = session.recordEvidence(evidence); + return toCallToolResult(recorded.ok ? ok(evidence) : recorded, contract); + }, + ); +}; diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index e3fe133d4..c287d407e 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { EnhancedTools } from "../application/EnhancedTools.js"; import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; +import type { BinarySessionPort } from "../application/BinarySession.js"; import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js"; import { ENHANCED_TOOL_CONTRACTS, @@ -14,29 +15,35 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; -import type { ProviderIdentity } from "../application/AnalysisProvider.js"; -import type { Evidence } from "../domain/evidence.js"; +import { UnknownRegistryError } from "../domain/errors.js"; + +/** Optional session services used by enhanced tool registration. */ +export interface EnhancedToolRegistration { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; + readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined; +} + +const WORKFLOW_PROVIDER = { + id: "rea-workflow", + name: "REA composed investigation workflow", + version: "1", +} as const; /** Register composed workflows against the same port as direct bridge tools. */ export const registerEnhancedTools = ( server: McpServer, analysis: AnalysisOperationPort, - logger: Logger, - activeTarget?: () => BinaryTarget | undefined, - provider: ProviderIdentity = { - id: "unidentified", - name: "Unidentified provider", - version: null, - }, - recordEvidence?: (evidence: Evidence) => void, + options: EnhancedToolRegistration, ): void => { const services = new EnhancedTools(analysis); for (const contract of ENHANCED_TOOL_CONTRACTS) { registerEnhancedTool(server, services, contract, { - logger, - activeTarget, - provider, - recordEvidence, + logger: options.logger, + activeTarget: options.activeTarget, + recordEvidence: options.recordEvidence, + recordUnknown: options.recordUnknown, }); } }; @@ -48,8 +55,8 @@ const registerEnhancedTool = ( registration: { readonly logger: Logger; readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly provider: ProviderIdentity; - readonly recordEvidence: ((evidence: Evidence) => void) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; + readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined; }, ): void => { const name = enhancedToolNameSchema.parse(contract.name); @@ -78,19 +85,90 @@ const registerEnhancedTool = ( if (result.ok) { const evidence = createEvidence( registration.activeTarget?.(), - registration.provider, + WORKFLOW_PROVIDER, { operation: name, parameters, result: result.value, confidence: "derived", - redactedRawPayload: result.value, + limitations: [ + "Derived by an REA workflow from one or more provider observations.", + ], }, ); - registration.recordEvidence?.(evidence); + const recorded = registration.recordEvidence?.(evidence); + if (recorded !== undefined && !recorded.ok) + return toCallToolResult(recorded, contract); + const unknowns = recordWorkflowUnknowns({ + name, + input: parsedInput, + result: result.value, + evidenceId: evidence.evidence_id, + recordUnknown: registration.recordUnknown, + }); + if (!unknowns.ok) return toCallToolResult(unknowns, contract); return toCallToolResult({ ok: true, value: evidence }, contract); } return toCallToolResult(result, contract); }, ); }; + +interface WorkflowUnknownInput { + readonly name: string; + readonly input: Readonly>; + readonly result: JsonValue; + readonly evidenceId: string; + readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined; +} + +const recordWorkflowUnknowns = ({ + name, + input, + result, + evidenceId, + recordUnknown, +}: WorkflowUnknownInput): + | ReturnType + | { readonly ok: true; readonly value: null } => { + if ( + name !== "trace_feature" || + input.unknown_registry_approved !== true || + recordUnknown === undefined || + typeof result !== "object" || + result === null || + Array.isArray(result) || + !Array.isArray(result.residual_unknowns) + ) + return { ok: true, value: null }; + for (const question of result.residual_unknowns) { + if (typeof question !== "string") continue; + const recorded = recordUnknown({ + approved: true, + question, + severity: "medium", + domain: "control-flow", + supporting_evidence_ids: [evidenceId], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: "trace_feature", + rationale: "Continue with a larger bounded budget.", + }, + ], + relationships: [], + }); + if ( + !recorded.ok && + !( + recorded.error instanceof UnknownRegistryError && + recorded.error.reason === "already-exists" + ) + ) + return recorded; + } + return { ok: true, value: null }; +}; diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts new file mode 100644 index 000000000..22760569c --- /dev/null +++ b/src/server/registerEvidenceTools.ts @@ -0,0 +1,75 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { + AnalysisOperation, + AnalysisOperationPort, +} from "../application/AnalysisProvider.js"; +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import { createEvidence } from "../domain/evidence.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toCallToolResult } from "./toolResult.js"; + +interface EvidenceToolRegistration { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; +} + +/** Register provider-backed contracts that return atomic Evidence v2 observations. */ +export const registerEvidenceTools = ( + server: McpServer, + analysis: AnalysisOperationPort, + contracts: readonly ToolContract>[], + options: EvidenceToolRegistration, +): void => { + for (const contract of contracts) { + server.registerTool( + contract.name, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, + async (input, context) => { + const parameters = jsonObject(contract.inputSchema.parse(input)); + const execution = await logToolExecution( + options.logger, + contract.name, + () => + analysis.execute(contract.name, parameters, { + signal: context.mcpReq.signal, + }), + ); + if (!execution.ok) return toCallToolResult(execution, contract); + const evidence = createEvidence( + execution.value.subject ?? options.activeTarget?.(), + execution.value.provider, + { + operation: contract.name, + parameters, + result: execution.value.result, + rawResult: execution.value.rawResult, + limitations: execution.value.limitations, + locations: execution.value.locations, + }, + ); + const recorded = options.recordEvidence?.(evidence); + return recorded !== undefined && !recorded.ok + ? toCallToolResult(recorded, contract) + : toCallToolResult({ ok: true, value: evidence }, contract); + }, + ); + } +}; + +const jsonObject = (input: unknown): Readonly> => { + const parsed = jsonValueSchema.parse(input); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + throw new TypeError("Provider tool input was not an object"); + return parsed; +}; diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts new file mode 100644 index 000000000..aa7f782e0 --- /dev/null +++ b/src/server/registerFunctionComparisonTool.ts @@ -0,0 +1,118 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; +import { + compareFunctions, + functionComparisonInputSchema, +} from "../domain/functionComparison.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { RecordUnknownInput } from "../domain/residualUnknown.js"; +import { err } from "../domain/result.js"; +import { FUNCTION_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; +import { toCallToolResult } from "./toolResult.js"; +import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; + +/** Register explicit Evidence-backed function comparison. */ +export const registerFunctionComparisonTool = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"compare_functions">, +): void => { + server.registerTool( + contract.name, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, + (input) => { + const parsed = functionComparisonInputSchema.parse(input); + const leftIds = evidenceIds(parsed.left); + const rightIds = evidenceIds(parsed.right); + const missing = [...leftIds, ...rightIds].filter( + (evidenceId) => !session.hasEvidence(evidenceId), + ); + if (missing.length > 0) + return toCallToolResult( + err( + new EvidenceIntegrityError( + "Function comparison input Evidence is not present in this session", + ), + ), + contract, + ); + const comparison = compareFunctions( + parsed.left, + parsed.right, + parsed.offset, + parsed.limit, + ); + const evidence = createEvidence(undefined, FUNCTION_COMPARISON_PROVIDER, { + predicateType: "rea.function-comparison/v1", + operation: contract.name, + parameters: { + left_evidence_ids: leftIds, + right_evidence_ids: rightIds, + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: [...leftIds, ...rightIds], + }); + const recorded = recordDerivedEvidence( + session, + evidence, + functionUnknownInput({ + approved: parsed.unknown_registry_approved, + status: comparison.status, + leftIds, + rightIds, + }), + ); + return toCallToolResult(recorded, contract); + }, + ); +}; + +const functionUnknownInput = ({ + approved, + status, + leftIds, + rightIds, +}: { + approved: true | undefined; + status: ReturnType["status"]; + leftIds: readonly string[]; + rightIds: readonly string[]; +}): RecordUnknownInput | undefined => { + if (approved !== true || status === "unchanged") return undefined; + return { + approved: true, + question: `Function comparison is ${status}`, + severity: status === "changed" ? "medium" : "high", + domain: "function-comparison", + supporting_evidence_ids: [...leftIds], + contradicting_evidence_ids: [...rightIds], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: "analyze_function", + rationale: + "Capture complete dossiers with equal providers and analysis limits.", + }, + ], + relationships: [], + }; +}; + +const evidenceIds = (input: Evidence | readonly Evidence[]): string[] => + (Array.isArray(input) ? input : [input]).map(({ evidence_id: id }) => id); diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts new file mode 100644 index 000000000..956be39f3 --- /dev/null +++ b/src/server/registerInvestigationTools.ts @@ -0,0 +1,383 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; +import { buildCallPath, callPathInputSchema } from "../domain/callPath.js"; +import { + correlateStaticAndRuntime, + staticRuntimeCorrelationInputSchema, +} from "../domain/staticRuntimeCorrelation.js"; +import { + reconstructionVerificationInputSchema, + verifyReconstruction, +} from "../domain/reconstructionVerification.js"; +import { + changedBehaviorInputSchema, + findChangedBehavior, +} from "../domain/changedBehavior.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + CALL_PATH_PROVIDER, + CHANGED_BEHAVIOR_PROVIDER, + RECONSTRUCTION_PROVIDER, + STATIC_RUNTIME_PROVIDER, +} from "./sessionToolPolicies.js"; +import { toCallToolResult } from "./toolResult.js"; +import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; + +/** Register Evidence-composed differential investigation workflows. */ +export const registerInvestigationTools = ( + server: McpServer, + session: BinarySessionPort, + contracts: readonly [ + ToolContract<"find_changed_behavior">, + ToolContract<"build_call_path">, + ToolContract<"correlate_static_and_runtime">, + ToolContract<"verify_reconstruction">, + ], +): void => { + registerChangedBehavior(server, session, contracts[0]); + registerCallPath(server, session, contracts[1]); + registerStaticRuntime(server, session, contracts[2]); + registerReconstruction(server, session, contracts[3]); +}; + +const registerChangedBehavior = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"find_changed_behavior">, +): void => { + server.registerTool(contract.name, contractOptions(contract), (input) => { + const parsed = changedBehaviorInputSchema.parse(input); + const closure = evidenceClosure( + session, + comparisonClosure(parsed.comparisons), + ); + if (!closure.ok) return toCallToolResult(closure, contract); + const links = closure.value; + const result = findChangedBehavior( + parsed.comparisons, + parsed.offset, + parsed.limit, + ); + const evidence = createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, { + predicateType: "rea.changed-behavior/v1", + operation: contract.name, + parameters: { + comparison_evidence_ids: parsed.comparisons.map( + ({ evidence_id: id }) => id, + ), + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(result), + confidence: "derived", + authority: "analyst-inference", + limitations: result.limitations, + evidenceLinks: links, + }); + const recorded = recordWorkflowEvidence( + session, + evidence, + parsed.unknown_registry_approved, + result.behavior_status === "unknown" || + result.behavior_status === "truncated", + { + question: `Changed behavior result ${evidence.evidence_id} remains ${result.behavior_status}`, + domain: "changed-behavior", + requiredAuthority: "controlled-replay", + requiredConfidence: "observed", + probes: [ + { + operation: "capture_process_scenario", + rationale: + "Capture both versions under the same bounded scenario and environment.", + }, + ], + }, + ); + return toCallToolResult(recorded, contract); + }); +}; + +const registerCallPath = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"build_call_path">, +): void => { + server.registerTool(contract.name, contractOptions(contract), (input) => { + const parsed = callPathInputSchema.parse(input); + const closure = evidenceClosure( + session, + functionEvidenceIds(parsed.functions), + ); + if (!closure.ok) return toCallToolResult(closure, contract); + const links = closure.value; + const result = buildCallPath(parsed); + const evidence = createEvidence(undefined, CALL_PATH_PROVIDER, { + predicateType: "rea.call-path/v1", + operation: contract.name, + parameters: { + start: parsed.start.address, + goal: parsed.goal.address, + max_depth: parsed.max_depth, + max_paths: parsed.max_paths, + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(result), + confidence: "derived", + authority: "analyst-inference", + limitations: result.limitations, + evidenceLinks: links, + }); + const recorded = recordWorkflowEvidence( + session, + evidence, + parsed.unknown_registry_approved, + result.status === "unknown" || result.status === "truncated", + { + question: `Call path ${parsed.start.address} → ${parsed.goal.address} is ${result.status}`, + domain: "call-path", + requiredAuthority: "shipped-artifact", + requiredConfidence: "derived", + probes: [ + { + operation: "analyze_function", + rationale: + "Collect complete callee dossiers for the reported frontier addresses.", + }, + ], + }, + ); + return toCallToolResult(recorded, contract); + }); +}; + +const registerStaticRuntime = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"correlate_static_and_runtime">, +): void => { + server.registerTool(contract.name, contractOptions(contract), (input) => { + const parsed = staticRuntimeCorrelationInputSchema.parse(input); + const closure = evidenceClosure( + session, + comparisonClosure([ + ...parsed.static_comparisons, + ...parsed.runtime_comparisons, + ]), + ); + if (!closure.ok) return toCallToolResult(closure, contract); + const links = closure.value; + const result = correlateStaticAndRuntime(parsed); + const evidence = createEvidence(undefined, STATIC_RUNTIME_PROVIDER, { + predicateType: "rea.static-runtime-correlation/v1", + operation: contract.name, + parameters: { + mapping_count: parsed.mappings.length, + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(result), + confidence: "inferred", + authority: "analyst-inference", + limitations: result.limitations, + evidenceLinks: links, + }); + return toCallToolResult( + recordWorkflowEvidence( + session, + evidence, + parsed.unknown_registry_approved, + result.status === "unknown" || result.status === "truncated", + { + question: `Static/runtime correlation result ${evidence.evidence_id} is ${result.status}`, + domain: "static-runtime-correlation", + requiredAuthority: null, + requiredConfidence: "derived", + probes: [ + { + operation: "capture_process_scenario", + rationale: + "Repeat runtime observations and complete the mapped static comparison Evidence.", + }, + ], + }, + ), + contract, + ); + }); +}; + +const registerReconstruction = ( + server: McpServer, + session: BinarySessionPort, + contract: ToolContract<"verify_reconstruction">, +): void => { + server.registerTool(contract.name, contractOptions(contract), (input) => { + const parsed = reconstructionVerificationInputSchema.parse(input); + const owned = session.exportEvidenceBundle(); + const ownedUnknowns = new Set( + owned.unknowns.map(({ revision_digest: digest }) => digest), + ); + const ownedEvidenceIds = new Set( + owned.records.map(({ evidence_id: id }) => id), + ); + if ( + parsed.evidence_bundle.records.some( + ({ evidence_id: id }) => !ownedEvidenceIds.has(id), + ) || + parsed.evidence_bundle.unknowns.some( + ({ revision_digest: digest }) => !ownedUnknowns.has(digest), + ) + ) + return toCallToolResult( + err( + new EvidenceIntegrityError( + "Reconstruction input unknown history is not present in this session", + ), + ), + contract, + ); + const result = verifyReconstruction( + parsed.specification, + owned, + parsed.offset, + parsed.limit, + ); + const closure = evidenceClosure(session, result.evidence_links); + if (!closure.ok) return toCallToolResult(closure, contract); + const links = closure.value; + const evidence = createEvidence(undefined, RECONSTRUCTION_PROVIDER, { + predicateType: "rea.reconstruction-verification/v1", + operation: contract.name, + parameters: { + specification_sha256: result.specification_sha256, + claim_ids: parsed.specification.claims.map(({ claim_id: id }) => id), + offset: parsed.offset, + limit: parsed.limit, + }, + result: jsonValueSchema.parse(result), + confidence: "derived", + authority: "analyst-inference", + limitations: result.limitations, + evidenceLinks: links, + }); + return toCallToolResult( + recordWorkflowEvidence( + session, + evidence, + parsed.unknown_registry_approved, + result.status === "unknown", + { + question: `Reconstruction ${result.specification_sha256} has unknown claims`, + domain: "reconstruction-verification", + requiredAuthority: null, + requiredConfidence: "derived", + probes: result.recommended_probes.map(({ operation, rationale }) => ({ + operation, + rationale, + })), + }, + ), + contract, + ); + }); +}; + +const contractOptions = (contract: ToolContract) => ({ + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, +}); + +const comparisonClosure = (comparisons: readonly Evidence[]): string[] => + uniqueIds( + comparisons.flatMap((evidence) => [ + evidence.evidence_id, + ...evidence.evidence_links, + ]), + ); + +const functionEvidenceIds = ( + groups: readonly (Evidence | readonly Evidence[])[], +): string[] => + uniqueIds( + groups.flatMap((group) => + (Array.isArray(group) ? group : [group]).map(({ evidence_id: id }) => id), + ), + ); + +const uniqueIds = (ids: readonly string[]): string[] => + [...new Set(ids)].sort((left, right) => left.localeCompare(right)); + +const evidenceClosure = ( + session: BinarySessionPort, + seedIds: readonly string[], +): Result => { + const records = new Map( + session + .exportEvidenceBundle() + .records.map((evidence) => [evidence.evidence_id, evidence]), + ); + const visited = new Set(); + const pending = [...seedIds]; + while (pending.length > 0) { + const evidenceId = pending.pop(); + if (evidenceId === undefined || visited.has(evidenceId)) continue; + const evidence = records.get(evidenceId); + if (evidence === undefined) + return err( + new EvidenceIntegrityError( + "Investigation input has a dangling Evidence link", + ), + ); + visited.add(evidenceId); + pending.push(...evidence.evidence_links); + } + return ok(uniqueIds([...visited])); +}; + +const recordWorkflowEvidence = ( + ...[session, evidence, approved, unresolved, input]: readonly [ + BinarySessionPort, + Evidence, + true | undefined, + boolean, + { + readonly question: string; + readonly domain: string; + readonly requiredAuthority: + | "shipped-artifact" + | "controlled-replay" + | null; + readonly requiredConfidence: "observed" | "derived"; + readonly probes: readonly { + readonly operation: string; + readonly rationale: string; + }[]; + }, + ] +): Result => { + if (approved !== true || !unresolved) { + return recordDerivedEvidence(session, evidence, undefined); + } + return recordDerivedEvidence(session, evidence, { + approved: true, + question: input.question, + severity: "high", + domain: input.domain, + supporting_evidence_ids: [evidence.evidence_id], + contradicting_evidence_ids: [], + required_authority: input.requiredAuthority, + required_confidence: input.requiredConfidence, + required_environment: null, + recommended_probes: [...input.probes], + relationships: [], + }); +}; diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts new file mode 100644 index 000000000..10c37b5a9 --- /dev/null +++ b/src/server/registerNativeTools.ts @@ -0,0 +1,21 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; +import type { BinarySessionPort } from "../application/BinarySession.js"; +import { NATIVE_TOOL_CONTRACTS } from "../contracts/nativeToolContracts.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { Logger } from "../logger.js"; +import { registerEvidenceTools } from "./registerEvidenceTools.js"; + +/** Register provider-neutral static inspection operations. */ +export const registerNativeTools = ( + server: McpServer, + analysis: AnalysisOperationPort, + options: { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; + }, +): void => { + registerEvidenceTools(server, analysis, NATIVE_TOOL_CONTRACTS, options); +}; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 0df413f9e..d633c6564 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -1,38 +1,39 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { AnalysisOperationPort } from "../application/AnalysisProvider.js"; -import { - OFFICIAL_TOOL_CONTRACTS, - type ToolContract, -} from "../contracts/toolContracts.js"; +import type { BinarySessionPort } from "../application/BinarySession.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { toCallToolResult } from "./toolResult.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; import { createEvidence } from "../domain/evidence.js"; -import type { ProviderIdentity } from "../application/AnalysisProvider.js"; -import type { Evidence } from "../domain/evidence.js"; +import { + AnalysisCapabilityUnavailableError, + UnknownRegistryError, +} from "../domain/errors.js"; + +/** Optional session services used by direct tool registration. */ +export interface OfficialToolRegistration { + readonly logger: Logger; + readonly activeTarget: (() => BinaryTarget | undefined) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; + readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined; +} /** Register direct bridge proxies, preserving MCP cancellation and typed errors. */ export const registerOfficialTools = ( server: McpServer, analysis: AnalysisOperationPort, - logger: Logger, - activeTarget?: () => BinaryTarget | undefined, - provider: ProviderIdentity = { - id: "unidentified", - name: "Unidentified provider", - version: null, - }, - recordEvidence?: (evidence: Evidence) => void, + options: OfficialToolRegistration, ): void => { for (const contract of OFFICIAL_TOOL_CONTRACTS) { registerOfficialTool(server, analysis, contract, { - logger, - activeTarget, - provider, - recordEvidence, + logger: options.logger, + activeTarget: options.activeTarget, + recordEvidence: options.recordEvidence, + recordUnknown: options.recordUnknown, }); } }; @@ -40,12 +41,12 @@ export const registerOfficialTools = ( const registerOfficialTool = ( server: McpServer, analysis: AnalysisOperationPort, - contract: ToolContract, + contract: (typeof OFFICIAL_TOOL_CONTRACTS)[number], registration: { readonly logger: Logger; readonly activeTarget: (() => BinaryTarget | undefined) | undefined; - readonly provider: ProviderIdentity; - readonly recordEvidence: ((evidence: Evidence) => void) | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; + readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined; }, ): void => { server.registerTool( @@ -68,25 +69,62 @@ const registerOfficialTool = ( ); if (result.ok) { const evidence = createEvidence( - registration.activeTarget?.(), - registration.provider, + result.value.subject ?? registration.activeTarget?.(), + result.value.provider, { operation: contract.name, parameters: arguments_, - result: result.value, - redactedRawPayload: result.value, + result: result.value.result, + rawResult: result.value.rawResult, + limitations: result.value.limitations, + locations: result.value.locations, }, ); - registration.recordEvidence?.(evidence); + const recorded = registration.recordEvidence?.(evidence); + if (recorded !== undefined && !recorded.ok) + return toCallToolResult(recorded, contract); return toCallToolResult({ ok: true, value: evidence }, contract); } + if ( + result.error instanceof AnalysisCapabilityUnavailableError && + approvedUnknownTracking(input) && + registration.recordUnknown !== undefined + ) { + const unknown = registration.recordUnknown({ + approved: true, + question: `${contract.name} is unavailable: ${result.error.reason}`, + severity: "medium", + domain: "provider-capability", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: contract.name, + rationale: + "Use a provider that declares this capability available.", + }, + ], + relationships: [], + }); + if ( + !unknown.ok && + !( + unknown.error instanceof UnknownRegistryError && + unknown.error.reason === "already-exists" + ) + ) + return toCallToolResult(unknown, contract); + } return toCallToolResult(result, contract); }, ); }; const projectOfficialArguments = ( - contract: ToolContract, + contract: (typeof OFFICIAL_TOOL_CONTRACTS)[number], input: unknown, ): Readonly> => { const parsed = jsonValueSchema.safeParse(input); @@ -102,7 +140,14 @@ const projectOfficialArguments = ( const projected: Record = {}; for (const key of Object.keys(contract.inputSchema.shape)) { + if (key === "unknown_registry_approved") continue; projected[key] = parsed.data[key] ?? null; } return projected; }; + +const approvedUnknownTracking = (input: unknown): boolean => + typeof input === "object" && + input !== null && + "unknown_registry_approved" in input && + input.unknown_registry_approved === true; diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts new file mode 100644 index 000000000..de647ecf9 --- /dev/null +++ b/src/server/registerProcessComparisonTool.ts @@ -0,0 +1,159 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import { createEvidence } from "../domain/evidence.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { RecordUnknownInput } from "../domain/residualUnknown.js"; +import { + compareProcessCaptures, + processCaptureSchema, +} from "../domain/processCapture.js"; +import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { err } from "../domain/result.js"; +import { PROCESS_PROVIDER } from "./sessionToolPolicies.js"; +import { toCallToolResult } from "./toolResult.js"; + +/** Register deterministic process-capture comparison and contradiction tracking. */ +export const registerProcessComparisonTool = ( + server: McpServer, + session: BinarySessionPort, + contract: (typeof SESSION_TOOL_CONTRACTS)[6], +): void => { + server.registerTool( + contract.name, + { + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, + }, + (input) => { + const parsed = z + .object({ + left_evidence_id: z.string(), + left: processCaptureSchema, + right_evidence_id: z.string(), + right: processCaptureSchema, + unknown_registry_approved: z.literal(true).optional(), + }) + .parse(input); + const validated = validateCaptureSources(session, parsed); + if (!validated.ok) return toCallToolResult(validated, contract); + const comparison = compareProcessCaptures(parsed.left, parsed.right); + const evidence = createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-comparison/v1", + operation: contract.name, + parameters: { + left_evidence_id: parsed.left_evidence_id, + right_evidence_id: parsed.right_evidence_id, + left_normalization: parsed.left.normalization, + right_normalization: parsed.right.normalization, + }, + result: jsonValueSchema.parse(comparison), + confidence: "derived", + authority: "analyst-inference", + limitations: comparison.limitations, + evidenceLinks: [parsed.left_evidence_id, parsed.right_evidence_id], + }); + return toCallToolResult( + recordDerivedEvidence( + session, + evidence, + comparisonUnknownInput(parsed, comparison), + ), + contract, + ); + }, + ); +}; + +const validateCaptureSources = ( + session: BinarySessionPort, + parsed: { + readonly left_evidence_id: string; + readonly left: z.infer; + readonly right_evidence_id: string; + readonly right: z.infer; + }, +) => { + for (const [evidenceId, capture] of [ + [parsed.left_evidence_id, parsed.left], + [parsed.right_evidence_id, parsed.right], + ] as const) { + const evidence = session.evidenceById(evidenceId); + const result = processCaptureSchema.safeParse(evidence?.normalized_result); + if ( + evidence === undefined || + evidence.operation !== "capture_process_scenario" || + evidence.predicate_type !== "rea.process-capture/v2" || + evidence.provider.id !== PROCESS_PROVIDER.id || + evidence.provider.name !== PROCESS_PROVIDER.name || + evidence.provider.version !== PROCESS_PROVIDER.version || + evidence.confidence !== "observed" || + evidence.authority !== "controlled-replay" || + !result.success || + canonicalJson(result.data) !== canonicalJson(capture) + ) + return err( + new EvidenceIntegrityError( + "Process comparison payload does not match its source Evidence", + ), + ); + } + return { ok: true as const, value: null }; +}; + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Process capture could not be canonicalized"); + return encoded; +}; + +const comparisonUnknownInput = ( + parsed: { + readonly left_evidence_id: string; + readonly right_evidence_id: string; + readonly unknown_registry_approved?: true | undefined; + }, + comparison: ReturnType, +): RecordUnknownInput | undefined => { + if ( + parsed.unknown_registry_approved !== true || + comparison.status === "unchanged" + ) + return undefined; + const differingScopes = [ + ["terminal", comparison.terminal], + ["exit", comparison.exit], + ["filesystem", comparison.filesystem], + ["protocol", comparison.protocol], + ["process", comparison.process], + ] + .filter(([, status]) => status !== "unchanged") + .map(([scope]) => scope) + .join(", "); + return { + approved: true, + question: `Process captures disagree across: ${differingScopes}`, + severity: "high", + domain: "process-comparison", + supporting_evidence_ids: [parsed.left_evidence_id], + contradicting_evidence_ids: [parsed.right_evidence_id], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: "capture_process_scenario", + rationale: + "Repeat both scenarios under the same controlled environment.", + }, + ], + relationships: [], + }; +}; diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 7b504aeae..7eeb02dc9 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -6,30 +6,85 @@ import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { toCallToolResult } from "./toolResult.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; -import { err, ok } from "../domain/result.js"; -import { EvidenceLedgerError } from "../domain/errors.js"; -import type { ProcessExecutionPolicy } from "../domain/processCapture.js"; -import { - compareProcessCaptures, - processCaptureSchema, - processScenarioSchema, +import { ok, type Result } from "../domain/result.js"; +import type { + ProcessCapture, + ProcessExecutionPolicy, + ProcessScenario, } from "../domain/processCapture.js"; +import { processScenarioSchema } from "../domain/processCapture.js"; import { captureProcessScenario } from "../application/ProcessHarness.js"; -import { createEvidence } from "../domain/evidence.js"; -import { jsonValueSchema } from "../domain/jsonValue.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + recordUnknownInputSchema, + updateUnknownInputSchema, +} from "../domain/residualUnknown.js"; +import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; +import { + readEvidenceBundle, + writeEvidenceBundle, +} from "../application/EvidenceBundleFiles.js"; +import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js"; +import { + DENY_EVIDENCE_FILE_POLICY, + DENY_PROCESS_POLICY, + PROCESS_PROVIDER, +} from "./sessionToolPolicies.js"; +import { registerProcessComparisonTool } from "./registerProcessComparisonTool.js"; +import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool.js"; +import { registerFunctionComparisonTool } from "./registerFunctionComparisonTool.js"; +import { registerBundleComparisonTool } from "./registerBundleComparisonTool.js"; +import { registerInvestigationTools } from "./registerInvestigationTools.js"; -const DENY_PROCESS_POLICY: ProcessExecutionPolicy = { - enabled: false, - executableRoots: [], - workingRoots: [], - allowedEnvironment: [], +const recordProcessResidualUnknowns = ( + session: BinarySessionPort, + scenario: ProcessScenario, + evidence: Evidence, + residuals: ProcessCapture["residual_unknowns"], +): Result => { + if (scenario.unknown_registry_approved !== true) return ok(null); + for (const residual of residuals) { + const unknown = session.recordUnknown({ + approved: true, + question: `${residual.scope}: ${residual.reason}`, + severity: "medium", + domain: `process-${residual.scope}`, + supporting_evidence_ids: [evidence.evidence_id], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: evidence.environment, + recommended_probes: [ + { + operation: "capture_process_scenario", + rationale: + "Repeat with a scenario that observes the missing behavior.", + }, + ], + relationships: [], + }); + if ( + !unknown.ok && + !( + unknown.error instanceof UnknownRegistryError && + unknown.error.reason === "already-exists" + ) + ) + return unknown; + } + return ok(null); }; -const PROCESS_PROVIDER = { - id: "rea-process", - name: "REA deterministic process harness", - version: "1", -} as const; +const processEvidenceParameters = ( + scenario: ProcessScenario, +): Readonly> => ({ + executable_name: scenario.executable.split("/").at(-1) ?? scenario.executable, + argument_count: scenario.arguments.length, + event_count: scenario.events.length, + filesystem_root_count: scenario.filesystem_roots.length, + normalization: scenario.normalization, +}); interface ProcessToolRegistration { readonly server: McpServer; @@ -37,7 +92,6 @@ interface ProcessToolRegistration { readonly logger: Logger; readonly processPolicy: ProcessExecutionPolicy; readonly captureContract: (typeof SESSION_TOOL_CONTRACTS)[5]; - readonly compareContract: (typeof SESSION_TOOL_CONTRACTS)[6]; } const registerProcessTools = ({ @@ -46,7 +100,6 @@ const registerProcessTools = ({ logger, processPolicy, captureContract, - compareContract, }: ProcessToolRegistration): void => { server.registerTool( captureContract.name, @@ -70,15 +123,9 @@ const registerProcessTools = ({ ); if (!captured.ok) return toCallToolResult(captured, captureContract); const evidence = createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-capture/v1", + predicateType: "rea.process-capture/v2", operation: captureContract.name, - parameters: { - executable_name: - scenario.executable.split("/").at(-1) ?? scenario.executable, - argument_count: scenario.arguments.length, - event_count: scenario.events.length, - filesystem_root_count: scenario.filesystem_roots.length, - }, + parameters: processEvidenceParameters(scenario), result: jsonValueSchema.parse(captured.value), confidence: "observed", authority: "controlled-replay", @@ -90,42 +137,18 @@ const registerProcessTools = ({ }, limitations: captured.value.limitations, }); - session.recordEvidence(evidence); + const recorded = session.recordEvidence(evidence); + if (!recorded.ok) return toCallToolResult(recorded, captureContract); + const unknowns = recordProcessResidualUnknowns( + session, + scenario, + evidence, + captured.value.residual_unknowns, + ); + if (!unknowns.ok) return toCallToolResult(unknowns, captureContract); return toCallToolResult(ok(evidence), captureContract); }, ); - server.registerTool( - compareContract.name, - { - description: compareContract.description, - inputSchema: compareContract.inputSchema, - outputSchema: compareContract.outputSchema, - annotations: compareContract.annotations, - }, - (input) => { - const parsed = z - .object({ - left_evidence_id: z.string(), - left: processCaptureSchema, - right_evidence_id: z.string(), - right: processCaptureSchema, - }) - .parse(input); - const comparison = compareProcessCaptures(parsed.left, parsed.right); - const evidence = createEvidence(undefined, PROCESS_PROVIDER, { - predicateType: "rea.process-comparison/v1", - operation: compareContract.name, - parameters: {}, - result: jsonValueSchema.parse(comparison), - confidence: "derived", - authority: "analyst-inference", - limitations: comparison.limitations, - evidenceLinks: [parsed.left_evidence_id, parsed.right_evidence_id], - }); - session.recordEvidence(evidence); - return toCallToolResult(ok(evidence), compareContract); - }, - ); }; interface EvidenceToolRegistration { @@ -133,6 +156,7 @@ interface EvidenceToolRegistration { readonly session: BinarySessionPort; readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3]; readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4]; + readonly filePolicy: EvidenceFilePolicy; } const registerEvidenceTools = ({ @@ -140,6 +164,7 @@ const registerEvidenceTools = ({ session, exportContract, importContract, + filePolicy, }: EvidenceToolRegistration): void => { server.registerTool( exportContract.name, @@ -149,7 +174,33 @@ const registerEvidenceTools = ({ outputSchema: exportContract.outputSchema, annotations: exportContract.annotations, }, - () => toCallToolResult(ok(session.exportEvidenceBundle()), exportContract), + async (input) => { + const parsed = z + .object({ + path: z.string().min(1).optional(), + overwrite: z.boolean().default(false), + }) + .parse(input); + const bundle = session.exportEvidenceBundle(); + if (parsed.path === undefined) + return toCallToolResult(ok(bundle), exportContract); + const written = await writeEvidenceBundle( + bundle, + parsed.path, + parsed.overwrite, + filePolicy, + ); + return written.ok + ? toCallToolResult( + ok({ + path: written.value.path, + bytes: written.value.bytes, + records: bundle.records.length, + }), + exportContract, + ) + : toCallToolResult(written, exportContract); + }, ); server.registerTool( importContract.name, @@ -159,47 +210,137 @@ const registerEvidenceTools = ({ outputSchema: importContract.outputSchema, annotations: importContract.annotations, }, - (input) => { - try { - const bundle = z.object({ bundle: z.unknown() }).parse(input).bundle; - const imported = session.importEvidenceBundle(bundle); - return toCallToolResult( - ok({ - imported, - total: session.exportEvidenceBundle().records.length, - }), - importContract, - ); - } catch (cause: unknown) { - return toCallToolResult( - err( - new EvidenceLedgerError("Evidence bundle validation failed", { - cause, + async (input) => { + const path = z.object({ path: z.string().min(1) }).parse(input).path; + const loaded = await readEvidenceBundle(path, filePolicy); + if (!loaded.ok) return toCallToolResult(loaded, importContract); + const imported = session.importEvidenceBundle(loaded.value); + return imported.ok + ? toCallToolResult( + ok({ + imported: imported.value, + total: session.exportEvidenceBundle().records.length, }), - ), - importContract, - ); - } + importContract, + ) + : toCallToolResult(imported, importContract); }, ); }; -/** Register MCP-only target lifecycle operations on a long-lived session. */ -export const registerSessionTools = ( +interface UnknownToolRegistration { + readonly server: McpServer; + readonly session: BinarySessionPort; + readonly contracts: readonly [ + (typeof SESSION_TOOL_CONTRACTS)[14], + (typeof SESSION_TOOL_CONTRACTS)[15], + (typeof SESSION_TOOL_CONTRACTS)[16], + (typeof SESSION_TOOL_CONTRACTS)[17], + ]; +} + +const registerUnknownTools = ({ + server, + session, + contracts: [listContract, recordContract, updateContract, verifyContract], +}: UnknownToolRegistration): void => { + server.registerTool( + listContract.name, + { + description: listContract.description, + inputSchema: listContract.inputSchema, + outputSchema: listContract.outputSchema, + annotations: listContract.annotations, + }, + (input) => { + const filters = z + .object({ + status: z + .enum([ + "open", + "investigating", + "blocked", + "contradicted", + "resolved", + ]) + .optional(), + severity: z.enum(["low", "medium", "high", "critical"]).optional(), + domain: z.string().trim().min(1).max(100).optional(), + }) + .parse(input); + return toCallToolResult( + ok( + session.listUnknowns({ + ...(filters.status === undefined ? {} : { status: filters.status }), + ...(filters.severity === undefined + ? {} + : { severity: filters.severity }), + ...(filters.domain === undefined ? {} : { domain: filters.domain }), + }), + ), + listContract, + ); + }, + ); + server.registerTool( + recordContract.name, + { + description: recordContract.description, + inputSchema: recordContract.inputSchema, + outputSchema: recordContract.outputSchema, + annotations: recordContract.annotations, + }, + (input) => + toCallToolResult( + session.recordUnknown(recordUnknownInputSchema.parse(input)), + recordContract, + ), + ); + server.registerTool( + updateContract.name, + { + description: updateContract.description, + inputSchema: updateContract.inputSchema, + outputSchema: updateContract.outputSchema, + annotations: updateContract.annotations, + }, + (input) => + toCallToolResult( + session.updateUnknown(updateUnknownInputSchema.parse(input)), + updateContract, + ), + ); + server.registerTool( + verifyContract.name, + { + description: verifyContract.description, + inputSchema: verifyContract.inputSchema, + outputSchema: verifyContract.outputSchema, + annotations: verifyContract.annotations, + }, + (input) => { + const unknownId = z + .object({ unknown_id: z.string().regex(/^unk_[a-f0-9]{64}$/u) }) + .parse(input).unknown_id; + return toCallToolResult( + session.verifyUnknownResolution(unknownId), + verifyContract, + ); + }, + ); +}; + +const registerLifecycleTools = ( server: McpServer, session: BinarySessionPort, logger: Logger, - processPolicy: ProcessExecutionPolicy = DENY_PROCESS_POLICY, + contracts: readonly [ + (typeof SESSION_TOOL_CONTRACTS)[0], + (typeof SESSION_TOOL_CONTRACTS)[1], + (typeof SESSION_TOOL_CONTRACTS)[2], + ], ): void => { - const [ - openContract, - closeContract, - statusContract, - exportContract, - importContract, - captureContract, - compareContract, - ] = SESSION_TOOL_CONTRACTS; + const [openContract, closeContract, statusContract] = contracts; server.registerTool( openContract.name, { @@ -258,13 +399,80 @@ export const registerSessionTools = ( () => toCallToolResult({ ok: true, value: session.status() }, statusContract), ); - registerEvidenceTools({ server, session, exportContract, importContract }); +}; + +/** Register MCP-only target lifecycle operations on a long-lived session. */ +export interface SessionToolOptions { + readonly processPolicy?: ProcessExecutionPolicy; + readonly evidenceFilePolicy?: EvidenceFilePolicy; +} + +export const registerSessionTools = ( + server: McpServer, + session: BinarySessionPort, + logger: Logger, + options: SessionToolOptions = {}, +): void => { + const processPolicy = options.processPolicy ?? DENY_PROCESS_POLICY; + const evidenceFilePolicy = + options.evidenceFilePolicy ?? DENY_EVIDENCE_FILE_POLICY; + const [ + openContract, + closeContract, + statusContract, + exportContract, + importContract, + captureContract, + compareContract, + compareArtifactsContract, + compareFunctionsContract, + compareBundlesContract, + changedBehaviorContract, + callPathContract, + staticRuntimeContract, + reconstructionContract, + listUnknownContract, + recordUnknownContract, + updateUnknownContract, + verifyUnknownContract, + ] = SESSION_TOOL_CONTRACTS; + registerLifecycleTools(server, session, logger, [ + openContract, + closeContract, + statusContract, + ]); + registerEvidenceTools({ + server, + session, + exportContract, + importContract, + filePolicy: evidenceFilePolicy, + }); registerProcessTools({ server, session, logger, processPolicy, captureContract, - compareContract, + }); + registerProcessComparisonTool(server, session, compareContract); + registerArtifactComparisonTool(server, session, compareArtifactsContract); + registerFunctionComparisonTool(server, session, compareFunctionsContract); + registerBundleComparisonTool(server, session, compareBundlesContract); + registerInvestigationTools(server, session, [ + changedBehaviorContract, + callPathContract, + staticRuntimeContract, + reconstructionContract, + ]); + registerUnknownTools({ + server, + session, + contracts: [ + listUnknownContract, + recordUnknownContract, + updateUnknownContract, + verifyUnknownContract, + ], }); }; diff --git a/src/server/sessionToolPolicies.ts b/src/server/sessionToolPolicies.ts new file mode 100644 index 000000000..72a9164bd --- /dev/null +++ b/src/server/sessionToolPolicies.ts @@ -0,0 +1,76 @@ +import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; +import type { ProcessExecutionPolicy } from "../domain/processCapture.js"; + +/** Fail-closed process policy used when operator configuration is absent. */ +export const DENY_PROCESS_POLICY: ProcessExecutionPolicy = { + enabled: false, + executableRoots: [], + workingRoots: [], + allowedEnvironment: [], + allowExternalNetwork: false, +}; + +/** Fail-closed evidence filesystem policy used without approved roots. */ +export const DENY_EVIDENCE_FILE_POLICY: EvidenceFilePolicy = { + roots: [], + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024 * 1024, + maxNodes: 1_000_000, +}; + +/** Provider identity for controlled process-capture evidence. */ +export const PROCESS_PROVIDER = { + id: "rea-process", + name: "REA deterministic process harness", + version: "1", +} as const; + +/** Provider identity for deterministic cross-artifact comparisons. */ +export const ARTIFACT_COMPARISON_PROVIDER = { + id: "rea-artifact-comparison", + name: "REA artifact comparison", + version: "1", +} as const; + +/** Provider identity for deterministic cross-function comparisons. */ +export const FUNCTION_COMPARISON_PROVIDER = { + id: "rea-function-comparison", + name: "REA function comparison", + version: "1", +} as const; + +/** Provider identity for deterministic Evidence bundle comparisons. */ +export const BUNDLE_COMPARISON_PROVIDER = { + id: "rea-bundle-comparison", + name: "REA Evidence bundle comparison", + version: "1", +} as const; + +/** Provider identity for comparison-Evidence behavior aggregation. */ +export const CHANGED_BEHAVIOR_PROVIDER = { + id: "rea-changed-behavior", + name: "REA changed behavior investigation", + version: "1", +} as const; + +/** Provider identity for Evidence-backed call-path reconstruction. */ +export const CALL_PATH_PROVIDER = { + id: "rea-call-path", + name: "REA call path reconstruction", + version: "1", +} as const; + +/** Provider identity for explicit static/runtime hypothesis correlation. */ +export const STATIC_RUNTIME_PROVIDER = { + id: "rea-static-runtime-correlation", + name: "REA static/runtime correlation", + version: "1", +} as const; + +/** Provider identity for finite reconstruction specification verification. */ +export const RECONSTRUCTION_PROVIDER = { + id: "rea-reconstruction-verifier", + name: "REA reconstruction verifier", + version: "1", +} as const; diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index 8a36f7071..cd965b433 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -1,6 +1,6 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; -import type { AnalysisError } from "../domain/errors.js"; +import { projectAnalysisError, type AnalysisError } from "../domain/errors.js"; import type { Result } from "../domain/result.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { ToolContract } from "../contracts/toolContracts.js"; @@ -14,17 +14,20 @@ export const toCallToolResult = ( result: Result, contract: ToolContract, ): CallToolResult => - result.ok - ? successResult(result.value, contract) - : { - content: [ - { - type: "text", - text: `${result.error._tag}: ${result.error.message}`, - }, - ], - isError: true, - }; + result.ok ? successResult(result.value, contract) : errorResult(result.error); + +const errorResult = (error: AnalysisError): CallToolResult => { + const projected = projectAnalysisError(error); + return { + content: [ + { + type: "text", + text: `${projected.tag}: ${projected.message}`, + }, + ], + isError: true, + }; +}; const successResult = ( value: JsonValue, @@ -37,7 +40,7 @@ const successResult = ( content: [ { type: "text", - text: "HopperProtocolError: Analysis output does not match the tool contract", + text: "AnalysisOutputError: Analysis output does not match the tool contract", }, ], isError: true, diff --git a/tests/artifactComparison.test.ts b/tests/artifactComparison.test.ts new file mode 100644 index 000000000..fd06838d1 --- /dev/null +++ b/tests/artifactComparison.test.ts @@ -0,0 +1,174 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { inventoryArtifact } from "../src/application/ArtifactInventory.js"; +import { + artifactComparisonResultSchema, + compareArtifacts, +} from "../src/domain/artifactComparison.js"; +import { createEvidence } from "../src/domain/evidence.js"; +import { jsonValueSchema } from "../src/domain/jsonValue.js"; + +const LIMITS = { + maxEntries: 1_000, + maxTotalBytes: 1024 * 1024, + maxEntryBytes: 1024 * 1024, + maxCompressionRatio: 100, + maxDepth: 20, + maxPathBytes: 1024, +} as const; +const PROVIDER = { + id: "rea-artifact-graph", + name: "REA artifact graph", + version: "1", +} as const; + +const observe = async ( + path: string, + page: { + readonly nodeOffset?: number; + readonly nodeLimit?: number; + readonly occurrenceOffset?: number; + readonly edgeOffset?: number; + } = {}, +) => { + const inventory = await inventoryArtifact(path, LIMITS, { + nodeOffset: page.nodeOffset ?? 0, + nodeLimit: page.nodeLimit ?? 500, + occurrenceOffset: page.occurrenceOffset ?? 0, + occurrenceLimit: 500, + edgeOffset: page.edgeOffset ?? 0, + edgeLimit: 500, + }); + return createEvidence( + { + path, + sha256: inventory.manifest.root_sha256, + format: inventory.manifest.root_format, + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse(inventory), + confidence: "observed", + authority: "shipped-artifact", + }, + ); +}; + +describe("artifact comparison", () => { + it("classifies deterministic path changes and cites both inventories", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-artifact-compare-")); + const leftPath = join(parent, "left.app"); + const rightPath = join(parent, "right.app"); + await Promise.all([mkdir(leftPath), mkdir(rightPath)]); + await Promise.all([ + writeFile(join(leftPath, "main.js"), "old();"), + writeFile(join(leftPath, "same.txt"), "same"), + writeFile(join(rightPath, "main.js"), "newer();"), + writeFile(join(rightPath, "same.txt"), "same"), + writeFile(join(rightPath, "added.txt"), "added"), + ]); + const left = await observe(leftPath); + const right = await observe(rightPath); + const first = compareArtifacts(left, right, 0, 1); + const second = compareArtifacts(left, right, 0, 1); + expect(first).toEqual(second); + expect(artifactComparisonResultSchema.parse(first)).toMatchObject({ + status: "changed", + summary: { added: 1, changed: 2, unknown: 0 }, + changes: { limit: 1, total: 3, next_offset: 1 }, + }); + expect(first.changes.items[0]?.evidence_links).toEqual([ + left.evidence_id, + right.evidence_id, + ]); + const all = compareArtifacts(left, right, 0, 500); + expect(all.changes.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + logical_path: "added.txt", + classification: "added", + }), + expect.objectContaining({ + logical_path: "main.js", + classification: "changed", + dimensions: expect.arrayContaining(["content", "size"]), + }), + ]), + ); + }); + + it("reports incomplete inventory as truncated, never unchanged", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-artifact-truncated-")); + await writeFile(join(root, "one.txt"), "one"); + const incomplete = await observe(root, { nodeLimit: 1 }); + const comparison = compareArtifacts(incomplete, incomplete, 0, 100); + expect(comparison).toMatchObject({ + status: "truncated", + summary: { unchanged: 0, unknown: 2 }, + }); + expect(comparison.limitations).toContain( + "Left artifact inventory is incomplete.", + ); + }); + + it("assembles bounded page sets for graphs larger than one page", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-artifact-pages-")); + for (let index = 0; index < 501; index += 1) + await writeFile( + join(root, `file-${String(index).padStart(3, "0")}.txt`), + String(index), + ); + const pages = await Promise.all([ + observe(root), + observe(root, { + nodeOffset: 500, + occurrenceOffset: 500, + edgeOffset: 500, + }), + ]); + expect(compareArtifacts(pages, pages, 0, 100)).toMatchObject({ + status: "unchanged", + summary: { unchanged: 502, unknown: 0 }, + changes: { total: 0 }, + }); + }); + + it("rejects non-inventory and tampered Evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-artifact-invalid-")); + const evidence = await observe(root); + expect(() => + compareArtifacts( + { ...evidence, operation: "binary_overview" }, + evidence, + 0, + 10, + ), + ).toThrow(/identifier/u); + const wrongOperation = createEvidence(undefined, PROVIDER, { + operation: "binary_overview", + parameters: {}, + result: evidence.normalized_result, + }); + expect(() => compareArtifacts(wrongOperation, evidence, 0, 10)).toThrow( + /inventory_artifact/u, + ); + const mismatchedSubject = createEvidence( + { path: root, sha256: "f".repeat(64), format: "directory" }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: evidence.normalized_result, + }, + ); + expect(() => compareArtifacts(mismatchedSubject, evidence, 0, 10)).toThrow( + /root digest/u, + ); + }); +}); diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts new file mode 100644 index 000000000..23d07ba0d --- /dev/null +++ b/tests/artifactMcp.test.ts @@ -0,0 +1,145 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../src/contracts/artifactComparisonExample.js"; +import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js"; +import { parseEvidence } from "../src/domain/evidence.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +describe("artifact graph MCP integration", () => { + it("rejects comparison Evidence that is not owned by the session", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ + name: "artifact-ownership-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_artifacts", + arguments: ARTIFACT_COMPARISON_EXAMPLE, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toEqual([]); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + + it("opens archives without Hopper, compares them, and exports linked evidence", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-artifact-mcp-")); + const archive = join(directory, "fixture.ipa"); + const changedArchive = join(directory, "changed.ipa"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("Payload/Fixture.app/main.js", new TextReader("main();")); + await writeFile(archive, await writer.close()); + const changedWriter = new ZipWriter(new Uint8ArrayWriter()); + await changedWriter.add( + "Payload/Fixture.app/main.js", + new TextReader("changed();"), + ); + await writeFile(changedArchive, await changedWriter.close()); + const session = new BinarySession(new ArtifactProvider()); + const server = createServer(session, session); + const client = new Client({ name: "artifact-mcp-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: archive }, + }); + expect(opened.isError).not.toBe(true); + const inventory = await client.callTool({ + name: "inventory_artifact", + arguments: {}, + }); + expect(inventory.isError).not.toBe(true); + const evidence = parseEvidence(inventory.structuredContent); + expect(evidence).toMatchObject({ + provider: { id: "rea-artifact-graph" }, + subject: { format: "ipa" }, + normalized_result: { + manifest: { root_format: "ipa" }, + }, + }); + const openedChanged = await client.callTool({ + name: "open_binary", + arguments: { path: changedArchive }, + }); + expect(openedChanged.isError).not.toBe(true); + const changedInventory = await client.callTool({ + name: "inventory_artifact", + arguments: {}, + }); + const changedEvidence = parseEvidence(changedInventory.structuredContent); + const compared = await client.callTool({ + name: "compare_artifacts", + arguments: { + left: evidence, + right: changedEvidence, + unknown_registry_approved: true, + }, + }); + expect(compared.isError).not.toBe(true); + const comparisonEvidence = parseEvidence( + z.object({ result: z.unknown() }).parse(compared.structuredContent) + .result, + ); + expect(comparisonEvidence).toMatchObject({ + provider: { id: "rea-artifact-comparison" }, + evidence_links: [evidence.evidence_id, changedEvidence.evidence_id], + normalized_result: { status: "changed" }, + }); + const unknowns = await client.callTool({ + name: "list_unknowns", + arguments: { domain: "artifact-comparison" }, + }); + expect(unknowns.structuredContent).toMatchObject({ + result: [expect.objectContaining({ domain: "artifact-comparison" })], + }); + const exported = await client.callTool({ + name: "export_evidence_bundle", + arguments: {}, + }); + const envelope = z + .object({ result: evidenceBundleSchema }) + .parse(exported.structuredContent); + expect(envelope.result.records).toHaveLength(4); + expect(envelope.result.artifacts).toContainEqual({ + digest: { sha256: evidence.subject?.digest.sha256 }, + format: "ipa", + architecture: null, + }); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); +}); diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts new file mode 100644 index 000000000..f6a2a9417 --- /dev/null +++ b/tests/artifactProvider.test.ts @@ -0,0 +1,372 @@ +import { + access, + mkdtemp, + mkdir, + readFile, + readdir, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createPackage } from "@electron/asar"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { describe, expect, it } from "vitest"; + +import { ArtifactProvider } from "../src/artifacts/ArtifactProvider.js"; +import { MachOSliceArtifactReader } from "../src/artifacts/MachOSliceArtifactReader.js"; +import { runProviderAnalysis } from "../src/application/DirectAnalysis.js"; +import { + ArtifactPathRegistry, + normalizeArtifactPath, +} from "../src/artifacts/ArtifactPaths.js"; +import { + artifactExtractionInputSchema, + artifactInventoryInputSchema, +} from "../src/contracts/artifactToolContracts.js"; +import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import { parseBinaryTarget } from "../src/domain/binaryTarget.js"; +import { parseEvidence } from "../src/domain/evidence.js"; +import { ok } from "../src/domain/result.js"; +import type { NativeCommandRunner } from "../src/native/CommandRunner.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../src/domain/artifactGraph.js"; + +describe("artifact graph provider", () => { + it("inventories app trees deterministically without following symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-artifacts-")); + const app = join(root, "Fixture.app"); + await mkdir(join(app, "Contents", "Resources"), { recursive: true }); + await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); + await mkdir(join(app, "Contents", "Frameworks", "Fixture.framework"), { + recursive: true, + }); + const machO = Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]); + await writeFile(join(app, "Contents", "MacOS", "Fixture"), machO, { + mode: 0o755, + }); + await writeFile( + join(app, "Contents", "Frameworks", "Fixture.framework", "Fixture"), + machO, + ); + await writeFile(join(app, "Contents", "Resources", "main.js"), "main();\n"); + await writeFile(join(app, "Contents", "Resources", "main.js.map"), "{}\n"); + await symlink("/etc/passwd", join(app, "Contents", "Resources", "outside")); + const client = new ArtifactProvider().createClient( + target(app, "directory"), + ); + const input = artifactInventoryInputSchema.parse({ + node_limit: 500, + occurrence_limit: 500, + edge_limit: 500, + }); + const first = await client.execute("inventory_artifact", input); + const second = await client.execute("inventory_artifact", input); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + const left = artifactInventoryResultSchema.parse(first.value.result); + const right = artifactInventoryResultSchema.parse(second.value.result); + expect(left.manifest).toEqual(right.manifest); + expect(left.nodes.items.map(({ kind }) => kind)).toEqual( + expect.arrayContaining([ + "executable", + "framework", + "javascript", + "source-map", + ]), + ); + expect( + left.occurrences.items.find(({ logical_path: path }) => + path.endsWith("outside"), + ), + ).toMatchObject({ artifact_id: null, entry_kind: "symlink" }); + const script = left.occurrences.items.find(({ logical_path: path }) => + path.endsWith("main.js"), + ); + const sourceMap = left.occurrences.items.find(({ logical_path: path }) => + path.endsWith("main.js.map"), + ); + expect(left.edges.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "maps-source", + parent_artifact_id: script?.artifact_id, + child_artifact_id: sourceMap?.artifact_id, + }), + ]), + ); + }); + + it("streams ZIP and official ASAR inventories within shared limits", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-containers-")); + const zipPath = join(root, "fixture.apk"); + const zipWriter = new ZipWriter(new Uint8ArrayWriter()); + await zipWriter.add("assets/index.js", new TextReader("export default 1;")); + await zipWriter.add("lib/arm64-v8a/addon.so", new TextReader("native")); + await writeFile(zipPath, await zipWriter.close()); + const parsed = await parseBinaryTarget(zipPath); + expect(parsed.ok && parsed.value.format).toBe("apk"); + if (!parsed.ok) return; + const zipResult = await inventory(parsed.value); + expect(zipResult.occurrences.total).toBe(3); + expect(zipResult.nodes.items.map(({ kind }) => kind)).toEqual( + expect.arrayContaining(["javascript", "dynamic-library"]), + ); + const cliEvidence = parseEvidence( + await runProviderAnalysis(zipPath, "inventory_artifact", {}), + ); + expect(cliEvidence).toMatchObject({ + operation: "inventory_artifact", + provider: { id: "rea-artifact-graph" }, + subject: { format: "apk" }, + }); + + const source = join(root, "asar-source"); + await mkdir(source); + await writeFile(join(source, "main.js"), "console.log('ok');\n"); + const asarPath = join(root, "app.asar"); + await createPackage(source, asarPath); + const asarResult = await inventory(target(asarPath, "asar")); + expect(asarResult.occurrences.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ logical_path: "main.js" }), + ]), + ); + }); + + it("rejects unsafe, colliding, and over-ratio entries", async () => { + expect(() => normalizeArtifactPath("../escape", limits())).toThrow( + /normalized/u, + ); + const registry = new ArtifactPathRegistry(); + registry.add("A.js", "file"); + expect(() => registry.add("a.js", "file")).toThrow(/collision/u); + + const root = await mkdtemp(join(tmpdir(), "rea-bomb-")); + const zipPath = join(root, "bomb.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("zeros", new TextReader("0".repeat(100_000))); + await writeFile(zipPath, await writer.close()); + const client = new ArtifactProvider().createClient(target(zipPath, "zip")); + const result = await client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ max_compression_ratio: 2 }), + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "limit" }, + }); + }); + + it("extracts only selected occurrences through an exclusively owned output tree", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-extract-")); + const source = join(root, "source"); + await mkdir(join(source, "assets"), { recursive: true }); + await writeFile(join(source, "assets", "selected.js"), "selected();\n"); + await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); + const targetValue = target(source, "directory"); + const graph = await inventory(targetValue); + const selected = graph.occurrences.items.find( + ({ logical_path: path }) => path === "assets/selected.js", + ); + expect(selected).toBeDefined(); + if (selected === undefined) return; + const cancelledOutput = join(root, "cancelled-output"); + const controller = new AbortController(); + controller.abort(); + const cancelled = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: cancelledOutput, + occurrence_ids: [selected.occurrence_id], + }), + { signal: controller.signal }, + ); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "cancelled" }, + }); + await expect(access(cancelledOutput)).rejects.toThrow(); + const output = join(root, "output"); + const result = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: output, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const firstExtraction = artifactExtractionResultSchema.parse( + result.value.result, + ); + expect(firstExtraction).toMatchObject({ + output_root: "$OUTPUT_ROOT", + containment_verified: true, + artifacts: { total: 1 }, + extraction_manifest: { output_root_alias: "$OUTPUT_ROOT" }, + }); + expect(await readFile(join(output, "assets", "selected.js"), "utf8")).toBe( + "selected();\n", + ); + await expect( + access(join(output, "assets", "ignored.js")), + ).rejects.toThrow(); + + const relocatedOutput = join(root, "relocated-output"); + const relocated = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: relocatedOutput, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(relocated.ok).toBe(true); + if (!relocated.ok) return; + expect( + artifactExtractionResultSchema.parse(relocated.value.result) + .extraction_manifest, + ).toEqual(firstExtraction.extraction_manifest); + + const second = await new ArtifactProvider() + .createClient(targetValue) + .execute( + "extract_artifact", + artifactExtractionInputSchema.parse({ + approved: true, + output_root: output, + occurrence_ids: [selected.occurrence_id], + }), + ); + expect(second).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "path" }, + }); + expect(await readdir(root)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/^\.output\.rea-/u)]), + ); + }); + + it("paginates wide graphs without changing manifest identity", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-wide-")); + await Promise.all( + Array.from({ length: 520 }, async (_, index) => + writeFile( + join(root, `file-${String(index).padStart(4, "0")}.txt`), + `${index}`, + ), + ), + ); + const client = new ArtifactProvider().createClient( + target(root, "directory"), + ); + const first = await client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ occurrence_limit: 500 }), + ); + const secondPage = await client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + occurrence_offset: 500, + occurrence_limit: 500, + }), + ); + expect(first.ok && secondPage.ok).toBe(true); + if (!first.ok || !secondPage.ok) return; + const left = artifactInventoryResultSchema.parse(first.value.result); + const right = artifactInventoryResultSchema.parse(secondPage.value.result); + expect(left.manifest).toEqual(right.manifest); + expect(left.occurrences).toMatchObject({ total: 521, next_offset: 500 }); + expect(right.occurrences).toMatchObject({ + offset: 500, + next_offset: null, + }); + }); + + it("uses bounded native lipo metadata for universal slice ranges", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-slices-")); + const binary = join(root, "fat"); + await writeFile(binary, Buffer.from("0123456789abcdef")); + const runner: NativeCommandRunner = { + run: () => + Promise.resolve( + ok({ + tool: "lipo", + executable: "/usr/bin/lipo", + executableSha256: "1".repeat(64), + toolVersion: null, + versionReason: "fixture", + arguments: ["-detailed_info", binary], + stdout: + "architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n", + stderr: "", + stdoutBytes: 1, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + exitCode: 0, + signal: null, + }), + ), + }; + const reader = new MachOSliceArtifactReader(binary, runner); + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + expect(entries).toHaveLength(2); + const secondEntry = entries[1]; + expect(secondEntry).toBeDefined(); + if (secondEntry === undefined) return; + expect(secondEntry).toMatchObject({ + path: "slices/arm64", + byteOffset: 8, + declaredSize: 8, + }); + const chunks: Buffer[] = []; + const stream = await reader.open(secondEntry); + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + expect(Buffer.concat(chunks).toString()).toBe("89abcdef"); + expect(reader.provenance()).toEqual([ + expect.objectContaining({ tool: "lipo", effects: ["read"] }), + ]); + }); +}); + +const inventory = async (targetValue: BinaryTarget) => { + const result = await new ArtifactProvider().createClient(targetValue).execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + node_limit: 500, + occurrence_limit: 500, + edge_limit: 500, + }), + ); + if (!result.ok) throw result.error; + return artifactInventoryResultSchema.parse(result.value.result); +}; + +const target = ( + path: string, + format: BinaryTarget["format"] | "directory", +): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: format === "directory" ? "executable" : "archive", + format: format === "directory" ? "mach-o" : format, + loaderArgs: [], +}); + +const limits = () => ({ maxDepth: 20, maxPathBytes: 4_096 }); diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index 9ae1889bd..7a0b2e214 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -7,7 +7,8 @@ import { BinarySession } from "../src/application/BinarySession.js"; import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; import type { AnalysisProvider } from "../src/application/AnalysisProvider.js"; import { HopperStartError } from "../src/domain/errors.js"; -import { err, ok } from "../src/domain/result.js"; +import { err } from "../src/domain/result.js"; +import { observed as ok } from "./fixtures/analysisExecution.js"; let directory: string | undefined; afterEach(async () => { @@ -24,9 +25,12 @@ describe("binary session", () => { identity: () => ({ id: "fixture", name: "Fixture", version: "1" }), capabilities: () => [ { - operation: "fixture-analysis", - version: 1, + provider: { id: "fixture", name: "Fixture", version: "1" }, + operation: "address_name", + inputContractVersion: 1, + outputContractVersion: 1, available: true, + reason: null, pagination: "none", exhaustive: true, effects: { @@ -35,7 +39,13 @@ describe("binary session", () => { mayShowUi: false, mayAccessNetwork: false, mayWriteFilesystem: false, - requiresPrivileges: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, }, limitations: [], }, @@ -50,13 +60,94 @@ describe("binary session", () => { }; const session = new BinarySession(provider); expect((await session.open(first)).ok).toBe(true); - expect(await session.execute("fixture_operation", {})).toEqual({ + expect(await session.execute("address_name", {})).toEqual({ ok: true, - value: "fixture_operation", + value: { + result: "address_name", + rawResult: "address_name", + provider: { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + }, + limitations: [], + locations: [], + subject: null, + }, }); expect(provider.identity().id).toBe("fixture"); - expect(provider.capabilities()[0]?.operation).toBe("fixture-analysis"); - expect(operations).toEqual(["health", "fixture_operation"]); + expect(provider.capabilities()[0]?.operation).toBe("address_name"); + expect(session.status()).toMatchObject({ + provider: { id: "fixture", name: "Fixture", version: "1" }, + capabilities: [ + { + operation: "address_name", + available: true, + reason: null, + effects: { + mutates_artifact: false, + launches_process: false, + }, + }, + ], + }); + expect(operations).toEqual(["health", "address_name"]); + await session.close(); + }); + + it("returns typed unavailability without dispatching a partial provider", async () => { + const [first] = await targets(); + const operations: string[] = []; + const provider: AnalysisProvider = { + identity: () => ({ id: "partial", name: "Partial", version: "1" }), + capabilities: () => [ + { + provider: { id: "partial", name: "Partial", version: "1" }, + operation: "address_name", + inputContractVersion: 1, + outputContractVersion: 1, + available: false, + reason: "fixture intentionally omits symbol lookup", + pagination: "none", + exhaustive: false, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: ["No symbol lookup implementation."], + }, + ], + createClient: () => ({ + execute: (operation) => { + operations.push(operation); + return Promise.resolve(ok(operation)); + }, + close: () => Promise.resolve(), + }), + }; + const session = new BinarySession(provider); + expect((await session.open(first)).ok).toBe(true); + const result = await session.execute("address_name", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + providerId: "partial", + operation: "address_name", + reason: "fixture intentionally omits symbol lookup", + }); + } + expect(operations).toEqual(["health"]); await session.close(); }); @@ -105,7 +196,7 @@ describe("binary session", () => { it("waits for an active call before closing its client during a switch", async () => { const [first, second] = await targets(); - const active = deferred>>(); + const active = deferred>(); const clients: TestClient[] = []; const session = new BinarySession(() => { const value = new TestClient( @@ -117,7 +208,7 @@ describe("binary session", () => { return value; }); await session.open(first); - const call = session.execute("long", {}); + const call = session.execute("procedure_pseudo_code", {}); const switching = session.open(second); await Promise.resolve(); expect(clients[0]?.closed).toBe(0); @@ -129,7 +220,7 @@ describe("binary session", () => { it("cancels an open queued behind another transition without creating a client", async () => { const [first, second] = await targets(); - const health = deferred>>(); + const health = deferred>(); let created = 0; const session = new BinarySession(() => { created += 1; @@ -143,21 +234,27 @@ describe("binary session", () => { await opening; const result = await queued; expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); expect(created).toBe(1); }); it("cancels a call while it waits for a transition", async () => { const [first] = await targets(); - const health = deferred>>(); + const health = deferred>(); const session = new BinarySession(() => new TestClient(health.promise)); const opening = session.open(first); const controller = new AbortController(); - const call = session.execute("overview", {}, { signal: controller.signal }); + const call = session.execute( + "binary_overview", + {}, + { + signal: controller.signal, + }, + ); controller.abort(); const result = await call; expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); + if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); health.resolve(ok(null)); await opening; }); @@ -211,9 +308,9 @@ const client = (fail = false): AnalysisClient => ({ class TestClient implements AnalysisClient { closed = 0; constructor( - readonly pendingHealth?: Promise>>, + readonly pendingHealth?: Promise>, readonly failHealth = false, - readonly pendingCall?: Promise>>, + readonly pendingCall?: Promise>, ) {} execute(name: string) { if (name === "health") diff --git a/tests/binaryTarget.test.ts b/tests/binaryTarget.test.ts index baaa4a07a..330c41576 100644 --- a/tests/binaryTarget.test.ts +++ b/tests/binaryTarget.test.ts @@ -73,6 +73,37 @@ describe("executable header parsing", () => { }); describe("binary target I/O", () => { + it("classifies ZIP profiles and text artifacts without Hopper", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-artifact-target-")); + const zip = join(directory, "fixture.apk"); + const script = join(directory, "bundle.js"); + await writeFile(zip, Buffer.from([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0])); + await writeFile(script, "export default 1;\n"); + const archive = await parseBinaryTarget(zip); + const javascript = await parseBinaryTarget(script); + expect(archive.ok && archive.value).toMatchObject({ + kind: "archive", + format: "apk", + loaderArgs: [], + }); + expect(javascript.ok && javascript.value).toMatchObject({ + kind: "artifact", + format: "javascript", + loaderArgs: [], + }); + }); + + it("does not trust a ZIP-family extension without ZIP magic", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-fake-archive-")); + const path = join(directory, "fake.zip"); + await writeFile(path, "not a zip"); + const result = await parseBinaryTarget(path); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "BinaryTargetError" }, + }); + }); + it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => { directory = await mkdtemp(join(tmpdir(), "rea-target-")); await writeFile(join(directory, "sample.hop"), "database"); diff --git a/tests/bridgeTruthfulness.test.ts b/tests/bridgeTruthfulness.test.ts index d39499d93..e84318053 100644 --- a/tests/bridgeTruthfulness.test.ts +++ b/tests/bridgeTruthfulness.test.ts @@ -19,7 +19,10 @@ describe("Hopper bridge truthfulness", () => { it("does not fabricate non-writable and non-executable segment permissions", () => { expect(bridgeSource).toContain('"writable": None'); expect(bridgeSource).toContain('"executable": None'); - expect(bridgeSource).toContain('"permissions": _unavailable('); + expect(bridgeSource).toContain('"readable": None'); + expect(bridgeSource).toContain("permission_limitation = _unavailable("); + expect(bridgeSource).toContain('"permissions": permission_limitation'); + expect(bridgeSource).toContain('"provenance": "hopper-public-python-api"'); expect(bridgeSource).not.toContain('"writable": False'); expect(bridgeSource).not.toContain('"executable": False'); }); @@ -51,4 +54,30 @@ describe("Hopper bridge truthfulness", () => { expect(bridgeSource).toContain('_offset(params, "assembly_offset")'); expect(bridgeSource).toContain('params.get("collection_offset", {})'); }); + + it("projects opaque Hopper locals into an exact provenance-bearing shape", () => { + expect(bridgeSource).toContain("def _procedure_locals(procedure):"); + expect(bridgeSource).toContain('"description": str(local)'); + expect(bridgeSource).not.toContain( + "_json_safe(procedure.getLocalVariableList())", + ); + }); + + it("bounds search and makes regex an explicitly constrained opt-in", () => { + expect(bridgeSource).toContain('params.get("mode", "literal")'); + expect(bridgeSource).toContain("pattern.casefold()"); + expect(bridgeSource).toContain("_validate_regex_node(parsed)"); + expect(bridgeSource).toContain( + "Nested regex repetitions are not supported", + ); + expect(bridgeSource).toContain("MAX_SEARCH_PATTERN_LENGTH = 256"); + expect(bridgeSource).toContain("MAX_SEARCH_VALUE_LENGTH = 4096"); + expect(bridgeSource).toContain('"value_truncated"'); + }); + + it("caches sorted search inventories and invalidates them after renames", () => { + expect(bridgeSource).toContain("_search_inventory_cache"); + expect(bridgeSource).toContain("tuple(sorted(values.items()"); + expect(bridgeSource).toContain("_invalidate_search_inventory(document)"); + }); }); diff --git a/tests/bundleComparison.test.ts b/tests/bundleComparison.test.ts new file mode 100644 index 000000000..69e0f5a3e --- /dev/null +++ b/tests/bundleComparison.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { + bundleComparisonResultSchema, + compareBundles, +} from "../src/domain/bundleComparison.js"; +import { createEvidence } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { + createResidualUnknown, + recordUnknownInputSchema, + updateResidualUnknown, + updateUnknownInputSchema, +} from "../src/domain/residualUnknown.js"; + +const PROVIDER = { id: "fixture", name: "Fixture", version: "1" } as const; +const evidence = (label: string) => + createEvidence(undefined, PROVIDER, { + operation: "observe", + parameters: { label }, + result: { label }, + confidence: "derived", + authority: "analyst-inference", + }); + +describe("bundle comparison", () => { + it("returns unchanged only for equal canonical bundles", () => { + const first = evidence("first"); + const second = evidence("second"); + const left = createEvidenceBundle([first, second]); + const right = createEvidenceBundle([second, first]); + const result = compareBundles(left, right, [], 0, 100); + expect(bundleComparisonResultSchema.parse(result)).toMatchObject({ + status: "unchanged", + summary: { + records_unchanged: 2, + records_added: 0, + records_removed: 0, + unresolved: 0, + }, + changes: { total: 0, next_offset: null }, + }); + expect(result.left_bundle_sha256).toBe(result.right_bundle_sha256); + }); + + it("classifies explicit pairs and pages stable membership changes", () => { + const oldRecord = evidence("old"); + const newRecord = evidence("new"); + const removed = evidence("removed"); + const added = evidence("added"); + const left = createEvidenceBundle([oldRecord, removed]); + const right = createEvidenceBundle([newRecord, added]); + const pairs = [ + { + left_evidence_id: oldRecord.evidence_id, + right_evidence_id: newRecord.evidence_id, + }, + ]; + const first = compareBundles(left, right, pairs, 0, 1); + const repeated = compareBundles(left, right, pairs, 0, 1); + expect(first).toEqual(repeated); + expect(first).toMatchObject({ + status: "changed", + summary: { + records_added: 1, + records_removed: 1, + records_changed: 1, + }, + changes: { total: 3, next_offset: 1 }, + }); + expect( + compareBundles(left, right, pairs, 1, 500).changes.items, + ).toHaveLength(2); + expect(first.limitations).toContain( + "One-sided membership proves only bundle inclusion or omission, not behavioral absence.", + ); + }); + + it("rejects missing and non-bijective explicit pairs", () => { + const leftRecord = evidence("left"); + const rightRecord = evidence("right"); + const left = createEvidenceBundle([leftRecord]); + const right = createEvidenceBundle([rightRecord]); + expect(() => + compareBundles( + left, + right, + [ + { + left_evidence_id: evidence("missing").evidence_id, + right_evidence_id: rightRecord.evidence_id, + }, + ], + 0, + 100, + ), + ).toThrow(/missing left evidence/u); + expect(() => + compareBundles( + left, + right, + [ + { + left_evidence_id: leftRecord.evidence_id, + right_evidence_id: rightRecord.evidence_id, + }, + { + left_evidence_id: leftRecord.evidence_id, + right_evidence_id: rightRecord.evidence_id, + }, + ], + 0, + 100, + ), + ).toThrow(/one-to-one/u); + expect(() => + compareBundles( + { ...left, records: [leftRecord, leftRecord] }, + right, + [], + 0, + 100, + ), + ).toThrow(/duplicate record IDs/u); + }); + + it("distinguishes advanced and missing unknown histories from equality", () => { + const mutationOne = evidence("mutation-one"); + const initial = createResidualUnknown( + recordUnknownInputSchema.parse({ + approved: true, + question: "Which branch remains unexplained?", + severity: "high", + domain: "comparison", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }), + mutationOne.evidence_id, + null, + ); + const mutationTwo = evidence("mutation-two"); + const advanced = updateResidualUnknown( + initial, + updateUnknownInputSchema.parse({ + approved: true, + unknown_id: initial.unknown_id, + expected_revision: 1, + status: "investigating", + severity: initial.severity, + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: initial.required_authority, + required_confidence: initial.required_confidence, + required_environment: null, + recommended_probes: [], + relationships: [], + resolution: null, + }), + mutationTwo.evidence_id, + ); + const initialBundle = createEvidenceBundle([mutationOne], [initial]); + const advancedBundle = createEvidenceBundle( + [mutationOne, mutationTwo], + [initial, advanced], + ); + expect( + compareBundles(initialBundle, advancedBundle, [], 0, 100), + ).toMatchObject({ + status: "changed", + summary: { unknowns_advanced: 1, unresolved: 0 }, + changes: { + items: [ + expect.objectContaining({ + entity: "evidence", + classification: "added", + }), + expect.objectContaining({ + entity: "residual_unknown", + classification: "history_advanced", + }), + ], + }, + }); + const absent = createEvidenceBundle([]); + const missing = compareBundles(initialBundle, absent, [], 0, 100); + expect(missing).toMatchObject({ + status: "unknown", + summary: { unknowns_removed: 1, unresolved: 1 }, + }); + expect(missing.changes.items).toContainEqual( + expect.objectContaining({ + classification: "removed", + conclusion_kind: "unresolved_branch", + limitations: [ + "A missing unknown history does not establish resolution.", + ], + }), + ); + }); +}); diff --git a/tests/bundleComparisonMcp.test.ts b/tests/bundleComparisonMcp.test.ts new file mode 100644 index 000000000..b250c029f --- /dev/null +++ b/tests/bundleComparisonMcp.test.ts @@ -0,0 +1,134 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { createEvidence, parseEvidence } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { createResidualUnknown } from "../src/domain/residualUnknown.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +const sourceEvidence = (label: string) => + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "observe", + parameters: { label }, + result: { label }, + confidence: "observed", + authority: "shipped-artifact", + }, + ); + +describe("bundle comparison MCP integration", () => { + it("rejects unknown histories that are not owned by the session", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const leftRecord = sourceEvidence("left-unowned-unknown"); + const rightRecord = sourceEvidence("right-unowned-unknown"); + expect(session.recordEvidence(leftRecord).ok).toBe(true); + expect(session.recordEvidence(rightRecord).ok).toBe(true); + const unknown = createResidualUnknown( + { + approved: true, + question: "Unowned history?", + severity: "high", + domain: "bundle-comparison-test", + supporting_evidence_ids: [leftRecord.evidence_id], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }, + leftRecord.evidence_id, + null, + ); + const server = createServer(session, session); + const client = new Client({ name: "bundle-unknown-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_bundles", + arguments: { + left: createEvidenceBundle([leftRecord], [unknown]), + right: createEvidenceBundle([rightRecord]), + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle()).toMatchObject({ + records: [leftRecord, rightRecord].sort((left, right) => + left.evidence_id.localeCompare(right.evidence_id), + ), + unknowns: [], + }); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + + it("records a digest-anchored comparison linked to every source record", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const leftRecord = sourceEvidence("left"); + const rightRecord = sourceEvidence("right"); + expect(session.recordEvidence(leftRecord).ok).toBe(true); + expect(session.recordEvidence(rightRecord).ok).toBe(true); + const server = createServer(session, session); + const client = new Client({ name: "bundle-comparison-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_bundles", + arguments: { + left: createEvidenceBundle([leftRecord]), + right: createEvidenceBundle([rightRecord]), + record_pairs: [ + { + left_evidence_id: leftRecord.evidence_id, + right_evidence_id: rightRecord.evidence_id, + }, + ], + }, + }); + expect(result.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(result.structuredContent) + .result, + ); + expect(evidence).toMatchObject({ + provider: { id: "rea-bundle-comparison" }, + evidence_links: [ + leftRecord.evidence_id, + rightRecord.evidence_id, + ].sort(), + normalized_result: { status: "changed" }, + }); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); +}); diff --git a/tests/callPath.test.ts b/tests/callPath.test.ts new file mode 100644 index 000000000..7bdc06297 --- /dev/null +++ b/tests/callPath.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { enhancedInputSchemas } from "../src/contracts/enhancedInputs.js"; +import { + buildCallPath, + callPathInputSchema, + callPathResultSchema, +} from "../src/domain/callPath.js"; +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import { functionDossierSchema } from "../src/domain/hopperValues.js"; +import { jsonValueSchema } from "../src/domain/jsonValue.js"; + +const bounded = (items: readonly Item[], complete = true) => ({ + items, + total: complete ? items.length : null, + returned: items.length, + truncated: !complete, + next_offset: null, +}); + +const observe = ( + address: string, + callees: readonly string[], + complete = true, + provider = "rea-workflow", +): Evidence => { + const value = functionDossierSchema.parse({ + procedure: { + address, + name: `fn_${address.slice(2)}`, + signature: null, + locals: [], + }, + pseudocode: { + text: "", + total_chars: 0, + returned_chars: 0, + truncated: false, + next_offset: null, + }, + assembly: bounded([]), + comments: bounded([]), + callers: bounded([]), + callees: bounded( + callees.map((callee) => ({ + address: callee, + name: `fn_${callee.slice(2)}`, + })), + complete, + ), + incoming_references: bounded([]), + outgoing_references: bounded([]), + referenced_strings: bounded([]), + referenced_names: bounded([]), + basic_blocks: bounded([]), + instruction_scan: { scanned: 0, truncated: false }, + }); + return createEvidence( + { path: "/tmp/a", sha256: "a".repeat(64), format: "mach-o" }, + { id: provider, name: provider, version: "1" }, + { + operation: "analyze_function", + parameters: enhancedInputSchemas.analyze_function.parse({ + procedure: address, + include_assembly: false, + }), + result: jsonValueSchema.parse(value), + confidence: "derived", + authority: "shipped-artifact", + }, + ); +}; + +const run = (functions: Evidence[], overrides: Record = {}) => + buildCallPath( + callPathInputSchema.parse({ + functions, + start: { address: "0x1000" }, + goal: { address: "0x4000" }, + ...overrides, + }), + ); + +describe("call path reconstruction", () => { + it("returns deterministic shortest-first cited paths", () => { + const result = run([ + observe("0x1000", ["0x3000", "0x2000"]), + observe("0x2000", ["0x4000"]), + observe("0x3000", ["0x5000"]), + observe("0x5000", ["0x6000"]), + observe("0x6000", ["0x4000"]), + ]); + expect(callPathResultSchema.parse(result)).toEqual(result); + expect(result.status).toBe("found"); + expect(result.shortest_hops).toBe(2); + expect( + result.paths.items.map((path) => + path.nodes.map(({ address }) => address), + ), + ).toEqual([ + ["0x1000", "0x2000", "0x4000"], + ["0x1000", "0x3000", "0x5000", "0x6000", "0x4000"], + ]); + expect( + result.paths.items[0]?.edges.every( + (edge) => edge.evidence_links.length > 0, + ), + ).toBe(true); + }); + + it("accepts a direct cited edge without a goal dossier", () => { + const result = run([observe("0x1000", ["0x4000"])]); + expect(result).toMatchObject({ status: "found", shortest_hops: 1 }); + expect(result.paths.items[0]?.nodes[1]).toMatchObject({ + address: "0x4000", + name: null, + }); + }); + + it("only reports not_found for exhaustive closure", () => { + expect(run([observe("0x1000", []), observe("0x4000", [])]).status).toBe( + "not_found", + ); + const unknown = run([observe("0x1000", ["0x2000"])]); + expect(unknown.status).toBe("unknown"); + expect(unknown.limitations).toContain( + "No analyze_function Evidence covers reachable function 0x2000", + ); + }); + + it("reports incomplete and depth-bounded absence as unknown", () => { + expect(run([observe("0x1000", [], false)]).status).toBe("unknown"); + expect( + run([observe("0x1000", ["0x2000"]), observe("0x2000", [])], { + max_depth: 0, + }).status, + ).toBe("unknown"); + }); + + it("truncates deterministically at max_paths and paginates retained paths", () => { + const result = run( + [ + observe("0x1000", ["0x2000", "0x3000"]), + observe("0x2000", ["0x4000"]), + observe("0x3000", ["0x4000"]), + ], + { max_paths: 1, offset: 0, limit: 1 }, + ); + expect(result.status).toBe("truncated"); + expect(result.paths).toMatchObject({ + total: null, + returned: 1, + truncated: true, + lower_bound: 2, + }); + expect(result.paths.items[0]?.nodes[1]?.address).toBe("0x2000"); + }); + + it("normalizes hex addresses and rejects duplicates and mixed providers", () => { + expect( + run([observe("0x001000", [])], { + start: { address: "0X00001000" }, + }), + ).toMatchObject({ status: "not_found", start: "0x1000" }); + expect(() => + run([observe("0x1000", [])], { start: { address: "1000" } }), + ).toThrow(); + const one = observe("0x1000", []); + expect(() => run([one, one])).toThrow(/Duplicate/u); + expect(() => run([one, observe("0x2000", [], true, "other")])).toThrow( + /providers/u, + ); + }); + + it("requires an observed start function", () => { + expect(() => run([observe("0x2000", [])])).toThrow( + /supplied for start 0x1000/u, + ); + }); +}); diff --git a/tests/changedBehavior.test.ts b/tests/changedBehavior.test.ts new file mode 100644 index 000000000..41f227bed --- /dev/null +++ b/tests/changedBehavior.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; + +import { + changedBehaviorResultSchema, + findChangedBehavior, +} from "../src/domain/changedBehavior.js"; +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import type { JsonValue } from "../src/domain/jsonValue.js"; + +const source = (digit: string): Evidence => + createEvidence( + { + path: `/tmp/source-${digit}`, + sha256: digit.repeat(64), + format: "file", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "fixture", parameters: {}, result: { digit } }, + ); + +const left = source("1"); +const right = source("2"); + +const comparison = ( + operation: "compare_process_captures" | "compare_artifacts", + result: JsonValue, +): Evidence => { + const process = operation === "compare_process_captures"; + return createEvidence( + undefined, + { + id: process ? "rea-process" : "rea-artifact-comparison", + name: process + ? "REA deterministic process harness" + : "REA artifact comparison", + version: "1", + }, + { + predicateType: process + ? "rea.process-comparison/v1" + : "rea.artifact-comparison/v1", + operation, + parameters: {}, + result, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [left.evidence_id, right.evidence_id], + }, + ); +}; + +const processResult = ( + overrides: Partial<{ + status: "unchanged" | "changed" | "unknown" | "truncated"; + terminal: "unchanged" | "changed" | "unknown" | "truncated"; + exit: "unchanged" | "changed" | "unknown" | "truncated"; + }> = {}, +): JsonValue => ({ + status: overrides.status ?? "unchanged", + terminal: overrides.terminal ?? "unchanged", + exit: overrides.exit ?? "unchanged", + filesystem: "unchanged", + protocol: "unchanged", + process: "unchanged", + limitations: [], +}); + +const artifactResult = ( + pagination: { + readonly offset?: number; + readonly next_offset?: number | null; + } = {}, + nestedLinks: string[] = [left.evidence_id, right.evidence_id], +): JsonValue => ({ + status: "changed", + left_manifest_id: `agm_${"3".repeat(64)}`, + right_manifest_id: `agm_${"4".repeat(64)}`, + summary: { + unchanged: 0, + added: 0, + removed: 0, + changed: 1, + unknown: 0, + }, + changes: { + items: [ + { + classification: "changed", + logical_path: "main.js", + dimensions: ["content"], + left_occurrence_id: `occ_${"5".repeat(64)}`, + right_occurrence_id: `occ_${"6".repeat(64)}`, + left_artifact_id: `art_${"7".repeat(64)}`, + right_artifact_id: `art_${"8".repeat(64)}`, + evidence_links: nestedLinks, + }, + ], + offset: pagination.offset ?? 0, + limit: 100, + total: 1, + next_offset: pagination.next_offset ?? null, + }, + limitations: [], +}); + +describe("changed behavior", () => { + it("classifies runtime changes as observed and cites both observations", () => { + const evidence = comparison( + "compare_process_captures", + processResult({ status: "changed", terminal: "changed" }), + ); + const result = findChangedBehavior([evidence], 0, 100); + expect(changedBehaviorResultSchema.parse(result)).toMatchObject({ + behavior_status: "observed_changed", + summary: { observed_changes: 1, static_candidates: 0 }, + findings: { total: 1, next_offset: null }, + }); + expect(result.findings.items[0]).toMatchObject({ + scope: "runtime", + dimension: "terminal", + classification: "observed_change", + evidence_links: expect.arrayContaining([ + evidence.evidence_id, + left.evidence_id, + right.evidence_id, + ]), + }); + }); + + it("keeps static differences as candidates, never runtime observations", () => { + const evidence = comparison("compare_artifacts", artifactResult()); + const result = findChangedBehavior([evidence], 0, 100); + expect(result).toMatchObject({ + behavior_status: "unknown", + summary: { observed_changes: 0, static_candidates: 1 }, + }); + expect(result.findings.items[0]).toMatchObject({ + scope: "static_candidate", + dimension: "artifact:main.js", + classification: "derived_relationship", + }); + expect(result.limitations).toContain( + "Static differences are behavior candidates, not runtime observations.", + ); + }); + + it("lets incomplete runtime evidence dominate observed changes", () => { + const changed = comparison( + "compare_process_captures", + processResult({ status: "changed", terminal: "changed" }), + ); + const unknown = comparison( + "compare_process_captures", + processResult({ status: "unknown", exit: "unknown" }), + ); + const result = findChangedBehavior([changed, unknown], 0, 1); + expect(result).toMatchObject({ + behavior_status: "unknown", + findings: { total: 2, limit: 1, next_offset: 1 }, + }); + expect(findChangedBehavior([changed, unknown], 0, 1)).toEqual(result); + }); + + it("requires complete artifact comparison pagination", () => { + const paged = comparison( + "compare_artifacts", + artifactResult({ next_offset: 1 }), + ); + expect(() => findChangedBehavior([paged], 0, 100)).toThrow( + /complete artifact comparison pagination/u, + ); + }); + + it("rejects bundles, duplicate comparisons, and malformed exact results", () => { + const valid = comparison("compare_process_captures", processResult()); + expect(() => findChangedBehavior([valid, valid], 0, 100)).toThrow( + /duplicate comparison Evidence/u, + ); + const bundle = createEvidence( + undefined, + { id: "rea-bundle-comparison", name: "Bundle", version: "1" }, + { + predicateType: "rea.bundle-comparison/v1", + operation: "compare_bundles", + parameters: {}, + result: {}, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [left.evidence_id, right.evidence_id], + }, + ); + expect(() => findChangedBehavior([bundle], 0, 100)).toThrow( + /requires process, artifact, or function/u, + ); + const malformed = comparison("compare_process_captures", { + status: "unchanged", + }); + expect(() => findChangedBehavior([malformed], 0, 100)).toThrow(); + const contradictory = comparison( + "compare_process_captures", + processResult({ status: "unchanged", terminal: "changed" }), + ); + expect(() => findChangedBehavior([contradictory], 0, 100)).toThrow( + /contradicts its dimensions/u, + ); + const danglingNested = comparison( + "compare_artifacts", + artifactResult({}, [`ev_${"f".repeat(64)}`, right.evidence_id]), + ); + expect(() => findChangedBehavior([danglingNested], 0, 100)).toThrow( + /outside its top-level closure/u, + ); + }); +}); diff --git a/tests/completionManifest.test.ts b/tests/completionManifest.test.ts new file mode 100644 index 000000000..dfeb7521c --- /dev/null +++ b/tests/completionManifest.test.ts @@ -0,0 +1,49 @@ +import { execFile } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +const exec = promisify(execFile); +const manifestPath = "metadata/completion-manifest.json"; +let original: string | undefined; + +afterEach(async () => { + if (original !== undefined) await writeFile(manifestPath, original); + original = undefined; +}); + +describe("generated completion manifest", () => { + it("is deterministic, current, path-independent, and never promotes unknowns", async () => { + await exec(process.execPath, ["scripts/generate-completion-manifest.mjs"]); + const first = await readFile(manifestPath, "utf8"); + await exec(process.execPath, ["scripts/generate-completion-manifest.mjs"]); + expect(await readFile(manifestPath, "utf8")).toBe(first); + await expect( + exec(process.execPath, [ + "scripts/generate-completion-manifest.mjs", + "--check", + ]), + ).resolves.toBeDefined(); + expect(first).not.toContain(process.cwd()); + const manifest = JSON.parse(first) as { + outcomes: { pass: unknown[]; unsupported: unknown[]; unknown: unknown[] }; + }; + expect(manifest.outcomes.pass).toEqual([]); + expect(manifest.outcomes.unsupported).toHaveLength(1); + expect(manifest.outcomes.unknown).toHaveLength(1); + }); + + it("check mode detects manual tampering without rewriting it", async () => { + original = await readFile(manifestPath, "utf8"); + const tampered = `${original.trimEnd()} `; + await writeFile(manifestPath, tampered); + await expect( + exec(process.execPath, [ + "scripts/generate-completion-manifest.mjs", + "--check", + ]), + ).rejects.toThrow(/stale or tampered/u); + expect(await readFile(manifestPath, "utf8")).toBe(tampered); + }); +}); diff --git a/tests/compositeProvider.test.ts b/tests/compositeProvider.test.ts new file mode 100644 index 000000000..bf4d9a3d0 --- /dev/null +++ b/tests/compositeProvider.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { CompositeProvider } from "../src/application/CompositeProvider.js"; +import type { + AnalysisOperation, + AnalysisProvider, + CapabilityDescriptor, + ProviderIdentity, +} from "../src/application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../src/application/AnalysisProvider.js"; +import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import { ok } from "../src/domain/result.js"; + +const target: BinaryTarget = { + path: "/fixture", + kind: "database", + format: "hopper", + sha256: "0".repeat(64), + loaderArgs: [], +}; + +describe("composite analysis provider", () => { + it("routes disjoint operations and does not start children during health", async () => { + const hopperCalls: string[] = []; + const nativeCalls: string[] = []; + const hopper = provider("hopper", "address_name", hopperCalls); + const native = provider("native", "analyze_function", nativeCalls); + const composite = new CompositeProvider([hopper, native]); + const client = composite.createClient(target); + + expect(await client.execute("health", {})).toEqual({ + ok: true, + value: { + result: null, + rawResult: null, + provider: { + id: "composite:hopper+native", + name: "REA composite analysis provider", + version: null, + }, + limitations: [], + locations: [], + subject: null, + }, + }); + expect(hopperCalls).toEqual([]); + expect(nativeCalls).toEqual([]); + expect(await client.execute("address_name", {})).toEqual({ + ok: true, + value: createAnalysisExecution("hopper:address_name", hopper.identity()), + }); + expect(await client.execute("analyze_function", {})).toEqual({ + ok: true, + value: createAnalysisExecution( + "native:analyze_function", + native.identity(), + ), + }); + expect(hopperCalls).toEqual(["address_name"]); + expect(nativeCalls).toEqual(["analyze_function"]); + }); + + it("publishes exact operation provenance and rejects ambiguous routes", () => { + const hopper = provider("hopper", "address_name", []); + const native = provider("native", "analyze_function", []); + const session = new BinarySession(new CompositeProvider([hopper, native])); + expect(session.providerIdentity("address_name").id).toBe("hopper"); + expect(session.providerIdentity("analyze_function").id).toBe("native"); + expect(session.providerIdentity("binary_overview").id).toBe("hopper"); + expect(session.providerIdentity().id).toBe("composite:hopper+native"); + expect( + () => + new CompositeProvider([ + hopper, + provider("duplicate", "address_name", []), + ]), + ).toThrow(/Multiple providers declare operation address_name/u); + }); +}); + +const provider = ( + id: string, + operation: Exclude, + calls: string[], +): AnalysisProvider => { + const identity: ProviderIdentity = { id, name: id, version: "1" }; + return { + identity: () => identity, + capabilities: () => [capability(identity, operation)], + createClient: () => ({ + execute: (called) => { + calls.push(called); + return Promise.resolve( + ok(createAnalysisExecution(`${id}:${called}`, identity)), + ); + }, + close: () => Promise.resolve(), + }), + }; +}; + +const capability = ( + providerIdentity: ProviderIdentity, + operation: Exclude, +): CapabilityDescriptor => ({ + provider: providerIdentity, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact: false, + launchesProcess: true, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { maxResults: null, maxPayloadBytes: 1_000_000, timeoutMs: 5_000 }, + limitations: [], +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index cba55c117..eb62bea69 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -14,6 +14,14 @@ describe("runtime configuration", () => { expect(result.value.hopperTargetKind).toBe("executable"); expect(result.value.hopperLoaderArgs).toEqual([]); expect(result.value.logLevel).toBe("info"); + expect(result.value.referenceSourcePolicy).toEqual({ + roots: [], + secretPatterns: [], + maxBytes: 16 * 1024 * 1024, + maxEntries: 10_000, + maxDepth: 32, + maxPathBytes: 4_096, + }); } }); @@ -38,6 +46,22 @@ describe("runtime configuration", () => { executableRoots: [], workingRoots: [], allowedEnvironment: [], + allowExternalNetwork: false, + }, + evidenceFilePolicy: { + roots: [], + maxBytes: 64 * 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024 * 1024, + maxNodes: 1_000_000, + }, + referenceSourcePolicy: { + roots: [], + secretPatterns: [], + maxBytes: 16 * 1024 * 1024, + maxEntries: 10_000, + maxDepth: 32, + maxPathBytes: 4_096, }, }, }); @@ -60,4 +84,38 @@ describe("runtime configuration", () => { expect(configured.ok && configured.value.logLevel).toBe("debug"); expect(parseConfig({ REA_LOG_LEVEL: "verbose" }).ok).toBe(false); }); + + it("parses reference source policy roots and secret patterns", () => { + const result = parseConfig({ + REA_REFERENCE_ROOTS_JSON: '["/approved", "/srv/reference"]', + REA_REFERENCE_SECRET_PATTERNS_JSON: '["*.env", "*.pem", "secrets/"]', + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.referenceSourcePolicy).toEqual({ + roots: ["/approved", "/srv/reference"], + secretPatterns: ["*.env", "*.pem", "secrets/"], + maxBytes: 16 * 1024 * 1024, + maxEntries: 10_000, + maxDepth: 32, + maxPathBytes: 4_096, + }); + } + }); + + it.each(["not-json", "{}", '["/ok", 1]'])( + "rejects invalid reference source roots: %s", + (encoded) => { + expect(parseConfig({ REA_REFERENCE_ROOTS_JSON: encoded }).ok).toBe(false); + }, + ); + + it.each(["not-json", "{}", '["*.ok", 1]'])( + "rejects invalid reference source secret patterns: %s", + (encoded) => { + expect( + parseConfig({ REA_REFERENCE_SECRET_PATTERNS_JSON: encoded }).ok, + ).toBe(false); + }, + ); }); diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index 536f75fa7..eea4777a7 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -6,6 +6,8 @@ import { OFFICIAL_TOOL_CONTRACTS, SESSION_TOOL_CONTRACTS, } from "../src/contracts/toolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; import { enhancedOutputSchemas, officialOutputSchemas, @@ -14,8 +16,13 @@ import { describe("tool contract surface", () => { it("advertises complete typed schemas and annotations for all analysis tools", () => { - const contracts = [...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS]; - expect(contracts).toHaveLength(43); + const contracts = [ + ...OFFICIAL_TOOL_CONTRACTS, + ...ENHANCED_TOOL_CONTRACTS, + ...NATIVE_TOOL_CONTRACTS, + ...ARTIFACT_TOOL_CONTRACTS, + ]; + expect(contracts).toHaveLength(50); for (const contract of contracts) { const inputSchema = z.toJSONSchema(contract.inputSchema, { target: "draft-07", @@ -29,14 +36,21 @@ describe("tool contract surface", () => { expect(outputSchema.type).toBe("object"); expect(contract.annotations).toMatchObject({ idempotentHint: true, - openWorldHint: false, }); + expect(contract.annotations.openWorldHint).toBe(false); expect(typeof contract.annotations.readOnlyHint).toBe("boolean"); expect(typeof contract.annotations.destructiveHint).toBe("boolean"); + expect(contract.examples).toHaveLength(1); + for (const example of contract.examples) { + expect(example.title.length).toBeGreaterThan(10); + expect(contract.inputSchema.safeParse(example.input).success).toBe( + true, + ); + } } }); - it("keeps exactly seven additive session contracts", () => { + it("keeps exactly eighteen additive session contracts", () => { expect( SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })), ).toEqual([ @@ -47,7 +61,40 @@ describe("tool contract surface", () => { { name: "import_evidence_bundle", kind: "session" }, { name: "capture_process_scenario", kind: "session" }, { name: "compare_process_captures", kind: "session" }, + { name: "compare_artifacts", kind: "session" }, + { name: "compare_functions", kind: "session" }, + { name: "compare_bundles", kind: "session" }, + { name: "find_changed_behavior", kind: "session" }, + { name: "build_call_path", kind: "session" }, + { name: "correlate_static_and_runtime", kind: "session" }, + { name: "verify_reconstruction", kind: "session" }, + { name: "list_unknowns", kind: "session" }, + { name: "record_unknown", kind: "session" }, + { name: "update_unknown", kind: "session" }, + { name: "verify_unknown_resolution", kind: "session" }, ]); + expect( + SESSION_TOOL_CONTRACTS.find( + ({ name }) => name === "capture_process_scenario", + )?.annotations.openWorldHint, + ).toBe(true); + }); + + it("advertises evidence filesystem effects conservatively", () => { + const exported = SESSION_TOOL_CONTRACTS.find( + ({ name }) => name === "export_evidence_bundle", + ); + const imported = SESSION_TOOL_CONTRACTS.find( + ({ name }) => name === "import_evidence_bundle", + ); + expect(exported?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + }); + expect(imported?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + }); }); it("publishes a dedicated output schema and agent guidance for every tool", () => { diff --git a/tests/createServer.test.ts b/tests/createServer.test.ts index 2aacfc3c3..6a9080114 100644 --- a/tests/createServer.test.ts +++ b/tests/createServer.test.ts @@ -1,7 +1,7 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { describe, expect, it } from "vitest"; -import { ok } from "../src/domain/result.js"; +import { observed as ok } from "./fixtures/analysisExecution.js"; import { createServer } from "../src/server/createServer.js"; const analysis = { diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index f0a55fb76..cf7b26103 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import { ok } from "../src/domain/result.js"; +import { observed as ok } from "./fixtures/analysisExecution.js"; import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js"; import { createServer } from "../src/server/createServer.js"; @@ -164,9 +164,9 @@ const jsonResult = (result: CallToolResult): JsonValue => { typeof structured.data === "object" && structured.data !== null && !Array.isArray(structured.data) && - "result" in structured.data + "normalized_result" in structured.data ) { - return structured.data.result ?? null; + return structured.data.normalized_result ?? null; } const text = result.content.find((item) => item.type === "text"); if (text?.type !== "text") @@ -181,7 +181,7 @@ describe("enhanced MCP tools", () => { it("lists the complete 33 plus 10 surface", async () => { const client = await connect(); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(43); + expect(listed.tools).toHaveLength(50); expect( listed.tools .map(({ name }) => name) @@ -495,7 +495,7 @@ describe("enhanced MCP tools", () => { expect(result.isError).toBe(true); const text = result.content.find((item) => item.type === "text"); expect(text?.type === "text" ? text.text : "").toContain( - "AnalysisProtocolError", + "AnalysisOutputError", ); }); @@ -505,7 +505,13 @@ describe("enhanced MCP tools", () => { execute: async (name, arguments_, options) => { const result = await malformedPort.execute(name, arguments_, options); if (!result.ok || name !== "analyze_function") return result; - const dossier = result.value as Record; + const dossier = result.value.result; + if ( + typeof dossier !== "object" || + dossier === null || + Array.isArray(dossier) + ) + return result; return ok({ ...dossier, comments: { @@ -525,7 +531,7 @@ describe("enhanced MCP tools", () => { expect(result.isError).toBe(true); const text = result.content.find((item) => item.type === "text"); expect(text?.type === "text" ? text.text : "").toContain( - "AnalysisProtocolError", + "AnalysisOutputError", ); }); }); diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 000000000..2a087c6b8 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisInputError, + AnalysisOutputError, + AnalysisProtocolError, + BinaryTargetError, + ConfigurationError, + EvidenceFileError, + EvidenceIntegrityError, + EvidenceLimitError, + HopperCancelledError, + HopperProcessError, + HopperProtocolError, + HopperRemoteError, + HopperStartError, + HopperTimeoutError, + NoBinaryOpenError, + ProviderAdapterError, + ProviderSelectionError, + projectAnalysisError, + type AnalysisError, +} from "../src/domain/errors.js"; +import { ProcessCaptureError } from "../src/application/ProcessHarness.js"; + +describe("analysis error projection", () => { + it("uses the correct provider-neutral protocol tag", () => { + expect(new AnalysisProtocolError("invalid")._tag).toBe( + "AnalysisProtocolError", + ); + }); + + it("exhaustively projects stable tags without causes or local paths", () => { + const secretCause = new Error("secret-token"); + const errors: readonly AnalysisError[] = [ + new AnalysisProtocolError("protocol"), + new AnalysisInputError("overview", { cause: secretCause }), + new AnalysisOutputError("overview", "invalid shape", { + cause: secretCause, + }), + new AnalysisCapabilityUnavailableError("fixture", "overview", "absent"), + new AnalysisCancelledError("overview"), + new ProviderSelectionError("overview"), + new ProviderAdapterError("fixture", "overview", { cause: secretCause }), + new ProcessCaptureError("capture refused", { cause: secretCause }), + new EvidenceIntegrityError("integrity", { cause: secretCause }), + new EvidenceLimitError("records", 10), + new EvidenceFileError("read", "outside-root", { cause: secretCause }), + new HopperTimeoutError(100), + new HopperCancelledError(), + new HopperProtocolError("wire", { cause: secretCause }), + new HopperRemoteError(7, "safe"), + new HopperProcessError(1), + new HopperStartError({ cause: secretCause }), + new ConfigurationError("configuration", { cause: secretCause }), + new NoBinaryOpenError(), + new BinaryTargetError("/secret/local/path", "invalid", { + cause: secretCause, + }), + ]; + const projected = errors.map(projectAnalysisError); + expect(new Set(projected.map(({ tag }) => tag)).size).toBe(errors.length); + expect(projected).toHaveLength(20); + expect(JSON.stringify(projected)).not.toContain("secret-token"); + expect(JSON.stringify(projected)).not.toContain("/secret/local/path"); + expect( + projected.find(({ tag }) => tag === "HopperTimeoutError"), + ).toMatchObject({ details: { timeoutMs: 100 } }); + expect( + projected.find(({ tag }) => tag === "ProviderAdapterError"), + ).toMatchObject({ + details: { providerId: "fixture", operation: "overview" }, + }); + }); +}); diff --git a/tests/evidence.test.ts b/tests/evidence.test.ts index 4952023c9..02f30be11 100644 --- a/tests/evidence.test.ts +++ b/tests/evidence.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { fc, it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; import { EvidenceLedger } from "../src/application/EvidenceLedger.js"; import type { BinaryTarget } from "../src/domain/binaryTarget.js"; @@ -7,6 +8,8 @@ import { evidenceSchema, parseEvidence, } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { recordUnknownInputSchema } from "../src/domain/residualUnknown.js"; const TARGET: BinaryTarget = { path: "/tmp/fixture", @@ -20,12 +23,55 @@ const TARGET: BinaryTarget = { const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" }; describe("analysis evidence", () => { + it.prop([ + fc.dictionary( + fc.string({ minLength: 1, maxLength: 12 }), + fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null)), + ), + ])("canonicalizes parameter key order", (parameters) => { + const reversed = Object.fromEntries(Object.entries(parameters).reverse()); + const first = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters, + result: true, + }); + const second = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: reversed, + result: true, + }); + expect(second.evidence_id).toBe(first.evidence_id); + }); + + it.prop([fc.string({ minLength: 1 }), fc.string({ minLength: 1 })])( + "keeps identity path-independent and provider-sensitive", + (firstPath, secondPath) => { + const first = createEvidence( + { ...TARGET, path: `/first/${firstPath}/artifact` }, + PROVIDER, + { operation: "health", parameters: {}, result: true }, + ); + const moved = createEvidence( + { ...TARGET, path: `/second/${secondPath}/artifact` }, + PROVIDER, + { operation: "health", parameters: {}, result: true }, + ); + const changedProvider = createEvidence( + { ...TARGET, path: `/second/${secondPath}/artifact` }, + { ...PROVIDER, id: `${PROVIDER.id}-other` }, + { operation: "health", parameters: {}, result: true }, + ); + expect(moved.evidence_id).toBe(first.evidence_id); + expect(changedProvider.evidence_id).not.toBe(first.evidence_id); + }, + ); + it("creates provider-neutral, deterministic Evidence v2", () => { const observation = { operation: "procedure_info", parameters: { document: null, procedure: "0x1000" }, result: { name: "main" }, - redactedRawPayload: { token: "" }, + rawResult: { token: "" }, } as const; const evidence = createEvidence(TARGET, PROVIDER, observation); expect(evidenceSchema.parse(evidence)).toEqual(evidence); @@ -36,27 +82,32 @@ describe("analysis evidence", () => { subject: { digest: { sha256: "a".repeat(64) } }, confidence: "observed", authority: "shipped-artifact", + raw_result: { token: "" }, + normalized_result: { name: "main" }, }); expect(evidence.evidence_id).toMatch(/^ev_[a-f0-9]{64}$/u); - expect(evidence.raw_payload_sha256).toMatch(/^[a-f0-9]{64}$/u); expect(createEvidence(TARGET, PROVIDER, observation)).toEqual(evidence); }); - it("excludes local paths and redacted raw payloads from semantic identity", () => { + it("excludes local paths but includes redacted raw results in identity", () => { const observation = { operation: "health", parameters: {}, result: true, - redactedRawPayload: { pid: 100 }, + rawResult: { pid: 100 }, } as const; const first = createEvidence(TARGET, PROVIDER, observation); const moved = createEvidence( { ...TARGET, path: "/other/renamed-fixture" }, PROVIDER, - { ...observation, redactedRawPayload: { pid: 200 } }, + observation, ); expect(moved.evidence_id).toBe(first.evidence_id); - expect(moved.raw_payload_sha256).not.toBe(first.raw_payload_sha256); + const changedRaw = createEvidence(TARGET, PROVIDER, { + ...observation, + rawResult: { pid: 200 }, + }); + expect(changedRaw.evidence_id).not.toBe(first.evidence_id); }); it("rejects semantic tampering", () => { @@ -65,8 +116,14 @@ describe("analysis evidence", () => { parameters: {}, result: true, }); - expect(() => parseEvidence({ ...evidence, result: false })).toThrow( - "semantic identifier", + expect(() => + parseEvidence({ ...evidence, normalized_result: false }), + ).toThrow("semantic identifier"); + }); + + it("rejects Evidence v1 with actionable upgrade guidance", () => { + expect(() => parseEvidence({ schema_version: 1 })).toThrow( + "Evidence v1 is not accepted. Produce Evidence v2.", ); }); @@ -76,37 +133,149 @@ describe("analysis evidence", () => { parameters: {}, result: true, }); - const ledger = new EvidenceLedger({ maxRecords: 1 }); - expect(ledger.record(evidence)).toBe("added"); - expect(ledger.record(evidence)).toBe("duplicate"); + const ledger = new EvidenceLedger({ maxRecords: 1, maxBytes: 1_000_000 }); + expect(ledger.record(evidence)).toEqual({ ok: true, value: "added" }); + expect(ledger.record(evidence)).toEqual({ ok: true, value: "duplicate" }); const relocated = createEvidence( { ...TARGET, path: "/relocated/different-name" }, PROVIDER, { operation: "health", parameters: {}, result: true }, ); expect(relocated.evidence_id).toBe(evidence.evidence_id); - expect(ledger.record(relocated)).toBe("duplicate"); - expect(ledger.import({ bundle_version: 1, records: [evidence] })).toBe(0); + expect(ledger.record(relocated)).toEqual({ + ok: true, + value: "duplicate", + }); + expect(ledger.import(createEvidenceBundle([evidence]))).toEqual({ + ok: true, + value: 0, + }); expect(ledger.export().records).toEqual([evidence]); ledger.clear(); expect(ledger.export().records).toEqual([]); }); - it("rejects duplicate semantic IDs with conflicting raw payload hashes", () => { - const ledger = new EvidenceLedger({ maxRecords: 2 }); + it("returns typed record and byte limit failures without eviction", () => { const first = createEvidence(TARGET, PROVIDER, { operation: "health", parameters: {}, result: true, - redactedRawPayload: { pid: 1 }, }); const second = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: { changed: true }, + result: true, + }); + const recordBound = new EvidenceLedger({ + maxRecords: 1, + maxBytes: 1_000_000, + }); + expect(recordBound.record(first).ok).toBe(true); + expect(recordBound.record(second)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceLimitError", limit: "records", maximum: 1 }, + }); + expect(recordBound.export().records).toEqual([first]); + + const byteBound = new EvidenceLedger({ maxRecords: 10, maxBytes: 1 }); + expect(byteBound.record(first)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceLimitError", limit: "bytes", maximum: 1 }, + }); + expect(byteBound.export().records).toEqual([]); + }); + + it("derives byte-stable manifests independent of record insertion order", () => { + const artifactEvidence = createEvidence(TARGET, PROVIDER, { operation: "health", parameters: {}, result: true, - redactedRawPayload: { pid: 2 }, }); - ledger.record(first); - expect(() => ledger.record(second)).toThrow("Conflicting evidence"); + const captureEvidence = createEvidence( + undefined, + { + id: "process", + name: "Process capture", + version: "1", + }, + { + predicateType: "rea.process-capture/v1", + operation: "capture_process_scenario", + parameters: {}, + result: { exit: 0 }, + authority: "controlled-replay", + environment: { + id: "linux-x64", + platform: "linux", + architecture: "x64", + isolation: "process", + }, + }, + ); + const forward = createEvidenceBundle([artifactEvidence, captureEvidence]); + const reverse = createEvidenceBundle([captureEvidence, artifactEvidence]); + expect(JSON.stringify(reverse)).toBe(JSON.stringify(forward)); + expect(forward).toMatchObject({ + artifacts: [{ digest: { sha256: TARGET.sha256 }, format: "mach-o" }], + providers: [{ id: "fixture" }, { id: "process" }], + environments: [{ id: "linux-x64" }], + scenarios: [{ evidence_id: captureEvidence.evidence_id }], + captures: [{ evidence_id: captureEvidence.evidence_id }], + }); + }); + + it("rejects conflicting duplicate IDs without mutating the ledger", () => { + const ledger = new EvidenceLedger({ maxRecords: 2, maxBytes: 1_000_000 }); + const first = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + rawResult: { pid: 1 }, + }); + expect(ledger.record(first).ok).toBe(true); + const bundle = createEvidenceBundle([first]); + expect( + ledger.import({ + ...bundle, + records: [{ ...first, normalized_result: false }], + }), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + expect(ledger.export().records).toEqual([first]); + }); + + it("atomically rejects Evidence plus unknown when the batch exceeds limits", () => { + const ledger = new EvidenceLedger({ maxRecords: 2, maxBytes: 1_000_000 }); + const output = createEvidence(undefined, PROVIDER, { + operation: "derived", + parameters: {}, + result: { status: "unknown" }, + }); + const mutation = createEvidence(undefined, PROVIDER, { + predicateType: "rea.residual-unknown-mutation/v1", + operation: "record_unknown", + parameters: {}, + result: { action: "record" }, + }); + const unknown = recordUnknownInputSchema.parse({ + approved: true, + question: "What remains unresolved?", + severity: "high", + domain: "atomic-test", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }); + expect(ledger.recordWithUnknown(output, unknown, mutation)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceLimitError" }, + }); + expect(ledger.export()).toMatchObject({ records: [], unknowns: [] }); }); }); diff --git a/tests/evidenceBundleFiles.test.ts b/tests/evidenceBundleFiles.test.ts new file mode 100644 index 000000000..28546ac24 --- /dev/null +++ b/tests/evidenceBundleFiles.test.ts @@ -0,0 +1,153 @@ +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + readEvidenceBundle, + writeEvidenceBundle, +} from "../src/application/EvidenceBundleFiles.js"; +import { createEvidence } from "../src/domain/evidence.js"; +import { + createEvidenceBundle, + serializeEvidenceBundle, + type EvidenceFilePolicy, +} from "../src/domain/evidenceBundle.js"; + +let directory: string | undefined; + +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const bundle = () => + createEvidenceBundle([ + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "health", parameters: {}, result: true }, + ), + ]); + +const policy = (root: string): EvidenceFilePolicy => ({ + roots: [root], + maxBytes: 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024, + maxNodes: 10_000, +}); + +describe("evidence bundle filesystem adapter", () => { + it("round trips canonical bytes and requires explicit overwrite", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-evidence-")); + const path = join(directory, "bundle.json"); + const evidenceBundle = bundle(); + const first = await writeEvidenceBundle( + evidenceBundle, + path, + false, + policy(directory), + ); + expect(first).toMatchObject({ ok: true, value: { path } }); + expect(await readFile(path, "utf8")).toBe( + serializeEvidenceBundle(evidenceBundle), + ); + expect(await readEvidenceBundle(path, policy(directory))).toEqual({ + ok: true, + value: evidenceBundle, + }); + expect( + await writeEvidenceBundle(evidenceBundle, path, false, policy(directory)), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "exists" }, + }); + expect( + await writeEvidenceBundle(evidenceBundle, path, true, policy(directory)), + ).toMatchObject({ ok: true }); + }); + + it("rejects traversal and symlink escape from approved roots", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-evidence-")); + const root = join(directory, "approved"); + const outside = join(directory, "outside"); + await mkdir(root); + await mkdir(outside); + const outsidePath = join(outside, "bundle.json"); + await writeFile(outsidePath, serializeEvidenceBundle(bundle())); + const link = join(root, "escaped.json"); + await symlink(outsidePath, link); + expect(await readEvidenceBundle(link, policy(root))).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "outside-root" }, + }); + expect( + await writeEvidenceBundle(bundle(), outsidePath, true, policy(root)), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "outside-root" }, + }); + }); + + it("rejects malformed, tampered, oversized, and deeply nested input", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-evidence-")); + const malformed = join(directory, "malformed.json"); + await writeFile(malformed, "{"); + expect( + await readEvidenceBundle(malformed, policy(directory)), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "invalid-json" }, + }); + + const tampered = bundle(); + const tamperedPath = join(directory, "tampered.json"); + await writeFile( + tamperedPath, + JSON.stringify({ + ...tampered, + records: [{ ...tampered.records[0], normalized_result: "changed" }], + }), + ); + expect( + await readEvidenceBundle(tamperedPath, policy(directory)), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + + const oversized = join(directory, "oversized.json"); + await writeFile(oversized, "x".repeat(100)); + expect( + await readEvidenceBundle(oversized, { + ...policy(directory), + maxBytes: 10, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "too-large" }, + }); + + const deep = join(directory, "deep.json"); + await writeFile(deep, JSON.stringify({ one: { two: { three: null } } })); + expect( + await readEvidenceBundle(deep, { + ...policy(directory), + maxDepth: 1, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "too-large" }, + }); + }); +}); diff --git a/tests/fixtures/analysisExecution.ts b/tests/fixtures/analysisExecution.ts new file mode 100644 index 000000000..94fe941fa --- /dev/null +++ b/tests/fixtures/analysisExecution.ts @@ -0,0 +1,12 @@ +import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { ok } from "../../src/domain/result.js"; + +const FIXTURE_PROVIDER = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +} as const; + +/** Wrap fixture output in the same atomic provider envelope as production. */ +export const observed = (value: unknown) => + ok(createAnalysisExecution(value, FIXTURE_PROVIDER)); diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs index 84f63216a..cea23a29c 100644 --- a/tests/fixtures/fakeHopper.mjs +++ b/tests/fixtures/fakeHopper.mjs @@ -1,6 +1,6 @@ import { createServer } from "node:net"; -const [socketPath, token] = process.argv.slice(2); +const [socketPath, token, runId] = process.argv.slice(2); const server = createServer((socket) => { socket.on("error", () => undefined); socket.setEncoding("utf8"); @@ -24,7 +24,11 @@ const server = createServer((socket) => { send( { id: request.id, - result: { name: "REA Hopper bridge", version: "1.0.0" }, + result: { + name: "REA Hopper bridge", + version: "1.0.0", + run_id: runId, + }, }, true, ); diff --git a/tests/fixtures/fakeLauncher.mjs b/tests/fixtures/fakeLauncher.mjs index 0e182ba7d..c9db990c7 100644 --- a/tests/fixtures/fakeLauncher.mjs +++ b/tests/fixtures/fakeLauncher.mjs @@ -16,11 +16,12 @@ if ( process.exit(2); } const source = readFileSync(process.argv[bootstrapIndex], "utf8"); -const [socketLine, tokenLine] = source.split("\n"); +const [socketLine, tokenLine, runIdLine] = source.split("\n"); const socketPath = JSON.parse(socketLine.slice(socketLine.indexOf("=") + 1)); const token = JSON.parse(tokenLine.slice(tokenLine.indexOf("=") + 1)); +const runId = JSON.parse(runIdLine.slice(runIdLine.indexOf("=") + 1)); const bridge = fileURLToPath(new URL("./fakeHopper.mjs", import.meta.url)); -const child = spawn(process.execPath, [bridge, socketPath, token], { +const child = spawn(process.execPath, [bridge, socketPath, token, runId], { stdio: "ignore", }); child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/tests/fixtures/native-macos/codesign.txt b/tests/fixtures/native-macos/codesign.txt new file mode 100644 index 000000000..9c6b35d4f --- /dev/null +++ b/tests/fixtures/native-macos/codesign.txt @@ -0,0 +1,10 @@ +Executable=/Applications/Fixture.app/Contents/MacOS/Fixture +Identifier=com.example.fixture +Format=app bundle with Mach-O thin (arm64) +CodeDirectory v=20500 size=500 flags=0x10000(runtime) hashes=10+7 location=embedded +Hash choices=sha256 +CDHash=abcdef0123456789 +Signature size=9000 +Authority=Developer ID Application: Example +TeamIdentifier=TEAM12345 +Timestamp=Jul 12, 2026 at 12:00:00 diff --git a/tests/fixtures/native-macos/demangle.txt b/tests/fixtures/native-macos/demangle.txt new file mode 100644 index 000000000..6a4b7a011 --- /dev/null +++ b/tests/fixtures/native-macos/demangle.txt @@ -0,0 +1,2 @@ +Test.foo() -> () +plain_symbol diff --git a/tests/fixtures/native-macos/dyld-exports.txt b/tests/fixtures/native-macos/dyld-exports.txt new file mode 100644 index 000000000..84611393f --- /dev/null +++ b/tests/fixtures/native-macos/dyld-exports.txt @@ -0,0 +1,3 @@ +exports: +0x100001000 _main +0x100001080 _$s4Test3fooyyF diff --git a/tests/fixtures/native-macos/dyld-imports.txt b/tests/fixtures/native-macos/dyld-imports.txt new file mode 100644 index 000000000..4ef562401 --- /dev/null +++ b/tests/fixtures/native-macos/dyld-imports.txt @@ -0,0 +1,3 @@ +imports: +0x100001100 libSystem.B.dylib _malloc +0x100001108 libobjc.A.dylib _objc_msgSend weak diff --git a/tests/fixtures/native-macos/entitlements.xml b/tests/fixtures/native-macos/entitlements.xml new file mode 100644 index 000000000..7366ffe71 --- /dev/null +++ b/tests/fixtures/native-macos/entitlements.xml @@ -0,0 +1,3 @@ + + +com.apple.security.app-sandbox diff --git a/tests/fixtures/native-macos/lipo-fat.txt b/tests/fixtures/native-macos/lipo-fat.txt new file mode 100644 index 000000000..b8a8d411b --- /dev/null +++ b/tests/fixtures/native-macos/lipo-fat.txt @@ -0,0 +1,15 @@ +Fat header in: fixture +fat_magic 0xcafebabe +nfat_arch 2 +architecture x86_64 + cputype 16777223 + cpusubtype 3 + offset 16384 + size 4096 + align 2^14 (16384) +architecture arm64 + cputype 16777228 + cpusubtype 0 + offset 32768 + size 8192 + align 2^14 (16384) diff --git a/tests/fixtures/native-macos/lipo-thin.txt b/tests/fixtures/native-macos/lipo-thin.txt new file mode 100644 index 000000000..fc18405c8 --- /dev/null +++ b/tests/fixtures/native-macos/lipo-thin.txt @@ -0,0 +1 @@ +Non-fat file: fixture is architecture: arm64 diff --git a/tests/fixtures/native-macos/otool-load.txt b/tests/fixtures/native-macos/otool-load.txt new file mode 100644 index 000000000..2889de738 --- /dev/null +++ b/tests/fixtures/native-macos/otool-load.txt @@ -0,0 +1,49 @@ +fixture: +Mach header + magic cputype cpusubtype caps filetype ncmds sizeofcmds flags + 0xfeedfacf 16777228 0 0x00 EXECUTE 4 512 NOUNDEFS DYLDLINK +Load command 0 + cmd LC_SEGMENT_64 + cmdsize 152 + segname __TEXT + vmaddr 0x100000000 + vmsize 0x2000 + fileoff 0 + filesize 8192 + maxprot 0x5 + initprot 0x5 + nsects 1 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x100001000 + size 0x100 + offset 4096 + align 4 + flags 0x80000400 +Load command 1 + cmd LC_UUID + cmdsize 24 + uuid 01234567-89AB-CDEF-0123-456789ABCDEF +Load command 2 + cmd LC_MAIN + cmdsize 24 + entryoff 4096 +stacksize 0 +Load command 3 + cmd LC_BUILD_VERSION + cmdsize 32 + platform MACOS + minos 13.0 + sdk 14.2 + ntools 1 + tool LD + version 1000.0 +Load command 4 + cmd LC_LOAD_DYLIB + cmdsize 64 + name /usr/lib/libSystem.B.dylib (offset 24) + time stamp 2 Thu Jan 1 00:00:02 1970 + current version 1319.0.0 +compatibility version 1.0.0 diff --git a/tests/fixtures/native-macos/plist.json b/tests/fixtures/native-macos/plist.json new file mode 100644 index 000000000..ff906502c --- /dev/null +++ b/tests/fixtures/native-macos/plist.json @@ -0,0 +1,7 @@ +{ + "CFBundleIdentifier": "com.example.fixture", + "CFBundleExecutable": "Fixture", + "CFBundleName": "Fixture", + "CFBundleVersion": "42", + "CFBundleShortVersionString": "1.2.3" +} diff --git a/tests/fixtures/processFidelity.mjs b/tests/fixtures/processFidelity.mjs new file mode 100644 index 000000000..0d4864845 --- /dev/null +++ b/tests/fixtures/processFidelity.mjs @@ -0,0 +1,46 @@ +import { spawn } from "node:child_process"; + +const mode = process.argv[2]; + +if (mode === "interactive") { + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdout.write("prompt> "); + process.on("SIGWINCH", () => { + process.stdout.write( + `resize:${String(process.stdout.columns)}x${String(process.stdout.rows)}\n`, + ); + }); + process.on("SIGINT", () => { + process.stdout.write("signal:SIGINT\n"); + process.exit(0); + }); + process.stdin.on("data", (value) => { + process.stdout.write(`input:${value.toString()} unicode:雪\n`); + }); +} else if (mode === "partial") { + process.stdout.write("partial-"); + setTimeout(() => { + process.stdout.write("frame\n"); + process.exit(0); + }, 25); +} else if (mode === "tree-child") { + spawn(process.execPath, [process.argv[1], "tree-grandchild"], { + stdio: "ignore", + }); + setInterval(() => undefined, 1_000); +} else if (mode === "tree-grandchild") { + setInterval(() => undefined, 1_000); +} else if (mode === "tree") { + spawn(process.execPath, [process.argv[1], "tree-child"], { stdio: "ignore" }); + process.stdout.write("tree-ready\n"); + setInterval(() => undefined, 1_000); +} else if (mode === "crash") { + process.stderr.write("intentional-crash\n"); + process.exit(23); +} else if (mode === "hang") { + setInterval(() => undefined, 1_000); +} else { + process.stderr.write("unknown fixture mode\n"); + process.exit(2); +} diff --git a/tests/functionComparison.test.ts b/tests/functionComparison.test.ts new file mode 100644 index 000000000..0864bbae5 --- /dev/null +++ b/tests/functionComparison.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js"; +import { enhancedInputSchemas } from "../src/contracts/enhancedInputs.js"; +import { + compareFunctions, + functionComparisonResultSchema, +} from "../src/domain/functionComparison.js"; +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import { functionDossierSchema } from "../src/domain/hopperValues.js"; +import { jsonValueSchema } from "../src/domain/jsonValue.js"; + +const bounded = (items: readonly Item[]) => ({ + items, + total: items.length, + returned: items.length, + truncated: false, + next_offset: null, +}); + +const dossier = ( + text: string, + base: "0x1000" | "0x2000", + successors: readonly string[] = [], +) => + functionDossierSchema.parse({ + procedure: { + address: base, + name: "main", + signature: "int main(void)", + locals: [], + }, + pseudocode: { + text, + total_chars: [...text].length, + returned_chars: [...text].length, + truncated: false, + next_offset: null, + }, + assembly: bounded(["ret"]), + comments: bounded([]), + callers: bounded([]), + callees: bounded([]), + incoming_references: bounded([]), + outgoing_references: bounded([]), + referenced_strings: bounded([]), + referenced_names: bounded([]), + basic_blocks: bounded([ + { + start: base, + end: base === "0x1000" ? "0x1001" : "0x2001", + successors, + }, + ]), + instruction_scan: { scanned: 1, truncated: false }, + }); + +const observe = ( + digit: string, + value: ReturnType, + providerId = "rea-workflow", +): Evidence => + createEvidence( + { + path: `/tmp/function-${digit}`, + sha256: digit.repeat(64), + format: "mach-o", + }, + { id: providerId, name: providerId, version: "1" }, + { + operation: "analyze_function", + parameters: enhancedInputSchemas.analyze_function.parse({ + procedure: "main", + include_assembly: true, + }), + result: jsonValueSchema.parse(value), + confidence: "derived", + authority: "shipped-artifact", + }, + ); + +describe("function comparison", () => { + it("preserves observed text changes while unavailable facets stay unknown", () => { + const result = compareFunctions( + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + 0, + 100, + ); + expect(functionComparisonResultSchema.parse(result)).toMatchObject({ + status: "unknown", + function_match: { status: "matched", method: "symbol" }, + }); + expect(result.dimensions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ dimension: "pseudocode", status: "changed" }), + expect.objectContaining({ dimension: "assembly", status: "unknown" }), + expect.objectContaining({ + dimension: "references", + status: "unchanged", + }), + ]), + ); + for (const dimension of result.dimensions) + expect(dimension.evidence_links).toEqual([ + FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, + FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + ]); + }); + + it("normalizes CFG relocation but preserves changed edges and constants", () => { + const left = observe("2", dossier("return 0x10;", "0x1000")); + const relocated = observe("3", dossier("return 0x10;", "0x2000")); + const same = compareFunctions(left, relocated, 0, 100); + expect( + same.dimensions.find(({ dimension }) => dimension === "cfg"), + ).toMatchObject({ + status: "unchanged", + }); + expect( + same.dimensions.find(({ dimension }) => dimension === "pseudocode"), + ).toMatchObject({ status: "unchanged" }); + + const changed = observe("4", dossier("return 0x20;", "0x2000", ["0x2000"])); + const comparison = compareFunctions(left, changed, 0, 100); + expect( + comparison.dimensions.find(({ dimension }) => dimension === "cfg"), + ).toMatchObject({ status: "changed" }); + expect( + comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"), + ).toMatchObject({ status: "changed" }); + }); + + it("treats provider-specific text as unknown and validates Evidence", () => { + const left = observe("5", dossier("return 0;", "0x1000"), "provider-a"); + const right = observe("6", dossier("return 1;", "0x2000"), "provider-b"); + const comparison = compareFunctions(left, right, 0, 1); + expect( + comparison.dimensions.find(({ dimension }) => dimension === "pseudocode"), + ).toMatchObject({ status: "unknown" }); + expect(comparison.changes).toMatchObject({ limit: 1, next_offset: 1 }); + expect(() => + compareFunctions({ ...left, operation: "binary_overview" }, right, 0, 10), + ).toThrow(/identifier/u); + }); + + it("reports fully observed identical dossiers as unchanged", () => { + const left = observe("7", dossier("return 0;\n", "0x1000")); + const right = observe("8", dossier("return 0;\n", "0x2000")); + const comparison = compareFunctions(left, right, 0, 100); + expect(comparison.status).toBe("unchanged"); + expect( + comparison.dimensions.every(({ status }) => status === "unchanged"), + ).toBe(true); + }); + + it("does not promote address-derived names through equal signatures", () => { + const autoDossier = (base: "0x1000" | "0x2000") => + functionDossierSchema.parse({ + ...dossier("return 0;", base), + procedure: { + address: base, + name: "sub_1000", + signature: "int helper(void)", + locals: [], + }, + }); + const comparison = compareFunctions( + observe("9", autoDossier("0x1000")), + observe("a", autoDossier("0x2000")), + 0, + 100, + ); + expect(comparison.function_match.status).toBe("ambiguous"); + expect( + comparison.dimensions.find(({ dimension }) => dimension === "identity"), + ).toMatchObject({ status: "unknown" }); + expect(comparison.status).toBe("unknown"); + }); + + it("rejects invalid CFG normalization and preserves newline differences", () => { + const leftDossier = dossier("line\r\n", "0x1000"); + const invalidCfg = functionDossierSchema.parse({ + ...dossier("line\n", "0x2000"), + basic_blocks: bounded([ + { start: "0x2000", end: "0x2001", successors: [] }, + { start: "0x2000", end: "0x2002", successors: [] }, + ]), + }); + const comparison = compareFunctions( + observe("b", leftDossier), + observe("c", invalidCfg), + 0, + 100, + ); + expect( + comparison.dimensions.find(({ dimension }) => dimension === "cfg"), + ).toMatchObject({ status: "unknown" }); + const pseudocode = comparison.dimensions.find( + ({ dimension }) => dimension === "pseudocode", + ); + expect(pseudocode).toMatchObject({ status: "changed" }); + expect(pseudocode?.left_digest).not.toBe(pseudocode?.right_digest); + }); +}); diff --git a/tests/functionComparisonMcp.test.ts b/tests/functionComparisonMcp.test.ts new file mode 100644 index 000000000..31c83b9c4 --- /dev/null +++ b/tests/functionComparisonMcp.test.ts @@ -0,0 +1,100 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js"; +import { parseEvidence } from "../src/domain/evidence.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +describe("function comparison MCP integration", () => { + it("records linked comparison Evidence and an approved residual unknown", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left).ok).toBe( + true, + ); + expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.right).ok).toBe( + true, + ); + const server = createServer(session, session); + const client = new Client({ + name: "function-comparison-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_functions", + arguments: { + left: FUNCTION_COMPARISON_EXAMPLE.left, + right: FUNCTION_COMPARISON_EXAMPLE.right, + unknown_registry_approved: true, + }, + }); + expect(result.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(result.structuredContent) + .result, + ); + expect(evidence).toMatchObject({ + provider: { id: "rea-function-comparison" }, + evidence_links: [ + FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, + FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + ], + }); + const unknowns = await client.callTool({ + name: "list_unknowns", + arguments: { domain: "function-comparison" }, + }); + expect(unknowns.structuredContent).toMatchObject({ + result: [expect.objectContaining({ domain: "function-comparison" })], + }); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + + it("rejects dangling Evidence links before recording output", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "function-link-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_functions", + arguments: { + left: FUNCTION_COMPARISON_EXAMPLE.left, + right: FUNCTION_COMPARISON_EXAMPLE.right, + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toEqual([]); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); +}); diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index 2793b94b2..4f9f1833b 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -30,6 +30,7 @@ class FixtureLauncher implements BridgeLauncher { fixturePath, session.socketPath, this.tokenOverride ?? session.token, + session.runId, ], { stdio: ["ignore", "ignore", "pipe"], diff --git a/tests/hopperValues.test.ts b/tests/hopperValues.test.ts index 75d016f67..14a6c2262 100644 --- a/tests/hopperValues.test.ts +++ b/tests/hopperValues.test.ts @@ -33,8 +33,29 @@ describe("Hopper boundary values", () => { value: ["0x3"], }); expect( - parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok, - ).toBe(true); + parseSegments([ + { + name: "__TEXT", + start: "0x1", + end: "0x2", + readable: true, + writable: false, + executable: null, + }, + ]), + ).toEqual({ + ok: true, + value: [ + { + name: "__TEXT", + start: "0x1", + end: "0x2", + readable: true, + writable: false, + executable: null, + }, + ], + }); expect(parseDocuments(["fixture"])).toEqual({ ok: true, value: ["fixture"], diff --git a/tests/integration/configIntegration.test.ts b/tests/integration/configIntegration.test.ts index 57336ca63..70e4efa79 100644 --- a/tests/integration/configIntegration.test.ts +++ b/tests/integration/configIntegration.test.ts @@ -20,6 +20,7 @@ describe("configuration parsing integration", () => { HOPPER_TARGET_PATH: "/usr/bin/true", HOPPER_TARGET_KIND: "executable", HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]', + REA_EVIDENCE_ROOTS_JSON: '["/tmp/evidence"]', }); expect(result.ok).toBe(true); if (!result.ok) throw new Error("unreachable"); @@ -30,6 +31,7 @@ describe("configuration parsing integration", () => { "Mach-O", "--aarch64", ]); + expect(result.value.evidenceFilePolicy.roots).toEqual(["/tmp/evidence"]); }); it("rejects target kind values outside the enum", () => { diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index 65dc85ddd..73e410640 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -7,9 +7,18 @@ import type { AnalysisClient, AnalysisOperationPort, } from "../../src/application/AnalysisProvider.js"; -import { HopperRemoteError } from "../../src/domain/errors.js"; -import { err, ok } from "../../src/domain/result.js"; +import { + AnalysisCapabilityUnavailableError, + HopperRemoteError, +} from "../../src/domain/errors.js"; +import { err } from "../../src/domain/result.js"; +import { observed as ok } from "../fixtures/analysisExecution.js"; import { createServer } from "../../src/server/createServer.js"; +import { createEvidence } from "../../src/domain/evidence.js"; +import { processCaptureSchema } from "../../src/domain/processCapture.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../src/contracts/processCaptureExample.js"; +import { jsonValueSchema } from "../../src/domain/jsonValue.js"; +import { PROCESS_PROVIDER } from "../../src/server/sessionToolPolicies.js"; const resources: Array<{ close(): Promise }> = []; @@ -87,8 +96,8 @@ describe("full MCP integration with multi-tool sequences", () => { expect(structured(listResult)).toMatchObject({ subject: null, operation: "list_procedures", - provider: { id: "unidentified", version: null }, - result: { + provider: { id: "fixture", version: "1" }, + normalized_result: { items: [ { address: "0x1000", value: "main" }, { address: "0x2000", value: "helper" }, @@ -105,7 +114,7 @@ describe("full MCP integration with multi-tool sequences", () => { expect(structured(decompileResult)).toMatchObject({ operation: "procedure_pseudo_code", parameters: { document: null, procedure: "0x1000" }, - result: "pseudo for 0x1000", + normalized_result: "pseudo for 0x1000", }); const xrefResult = await client.callTool({ @@ -114,11 +123,11 @@ describe("full MCP integration with multi-tool sequences", () => { }); expect(structured(xrefResult)).toMatchObject({ operation: "xrefs", - result: ["0x1000"], + normalized_result: ["0x1000"], }); }); - it("preserves the complete 50-tool inventory with a session", async () => { + it("preserves the complete 68-tool inventory with a session", async () => { const session = new BinarySession( (_path) => ({ @@ -141,7 +150,7 @@ describe("full MCP integration with multi-tool sequences", () => { await client.connect(clientTransport); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(50); + expect(listed.tools).toHaveLength(68); const names = listed.tools.map((t) => t.name); expect(names).toContain("open_binary"); expect(names).toContain("close_binary"); @@ -150,12 +159,195 @@ describe("full MCP integration with multi-tool sequences", () => { expect(names).toContain("batch_decompile"); }); - it("preserves the 43-tool inventory without a session", async () => { + it("records approved trace truncation as a deduplicated residual unknown", async () => { + const analysis: AnalysisOperationPort = { + execute: () => + Promise.resolve( + ok({ + items: [], + offset: 0, + limit: 500, + total: 0, + next_offset: null, + has_more: false, + }), + ), + }; + const session = new BinarySession( + () => + ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }) satisfies AnalysisClient, + ); + const server = createServer(analysis, session); + const client = new Client({ name: "unknown-workflow", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const traced = await client.callTool({ + name: "trace_feature", + arguments: { + query: "license", + max_operations: 1, + unknown_registry_approved: true, + }, + }); + expect(traced.isError).not.toBe(true); + } + const listed = structured( + await client.callTool({ name: "list_unknowns", arguments: {} }), + ); + expect(listed).toMatchObject({ + result: [ + { + status: "open", + domain: "control-flow", + question: "Investigation reached the operation budget.", + }, + ], + }); + }); + + it("records approved typed capability unavailability without forwarding the flag", async () => { + const received: Array>> = []; + const analysis: AnalysisOperationPort = { + execute: (name, arguments_) => { + received.push(arguments_); + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "partial", + name, + "Decompiler is not installed.", + ), + ), + ); + }, + }; + const session = new BinarySession( + () => + ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }) satisfies AnalysisClient, + ); + const server = createServer(analysis, session); + const client = new Client({ name: "unavailable-unknown", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const unavailable = await client.callTool({ + name: "procedure_pseudo_code", + arguments: { + procedure: "main", + unknown_registry_approved: true, + }, + }); + expect(unavailable.isError).toBe(true); + expect(received[0]).not.toHaveProperty("unknown_registry_approved"); + expect( + structured( + await client.callTool({ name: "list_unknowns", arguments: {} }), + ), + ).toMatchObject({ + result: [ + { + domain: "provider-capability", + question: + "procedure_pseudo_code is unavailable: Decompiler is not installed.", + }, + ], + }); + }); + + it("records approved capture disagreement as a contradicted unknown", async () => { + const session = new BinarySession( + () => + ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }) satisfies AnalysisClient, + ); + const left = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE); + const right = processCaptureSchema.parse({ + ...EMPTY_PROCESS_CAPTURE_EXAMPLE, + exit: { code: 1, signal: null, reason: "exited" }, + }); + const captureEvidence = (capture: typeof left) => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v2", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse(capture), + confidence: "observed", + authority: "controlled-replay", + }); + const leftEvidence = captureEvidence(left); + const rightEvidence = captureEvidence(right); + expect(session.recordEvidence(leftEvidence).ok).toBe(true); + expect(session.recordEvidence(rightEvidence).ok).toBe(true); + const server = createServer( + { execute: () => Promise.resolve(ok(null)) }, + session, + ); + const client = new Client({ name: "comparison-unknown", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const mismatched = await client.callTool({ + name: "compare_process_captures", + arguments: { + left_evidence_id: leftEvidence.evidence_id, + left: right, + right_evidence_id: rightEvidence.evidence_id, + right, + }, + }); + expect(mismatched.isError).toBe(true); + + const compared = await client.callTool({ + name: "compare_process_captures", + arguments: { + left_evidence_id: leftEvidence.evidence_id, + left, + right_evidence_id: rightEvidence.evidence_id, + right, + unknown_registry_approved: true, + }, + }); + expect(compared.isError).not.toBe(true); + expect( + structured( + await client.callTool({ name: "list_unknowns", arguments: {} }), + ), + ).toMatchObject({ + result: [ + { + status: "contradicted", + domain: "process-comparison", + contradicting_evidence_ids: [rightEvidence.evidence_id], + }, + ], + }); + }); + + it("preserves the 50-tool inventory without a session", async () => { const client = await connect({ execute: () => Promise.resolve(ok(null)), }); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(43); + expect(listed.tools).toHaveLength(50); const names = listed.tools.map((t) => t.name); expect(names).toContain("binary_overview"); expect(names).toContain("batch_decompile"); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts new file mode 100644 index 000000000..5bda64296 --- /dev/null +++ b/tests/investigationMcp.test.ts @@ -0,0 +1,217 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../src/contracts/functionComparisonExample.js"; +import { INVESTIGATION_EXAMPLES } from "../src/contracts/investigationExamples.js"; +import { + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_CAPTURE_REFERENCE, + PROCESS_COMPARISON_EVIDENCE, +} from "../src/contracts/investigationExamples.js"; +import { parseEvidence } from "../src/domain/evidence.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +describe("investigation MCP workflows", () => { + it("aggregates comparison Evidence and records an approved runtime gap", async () => { + const { session, server, client } = await connected(); + const comparison = + INVESTIGATION_EXAMPLES.find_changed_behavior.comparisons[0]; + if (comparison === undefined) throw new Error("missing comparison fixture"); + for (const evidence of [ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + comparison, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + try { + const response = await client.callTool({ + name: "find_changed_behavior", + arguments: { + comparisons: [comparison], + unknown_registry_approved: true, + }, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(response.structuredContent) + .result, + ); + expect(evidence).toMatchObject({ + provider: { id: "rea-changed-behavior" }, + normalized_result: { behavior_status: "unknown" }, + }); + expect(session.listUnknowns({ domain: "changed-behavior" })).toHaveLength( + 1, + ); + } finally { + await close(session, server, client); + } + }); + + it("builds a zero-hop exact-address path with dossier citations", async () => { + const { session, server, client } = await connected(); + expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left).ok).toBe( + true, + ); + try { + const response = await client.callTool({ + name: "build_call_path", + arguments: INVESTIGATION_EXAMPLES.build_call_path, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(response.structuredContent) + .result, + ); + expect(evidence).toMatchObject({ + provider: { id: "rea-call-path" }, + normalized_result: { + status: "found", + shortest_hops: 0, + paths: { total: 1 }, + }, + evidence_links: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id], + }); + } finally { + await close(session, server, client); + } + }); + + it("records an explicit non-causal static/runtime hypothesis", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + FUNCTION_COMPARISON_EVIDENCE, + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + try { + const response = await client.callTool({ + name: "correlate_static_and_runtime", + arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(response.structuredContent) + .result, + ); + expect(evidence).toMatchObject({ + provider: { id: "rea-static-runtime-correlation" }, + confidence: "inferred", + normalized_result: { + status: "correlated", + summary: { hypotheses: 1 }, + }, + }); + } finally { + await close(session, server, client); + } + }); + + it("passes only the finite declared reconstruction specification", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + try { + const response = await client.callTool({ + name: "verify_reconstruction", + arguments: INVESTIGATION_EXAMPLES.verify_reconstruction, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(response.structuredContent) + .result, + ); + expect(evidence).toMatchObject({ + provider: { id: "rea-reconstruction-verifier" }, + normalized_result: { status: "pass", summary: { passed: 1 } }, + }); + expect(evidence.limitations).toContain( + "Pass means every declared claim passed; it does not establish global implementation equivalence.", + ); + } finally { + await close(session, server, client); + } + }); + + it("cannot omit a session-owned active unknown from reconstruction input", async () => { + const { session, server, client } = await connected(); + for (const evidence of [ + PROCESS_CAPTURE_REFERENCE, + PROCESS_CAPTURE_RECONSTRUCTION, + PROCESS_COMPARISON_EVIDENCE, + ]) + expect(session.recordEvidence(evidence).ok).toBe(true); + expect( + session.recordUnknown({ + approved: true, + question: "Was terminal equivalence reproduced independently?", + severity: "high", + domain: "reconstruction-verification", + supporting_evidence_ids: [PROCESS_COMPARISON_EVIDENCE.evidence_id], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { + operation: "capture_process_scenario", + rationale: "Repeat both sides under one controlled environment.", + }, + ], + relationships: [], + }).ok, + ).toBe(true); + try { + const response = await client.callTool({ + name: "verify_reconstruction", + arguments: INVESTIGATION_EXAMPLES.verify_reconstruction, + }); + expect(response.isError).not.toBe(true); + const evidence = parseEvidence( + z.object({ result: z.unknown() }).parse(response.structuredContent) + .result, + ); + expect(evidence.normalized_result).toMatchObject({ + status: "unknown", + summary: { unknown: 1 }, + }); + } finally { + await close(session, server, client); + } + }); +}); + +const connected = async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "investigation-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { session, server, client }; +}; + +const close = async ( + session: BinarySession, + server: Awaited>, + client: Client, +) => { + await Promise.allSettled([client.close(), server.close(), session.close()]); +}; diff --git a/tests/nativeProvider.test.ts b/tests/nativeProvider.test.ts new file mode 100644 index 000000000..95ad3389a --- /dev/null +++ b/tests/nativeProvider.test.ts @@ -0,0 +1,235 @@ +import { mkdtemp, mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { NativeMacOSProvider } from "../src/native/NativeMacOSProvider.js"; +import { + NativeCommandFailure, + type NativeCommandCapture, + type NativeCommandRunner, +} from "../src/native/CommandRunner.js"; +import type { BinaryTarget } from "../src/domain/binaryTarget.js"; +import { err, ok } from "../src/domain/result.js"; +import { parseLipoArchitectures } from "../src/native/parsers/lipo.js"; + +let directory: string | undefined; +afterEach(async () => { + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("native macOS provider", () => { + it("normalizes comprehensive Mach-O inspection with exact bounded provenance", async () => { + const runner = new FixtureRunner(); + const client = new NativeMacOSProvider(runner, "darwin").createClient( + machoTarget("/private/fixture"), + ); + const execution = await client.execute("inspect_macho", {}); + expect(execution.ok).toBe(true); + if (!execution.ok) return; + expect(execution.value.provider.id).toBe("native-macos"); + expect(execution.value.result).toMatchObject({ + format: "mach-o", + word_size: 64, + uuid: "01234567-89AB-CDEF-0123-456789ABCDEF", + architectures: { total: 2, exhaustive: true }, + dependencies: { + items: [{ path: "/usr/lib/libSystem.B.dylib", kind: "LC_LOAD_DYLIB" }], + }, + segments: { + items: [ + { + name: "__TEXT", + initial_permissions: { read: true, write: false, execute: true }, + sections: { items: [{ segment: "__TEXT", name: "__text" }] }, + }, + ], + }, + imports: { exhaustive: false }, + exports: { exhaustive: false }, + }); + expect(JSON.stringify(execution.value.result)).not.toContain( + "/private/fixture", + ); + expect(execution.value.locations).toContainEqual({ + kind: "file-offset-range", + start: 16384, + end: 20480, + }); + }); + + it("supports architectures, signatures, plists, and ordered demangling", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-native-")); + const app = join(directory, "Fixture.app"); + const executable = join(app, "Contents/MacOS/Fixture"); + await mkdir(join(app, "Contents/MacOS"), { recursive: true }); + await writeFile(executable, "fixture"); + await writeFile(join(app, "Contents/Info.plist"), "fixture"); + const client = new NativeMacOSProvider( + new FixtureRunner(), + "darwin", + ).createClient(machoTarget(executable, app)); + + const architectures = await client.execute("list_architectures", {}); + expect(architectures.ok && architectures.value.result).toMatchObject({ + architectures: { total: 2 }, + }); + const signature = await client.execute("inspect_signature", {}); + expect(signature.ok && signature.value.result).toMatchObject({ + signed: true, + identifier: "com.example.fixture", + hardened_runtime: true, + entitlements: { "com.apple.security.app-sandbox": true }, + }); + const plist = await client.execute("inspect_plist", { + relative_path: "Contents/Info.plist", + }); + expect(plist.ok && plist.value.result).toMatchObject({ + bundle: { identifier: "com.example.fixture", version: "42" }, + source_path: "Contents/Info.plist", + }); + const demangled = await client.execute("demangle_swift", { + symbols: ["$s4Test3fooyyF", "plain_symbol"], + }); + expect(demangled.ok && demangled.value.result).toMatchObject({ + symbols: [ + { input: "$s4Test3fooyyF", status: "demangled" }, + { input: "plain_symbol", status: "unchanged" }, + ], + }); + }); + + it("classifies unavailable, malformed, timeout, and cancellation", async () => { + const unavailable = new NativeMacOSProvider( + new FailingRunner("unavailable"), + "darwin", + ).createClient(machoTarget("/fixture")); + const unavailableResult = await unavailable.execute( + "list_architectures", + {}, + ); + expect(!unavailableResult.ok && unavailableResult.error._tag).toBe( + "AnalysisCapabilityUnavailableError", + ); + + const malformed = new NativeMacOSProvider( + new FixtureRunner({ lipo: "malformed" }), + "darwin", + ).createClient(machoTarget("/fixture")); + const malformedResult = await malformed.execute("list_architectures", {}); + expect(!malformedResult.ok && malformedResult.error._tag).toBe( + "AnalysisOutputError", + ); + + for (const [reason, tag] of [ + ["timeout", "AnalysisTimeoutError"], + ["cancelled", "AnalysisCancelledError"], + ] as const) { + const client = new NativeMacOSProvider( + new FailingRunner(reason), + "darwin", + ).createClient(machoTarget("/fixture")); + const result = await client.execute("list_architectures", {}); + expect(!result.ok && result.error._tag).toBe(tag); + } + }); + + it("parses thin and universal lipo fixtures", async () => { + expect( + parseLipoArchitectures(await fixture("lipo-thin.txt")), + ).toMatchObject([{ name: "arm64" }]); + expect(parseLipoArchitectures(await fixture("lipo-fat.txt"))).toMatchObject( + [ + { name: "x86_64", file_offset: 16384, size: 4096 }, + { name: "arm64", file_offset: 32768, size: 8192 }, + ], + ); + }); +}); + +class FixtureRunner implements NativeCommandRunner { + constructor( + private readonly overrides: Readonly> = {}, + ) {} + + async run(tool: string, arguments_: readonly string[]) { + const output = this.overrides[tool] ?? (await outputFor(tool, arguments_)); + return ok(capture(tool, arguments_, output)); + } +} + +class FailingRunner implements NativeCommandRunner { + constructor(private readonly reason: NativeCommandFailure["reason"]) {} + + run(tool: string) { + return Promise.resolve(err(new NativeCommandFailure(tool, this.reason))); + } +} + +const outputFor = async ( + tool: string, + arguments_: readonly string[], +): Promise => { + if (tool === "lipo") return fixture("lipo-fat.txt"); + if (tool === "otool") return fixture("otool-load.txt"); + if (tool === "nm") return "_main\n_$s4Test3fooyyF\n"; + if (tool === "dyld_info") + return fixture( + arguments_[0] === "-imports" ? "dyld-imports.txt" : "dyld-exports.txt", + ); + if (tool === "dwarfdump") + return "UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) fixture\n"; + if (tool === "file") + return arguments_.at(-1)?.endsWith(".plist") === true + ? "XML 1.0 document text\n" + : "Mach-O 64-bit executable arm64, little-endian\n"; + if (tool === "vtool") return "Load command 3 LC_BUILD_VERSION\n"; + if (tool === "swift-demangle") return fixture("demangle.txt"); + if (tool === "plutil") return fixture("plist.json"); + if (tool === "codesign") { + if (arguments_.includes("--entitlements")) + return fixture("entitlements.xml"); + if (arguments_.includes("-r-")) + return "designated => identifier com.example.fixture\n"; + return fixture("codesign.txt"); + } + throw new Error(`Unexpected fixture tool ${tool}`); +}; + +const capture = ( + tool: string, + arguments_: readonly string[], + output: string, +): NativeCommandCapture => ({ + tool, + executable: `/usr/bin/${tool}`, + executableSha256: "a".repeat(64), + toolVersion: null, + versionReason: "fixture", + arguments: [...arguments_], + stdout: tool === "codesign" ? "" : output, + stderr: tool === "codesign" ? output : "", + stdoutBytes: Buffer.byteLength(tool === "codesign" ? "" : output), + stderrBytes: Buffer.byteLength(tool === "codesign" ? output : ""), + stdoutTruncated: false, + stderrTruncated: false, + exitCode: 0, + signal: null, +}); + +const machoTarget = (path: string, sourcePath?: string): BinaryTarget => ({ + path, + ...(sourcePath === undefined ? {} : { sourcePath }), + sha256: "0".repeat(64), + kind: "executable", + format: "mach-o", + architecture: "arm64", + availableArchitectures: ["x86_64", "arm64"], + loaderArgs: [], +}); + +const fixture = (name: string): Promise => + readFile(new URL(`./fixtures/native-macos/${name}`, import.meta.url), "utf8"); diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts index c5d137a4d..9aaa1a837 100644 --- a/tests/officialTools.test.ts +++ b/tests/officialTools.test.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; import { HopperRemoteError } from "../src/domain/errors.js"; -import { err, ok } from "../src/domain/result.js"; +import { err } from "../src/domain/result.js"; +import { observed as ok } from "./fixtures/analysisExecution.js"; import type { JsonValue } from "../src/domain/jsonValue.js"; import { createServer } from "../src/server/createServer.js"; @@ -98,7 +99,7 @@ const outputFor = (name: string): JsonValue => { ) return true; if (name === "set_addresses_names") return { "0x1000": true }; - if (["search_procedures", "search_strings"].includes(name)) return {}; + if (["search_procedures", "search_strings"].includes(name)) return page; if (name === "procedure_info") return { name: "main", @@ -137,7 +138,7 @@ describe("official Hopper proxy tools", () => { execute: () => Promise.resolve(ok(null)), }); const listed = await client.listTools(); - const officialNames = new Set( + const officialNames = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), ); expect( @@ -178,7 +179,10 @@ describe("official Hopper proxy tools", () => { invocations.find(({ name }) => name === "search_procedures")?.arguments_, ).toEqual({ pattern: "main", + mode: "literal", case_sensitive: false, + offset: 0, + limit: 100, document: null, }); }); diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index 482a49a0e..4dd4ed9cd 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -1,8 +1,11 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { startLoopbackReplay } from "../src/application/LoopbackReplay.js"; import { captureProcessScenario, @@ -16,6 +19,11 @@ import { type ProcessExecutionPolicy, } from "../src/domain/processCapture.js"; +const processFixture = fileURLToPath( + new URL("./fixtures/processFidelity.mjs", import.meta.url), +); +const execFileAsync = promisify(execFile); + describe("process capture domain", () => { const base = { approved: true as const, @@ -34,6 +42,33 @@ describe("process capture domain", () => { ], }), ).toThrow(/ordered/); + expect(() => + parseProcessScenario({ + ...base, + environment: { HOME: "/unsafe" }, + }), + ).toThrow(/reserved/); + expect(() => + parseProcessScenario({ + ...base, + events: [{ type: "input", at_ms: 31_000, data: "late" }], + }), + ).toThrow(/after the scenario timeout/); + }); + + it("requires explicit operator approval for host network access", () => { + expect( + authorizeProcessScenario(parseProcessScenario(base), { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: false, + }), + ).toEqual({ + allowed: false, + reason: "host network access is not approved by operator policy", + }); }); it("refuses paths and environment outside operator policy", () => { @@ -47,6 +82,7 @@ describe("process capture domain", () => { executableRoots: ["/bin"], workingRoots: ["/tmp"], allowedEnvironment: [], + allowExternalNetwork: true, }), ).toEqual({ allowed: false, @@ -56,18 +92,142 @@ describe("process capture domain", () => { it("never considers truncated captures equivalent", () => { const capture = { - schema_version: 1 as const, + schema_version: 2 as const, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, frames: [], - exit: { code: 0, signal: null }, + exit: { code: 0, signal: null, reason: "exited" as const }, process_samples: [], protocol_events: [], files_before: [], files_after: [], + filesystem_effects: [], truncated: true, limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, }; expect(compareProcessCaptures(capture, capture).status).toBe("truncated"); }); + + it("classifies missing observations as unknown and one-sided evidence as added", () => { + const base = { + schema_version: 2 as const, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + protocol_events: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + const added = compareProcessCaptures(base, { + ...base, + frames: [{ sequence: 0, at_ms: 0, data: "new" }], + }); + expect(added.terminal).toBe("added"); + expect(added.status).toBe("changed"); + const unknown = compareProcessCaptures( + { ...base, residual_unknowns: [{ scope: "process", reason: "sampled" }] }, + base, + ); + expect(unknown.process).toBe("unknown"); + expect(unknown.status).toBe("unknown"); + }); + + it("keeps filesystem evidence unknown when stable snapshots match", () => { + const capture = { + schema_version: 2 as const, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [], + protocol_events: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [ + { scope: "filesystem" as const, reason: "watcher unavailable" }, + ], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + + const comparison = compareProcessCaptures(capture, capture); + expect(comparison.filesystem).toBe("unknown"); + expect(comparison.status).toBe("unknown"); + }); + + it("detects changes in normalized process sample metadata", () => { + const capture = { + schema_version: 2 as const, + normalization: { + paths: true, + pids: true, + ports: true, + time_bucket_ms: 10, + patterns: [], + }, + frames: [], + exit: { code: 0, signal: null, reason: "exited" as const }, + process_samples: [ + { at_ms: 10, pid: 1, parent_pid: 0, command: "worker" }, + ], + protocol_events: [], + files_before: [], + files_after: [], + filesystem_effects: [], + truncated: false, + limitations: [], + residual_unknowns: [], + cleanup: { + owned_process_group: "verified" as const, + temporary_root: "removed" as const, + }, + }; + const changed = { + ...capture, + process_samples: [ + { at_ms: 20, pid: 1, parent_pid: 2, command: "worker" }, + ], + }; + + const comparison = compareProcessCaptures(capture, changed); + expect(comparison.process).toBe("changed"); + expect(comparison.status).toBe("changed"); + }); }); describe("process capture adapter", () => { @@ -105,6 +265,102 @@ describe("process capture adapter", () => { } }); + it("matches bounded HTTP scripts without persisting request secrets", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + http: [ + { + method: "POST", + path: "/callback", + request_headers: { authorization: "Bearer fixture-secret" }, + request_body: "credential-body", + status: 302, + response_headers: { location: "/done" }, + body: "redirecting", + max_calls: 1, + }, + { + method: "GET", + path: "/disconnect", + status: 200, + body: "", + disconnect: true, + }, + ], + }, + }); + const replay = await startLoopbackReplay(scenario); + try { + const request = () => + fetch(`${replay.httpUrl}/callback`, { + method: "POST", + headers: { authorization: "Bearer fixture-secret" }, + body: "credential-body", + redirect: "manual", + }); + const matched = await request(); + expect(matched.status).toBe(302); + expect(matched.headers.get("location")).toBe("/done"); + expect((await request()).status).toBe(409); + expect((await fetch(`${replay.httpUrl}/missing`)).status).toBe(404); + await expect(fetch(`${replay.httpUrl}/disconnect`)).rejects.toThrow(); + expect(replay.events.map(({ outcome }) => outcome)).toEqual( + expect.arrayContaining([ + "matched", + "script_exhausted", + "unmatched", + "disconnected", + ]), + ); + expect(JSON.stringify(replay.events)).not.toContain("fixture-secret"); + expect(JSON.stringify(replay.events)).not.toContain("credential-body"); + } finally { + await replay.close(); + } + }); + + it("consumes ordered WebSocket reconnect scripts and reports exhaustion", async () => { + const scenario = parseProcessScenario({ + approved: true, + executable: "/bin/sh", + working_directory: "/tmp", + replay: { + websocket_connections: [ + { + messages: [{ data: "first", delay_ms: 1 }], + disconnect_after: true, + }, + { + messages: [{ data: "second" }], + disconnect_after: true, + }, + ], + }, + }); + const replay = await startLoopbackReplay(scenario); + const receive = () => + new Promise((resolveMessage, rejectMessage) => { + const socket = new WebSocket(replay.websocketUrl); + socket.once("message", (value) => resolveMessage(value.toString())); + socket.once("error", rejectMessage); + }); + try { + await expect(receive()).resolves.toBe("first"); + await expect(receive()).resolves.toBe("second"); + await new Promise((resolveClose, rejectClose) => { + const socket = new WebSocket(replay.websocketUrl); + socket.once("close", () => resolveClose()); + socket.once("error", rejectClose); + }); + expect(replay.events.at(-1)?.outcome).toBe("script_exhausted"); + } finally { + await replay.close(); + } + }); + it("captures PTY, filesystem, descendants, HTTP replay, and redacts environment", async () => { const root = await mkdtemp(join(tmpdir(), "rea-harness-test-")); const script = join(root, "fixture.mjs"); @@ -127,6 +383,7 @@ describe("process capture adapter", () => { executableRoots: [dirname(process.execPath)], workingRoots: [root], allowedEnvironment: ["SECRET"], + allowExternalNetwork: true, }; const scenario = parseProcessScenario({ approved: true, @@ -160,6 +417,12 @@ describe("process capture adapter", () => { file.path.endsWith("result.txt"), ), ).toBe(true); + expect( + capture.value.filesystem_effects.some( + (effect) => + effect.path.endsWith(":result.txt") && effect.status === "created", + ), + ).toBe(true); expect( capture.value.protocol_events.some( (event) => event.protocol === "http" && event.path === "/probe", @@ -167,6 +430,44 @@ describe("process capture adapter", () => { ).toBe(true); expect(JSON.stringify(capture.value)).not.toContain("do-not-record"); expect(await readFile(join(root, "result.txt"), "utf8")).toBe("created"); + expect(JSON.stringify(capture.value.files_after)).not.toContain(root); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("does not follow or disclose symlink targets outside declared roots", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-symlink-test-")); + await symlink("/etc/passwd", join(root, "escape")); + try { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: "/bin/true", + working_directory: root, + filesystem_roots: [root], + }), + { + enabled: true, + executableRoots: ["/bin"], + workingRoots: [root], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const escaped = result.value.files_after.find((file) => + file.path.endsWith(":escape"), + ); + expect(escaped?.symlink_target).toBe(""); + expect(result.value.truncated).toBe(true); + expect(JSON.stringify(result.value.files_after)).not.toContain(root); + expect(JSON.stringify(result.value.files_after)).not.toContain( + "/etc/passwd", + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -183,9 +484,130 @@ describe("process capture adapter", () => { executableRoots: [], workingRoots: [], allowedEnvironment: [], + allowExternalNetwork: true, }); expect(result.ok).toBe(false); if (result.ok) throw new Error("expected policy refusal"); expect(result.error).toBeInstanceOf(ProcessCaptureError); }); + + it("distinguishes timeout from cancellation and cleans both runs", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const policy: ProcessExecutionPolicy = { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: true, + }; + const timedOut = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: "/bin/sh", + arguments: ["-c", "sleep 5"], + working_directory: "/tmp", + timeout_ms: 50, + idle_timeout_ms: 5_000, + }), + policy, + ); + expect(timedOut.ok).toBe(true); + if (!timedOut.ok) throw timedOut.error; + expect(timedOut.value.exit.reason).toBe("timeout"); + expect(timedOut.value.cleanup).toEqual({ + owned_process_group: "verified", + temporary_root: "removed", + }); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + const cancelled = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: "/bin/sh", + arguments: ["-c", "sleep 5"], + working_directory: "/tmp", + timeout_ms: 5_000, + idle_timeout_ms: 5_000, + }), + policy, + controller.signal, + ); + expect(cancelled.ok).toBe(false); + if (cancelled.ok) throw new Error("expected cancellation"); + expect(cancelled.error.message).toContain("cancelled"); + }); + + it("captures source-owned interactive, resize, Unicode, and signal behavior", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "interactive"], + working_directory: dirname(processFixture), + events: [ + { type: "input", at_ms: 100, data: "answer" }, + { type: "resize", at_ms: 300, columns: 100, rows: 40 }, + { type: "signal", at_ms: 700, signal: "SIGINT" }, + ], + timeout_ms: 2_000, + idle_timeout_ms: 2_000, + }), + { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [dirname(processFixture)], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const output = result.value.frames.map(({ data }) => data).join(""); + expect(output).toContain("prompt>"); + expect(output).toContain("input:answer unicode:雪"); + expect(output).toContain("resize:100x40"); + expect(output).toContain("signal:SIGINT"); + expect(result.value.exit.code).toBe(0); + }); + + it("samples and cleans a source-owned child and grandchild process tree", async () => { + const capability = await probeProcessCaptureCapability(); + if (!capability.available) return; + const result = await captureProcessScenario( + parseProcessScenario({ + approved: true, + executable: process.execPath, + arguments: [processFixture, "tree"], + working_directory: dirname(processFixture), + timeout_ms: 2_000, + idle_timeout_ms: 2_000, + }), + { + enabled: true, + executableRoots: [dirname(process.execPath)], + workingRoots: [dirname(processFixture)], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const commands = result.value.process_samples.map(({ command }) => command); + expect(commands.some((command) => command.includes("tree-child"))).toBe( + true, + ); + expect( + commands.some((command) => command.includes("tree-grandchild")), + ).toBe(true); + expect(JSON.stringify(result.value.process_samples)).not.toContain( + dirname(processFixture), + ); + const { stdout } = await execFileAsync("ps", ["-axo", "command="]); + expect(stdout).not.toContain(`${processFixture} tree-child`); + expect(stdout).not.toContain(`${processFixture} tree-grandchild`); + }); }); diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts new file mode 100644 index 000000000..04e2abfdb --- /dev/null +++ b/tests/processOwnership.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { + cleanupOwnedProcessGroup, + type ProcessOwnershipHost, +} from "../src/application/ProcessOwnership.js"; + +const ownership = { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, +}; + +const host = ( + environments: Readonly>>>, +): { + readonly adapter: ProcessOwnershipHost; + readonly signalGroup: ReturnType; +} => { + const signalGroup = vi.fn(); + return { + adapter: { + listMembers: () => + Promise.resolve( + Object.keys(environments).map((pid) => ({ + pid: Number(pid), + parentPid: Number(pid) === 100 ? 1 : 100, + processGroupId: 100, + command: "fixture", + })), + ), + environment: (pid) => Promise.resolve(environments[pid] ?? {}), + signalGroup, + }, + signalGroup, + }; +}; + +describe("owned process-group cleanup", () => { + it("signals only a group whose every member carries the run token", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + 101: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: true, + signaled: true, + }); + expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL"); + }); + + it("fails closed for stale metadata or an unrelated concurrent process", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + 101: { REA_PROCESS_RUN_ID: "different-run" }, + }); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: false, + reason: "process group contains an unowned or PID-reused process", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); + + it("is idempotent when the owned group has already exited", async () => { + const { adapter } = host({}); + expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ + cleaned: true, + signaled: false, + }); + }); +}); diff --git a/tests/providerArchitecture.test.ts b/tests/providerArchitecture.test.ts index 11b0468e2..54844b7db 100644 --- a/tests/providerArchitecture.test.ts +++ b/tests/providerArchitecture.test.ts @@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { parseConfig } from "../src/config.js"; +import { HopperProvider } from "../src/hopper/HopperProvider.js"; +import { silentLogger } from "../src/logger.js"; + const sourceRoot = new URL("../src/", import.meta.url); describe("provider-neutral architecture", () => { @@ -23,6 +27,40 @@ describe("provider-neutral architecture", () => { } expect(violations).toEqual([]); }); + + it("publishes deterministic immutable Hopper capability descriptors", () => { + const config = parseConfig({}); + expect(config.ok).toBe(true); + if (!config.ok) return; + const provider = new HopperProvider(config.value, silentLogger); + const capabilities = provider.capabilities(); + expect(capabilities).toHaveLength(34); + expect(new Set(capabilities.map(({ operation }) => operation)).size).toBe( + capabilities.length, + ); + expect(Object.isFrozen(capabilities)).toBe(true); + for (const descriptor of capabilities) { + expect(descriptor.provider).toEqual(provider.identity()); + expect(descriptor).toMatchObject({ + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + }); + expect(Object.isFrozen(descriptor)).toBe(true); + expect(Object.isFrozen(descriptor.effects)).toBe(true); + expect(Object.isFrozen(descriptor.limits)).toBe(true); + expect(Object.isFrozen(descriptor.limitations)).toBe(true); + } + expect( + capabilities.find(({ operation }) => operation === "list_procedures"), + ).toMatchObject({ pagination: "offset" }); + expect( + capabilities.find(({ operation }) => operation === "set_comment"), + ).toMatchObject({ + effects: { mutatesArtifact: true, mayWriteFilesystem: true }, + }); + }); }); const typescriptFiles = async ( diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index 107013dac..0eac0b074 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -2,6 +2,14 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { + ENHANCED_TOOL_CONTRACTS, + OFFICIAL_TOOL_CONTRACTS, + SESSION_TOOL_CONTRACTS, + TOOL_CONTRACTS, +} from "../src/contracts/toolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; const readmes = [ "README.md", @@ -22,10 +30,17 @@ describe("localized README product facts", () => { expect(content).toContain('"args": ["-y", "rea-agents", "mcp"]'); expect(content).toContain("Node.js 24"); expect(content).toContain("macOS 12"); - expect(content).toMatch(/\b50\b/u); - expect(content).toMatch(/\b33\b/u); - expect(content).toMatch(/\b10\b/u); - expect(content).toMatch(/\b3\b/u); + expect(content).toContain(`MCP_tools-${String(TOOL_CONTRACTS.length)}`); + for (const count of [ + OFFICIAL_TOOL_CONTRACTS.length, + ENHANCED_TOOL_CONTRACTS.length, + NATIVE_TOOL_CONTRACTS.length, + ARTIFACT_TOOL_CONTRACTS.length, + SESSION_TOOL_CONTRACTS.length, + ]) + expect(content).toMatch( + new RegExp(`\\|\\s*${String(count)}\\s*\\|`, "u"), + ); }, ); }); diff --git a/tests/realHopperAssertions.test.ts b/tests/realHopperAssertions.test.ts new file mode 100644 index 000000000..0140ac591 --- /dev/null +++ b/tests/realHopperAssertions.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { + firstProcedureAddress, + requireAddressArray, + requireFunctionDossier, + requirePseudocode, +} from "../src/application/RealHopperAssertions.js"; + +describe("real Hopper semantic assertions", () => { + it("extracts only a real procedure address from a structured page", () => { + expect( + firstProcedureAddress({ + items: [{ address: "0x1000", name: "main" }], + offset: 0, + limit: 100, + total: 1, + next_offset: null, + has_more: false, + }), + ).toBe("0x1000"); + expect(() => + firstProcedureAddress({ items: { address: "0x1000" } }), + ).toThrow(); + expect(() => + firstProcedureAddress({ items: [{ address: "items" }] }), + ).toThrow(); + }); + + it("rejects empty and embedded per-item decompilation failures", () => { + expect(requirePseudocode("return 0;", "batch_decompile")).toBe("return 0;"); + for (const invalid of ["", " ", "No output", "Error: invalid address"]) + expect(() => requirePseudocode(invalid, "batch_decompile")).toThrow(); + }); + + it("requires address-shaped relationship evidence", () => { + expect(requireAddressArray(["0x1000"], "xrefs")).toEqual(["0x1000"]); + expect(() => requireAddressArray(["main"], "xrefs")).toThrow(); + }); + + it("rejects success-shaped dossiers without truthful semantic content", () => { + const dossier = validDossier(); + expect(requireFunctionDossier(dossier, "0x1000")).toEqual(dossier); + expect(() => + requireFunctionDossier( + { + ...dossier, + pseudocode: { + text: "Error: failed", + returned_chars: 13, + total_chars: 13, + }, + }, + "0x1000", + ), + ).toThrow(/embedded failure/u); + expect(() => + requireFunctionDossier( + { ...dossier, instruction_scan: { scanned: 0, truncated: false } }, + "0x1000", + ), + ).toThrow(/instruction scan/u); + }); + + it("counts Unicode code points and accepts terminal scan truncation", () => { + const dossier = validDossier(); + const scanLimited = { + ...dossier, + pseudocode: { text: "😀", returned_chars: 1, total_chars: 1 }, + comments: { + items: [], + total: null, + returned: 0, + truncated: true, + next_offset: null, + }, + instruction_scan: { scanned: 1, truncated: true }, + }; + expect(requireFunctionDossier(scanLimited, "0x1000")).toEqual(scanLimited); + }); +}); + +const validDossier = () => { + const collection = { + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }; + return { + procedure: { address: "0x1000", name: "main" }, + pseudocode: { text: "return 0;", returned_chars: 9, total_chars: 9 }, + comments: collection, + callers: collection, + callees: collection, + incoming_references: collection, + outgoing_references: collection, + referenced_strings: collection, + referenced_names: collection, + basic_blocks: { + ...collection, + items: [{ successors: [] }], + total: 1, + returned: 1, + }, + instruction_scan: { scanned: 1, truncated: false }, + }; +}; diff --git a/tests/reconstructionVerification.test.ts b/tests/reconstructionVerification.test.ts new file mode 100644 index 000000000..801d07120 --- /dev/null +++ b/tests/reconstructionVerification.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; + +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { + reconstructionVerificationResultSchema, + verifyReconstruction, +} from "../src/domain/reconstructionVerification.js"; +import type { JsonValue } from "../src/domain/jsonValue.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; +import { ARTIFACT_COMPARISON_EXAMPLE } from "../src/contracts/artifactComparisonExample.js"; +import { createResidualUnknown } from "../src/domain/residualUnknown.js"; + +const environment = { + id: "fixture-linux", + platform: "linux", + architecture: "x86_64", + isolation: "container" as const, +}; + +const source = ( + digit: string, + authority: "controlled-replay" | "shipped-artifact", + confidence: "observed" | "derived" = "observed", +): Evidence => + createEvidence( + { + path: `/tmp/source-${digit}`, + sha256: digit.repeat(64), + format: "file", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { + predicateType: + authority === "controlled-replay" + ? "rea.process-capture/v2" + : "rea.analysis/v2", + operation: + authority === "controlled-replay" + ? "capture_process_scenario" + : "inventory_artifact", + parameters: {}, + result: + authority === "controlled-replay" + ? EMPTY_PROCESS_CAPTURE_EXAMPLE + : { digit }, + confidence, + authority, + environment: authority === "controlled-replay" ? environment : null, + }, + ); + +const processResult = ( + terminal: "unchanged" | "changed" | "unknown" = "unchanged", +): JsonValue => ({ + status: terminal === "changed" ? "changed" : terminal, + terminal, + exit: "unchanged", + filesystem: "unchanged", + protocol: "unchanged", + process: "unchanged", + limitations: [], +}); + +const processComparison = ( + left: Evidence, + right: Evidence, + result: JsonValue, + extraLinks: readonly string[] = [], +): Evidence => + createEvidence( + undefined, + { + id: "rea-process", + name: "REA deterministic process harness", + version: "1", + }, + { + predicateType: "rea.process-comparison/v1", + operation: "compare_process_captures", + parameters: { + left_evidence_id: left.evidence_id, + right_evidence_id: right.evidence_id, + }, + result, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [left.evidence_id, right.evidence_id, ...extraLinks], + }, + ); + +const behavioralSpec = (comparison: Evidence) => ({ + schema_version: 1 as const, + name: "CLI compatibility", + claims: [ + { + kind: "behavioral" as const, + claim_id: "terminal-output", + title: "Terminal output remains equal", + comparison_evidence_id: comparison.evidence_id, + dimension: "terminal" as const, + }, + ], +}); + +const artifactResult = (partial = false): JsonValue => ({ + status: "unchanged", + left_manifest_id: `agm_${"3".repeat(64)}`, + right_manifest_id: `agm_${"4".repeat(64)}`, + summary: { unchanged: 1, added: 0, removed: 0, changed: 0, unknown: 0 }, + changes: { + items: [], + offset: 0, + limit: 100, + total: partial ? 1 : 0, + next_offset: partial ? 1 : null, + }, + limitations: [], +}); + +const artifactComparison = ( + left: Evidence, + right: Evidence, + partial = false, +): Evidence => + createEvidence( + undefined, + { + id: "rea-artifact-comparison", + name: "REA artifact comparison", + version: "1", + }, + { + predicateType: "rea.artifact-comparison/v1", + operation: "compare_artifacts", + parameters: { + left_evidence_ids: [left.evidence_id], + right_evidence_ids: [right.evidence_id], + }, + result: artifactResult(partial), + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [left.evidence_id, right.evidence_id], + }, + ); + +describe("reconstruction verification", () => { + it("passes a complete authoritative claim with two-sided citations", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const comparison = processComparison(left, right, processResult()); + const result = verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([comparison, right, left]), + 0, + 100, + ); + expect(reconstructionVerificationResultSchema.parse(result)).toMatchObject({ + status: "pass", + summary: { total: 1, passed: 1, failed: 0, unknown: 0 }, + claims: { total: 1, next_offset: null }, + }); + expect(result.claims.items[0]?.evidence_links).toEqual( + expect.arrayContaining([ + comparison.evidence_id, + left.evidence_id, + right.evidence_id, + ]), + ); + }); + + it("fails an authoritative observed disagreement", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const comparison = processComparison(left, right, processResult("changed")); + const result = verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([left, right, comparison]), + 0, + 100, + ); + expect(result).toMatchObject({ + status: "fail", + summary: { failed: 1 }, + claims: { items: [{ status: "fail", observed_status: "changed" }] }, + }); + }); + + it("keeps insufficient authority and partial structural evidence unknown", () => { + const weakLeft = source("1", "controlled-replay", "derived"); + const replayRight = source("2", "controlled-replay"); + const runtime = processComparison(weakLeft, replayRight, processResult()); + expect( + verifyReconstruction( + behavioralSpec(runtime), + createEvidenceBundle([weakLeft, replayRight, runtime]), + 0, + 100, + ).status, + ).toBe("unknown"); + + const left = ARTIFACT_COMPARISON_EXAMPLE.left; + const right = ARTIFACT_COMPARISON_EXAMPLE.right; + const comparison = artifactComparison(left, right, true); + const result = verifyReconstruction( + { + schema_version: 1, + name: "Artifact structure", + claims: [ + { + kind: "structural-artifact", + claim_id: "artifact-graph", + title: "Artifact graph remains equal", + comparison_evidence_id: comparison.evidence_id, + dimension: "overall", + }, + ], + }, + createEvidenceBundle([left, comparison, right]), + 0, + 100, + ); + expect(result.status).toBe("unknown"); + expect(result.recommended_probes[0]?.operation).toBe("inventory_artifact"); + }); + + it("rejects dangling or extra comparison closure", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const extra = source("3", "controlled-replay"); + const comparison = processComparison(left, right, processResult(), [ + extra.evidence_id, + ]); + expect(() => + verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([left, right, extra, comparison]), + 0, + 100, + ), + ).toThrow(/closure disagrees/u); + }); + + it("pages claims deterministically and gives specification a stable digest", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const comparison = processComparison(left, right, processResult()); + const claims = [ + { + kind: "behavioral" as const, + claim_id: "z-exit", + title: "Exit remains equal", + comparison_evidence_id: comparison.evidence_id, + dimension: "exit" as const, + }, + behavioralSpec(comparison).claims[0], + ]; + const bundle = createEvidenceBundle([right, comparison, left]); + const first = verifyReconstruction( + { schema_version: 1, name: "Compatibility", claims }, + bundle, + 0, + 1, + ); + const repeated = verifyReconstruction( + { + name: "Compatibility", + claims: [...claims].reverse(), + schema_version: 1, + }, + bundle, + 0, + 1, + ); + expect(first).toEqual(repeated); + expect(first.claims.items[0]?.claim_id).toBe("terminal-output"); + expect(first.claims.next_offset).toBe(1); + }); + + it("keeps all active unknowns gating while bounding their projection", () => { + const left = source("1", "controlled-replay"); + const right = source("2", "controlled-replay"); + const comparison = processComparison(left, right, processResult()); + const mutations = Array.from({ length: 101 }, (_, index) => + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + predicateType: "rea.residual-unknown-mutation/v1", + operation: "record_unknown", + parameters: { index }, + result: { action: "record" }, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [comparison.evidence_id], + }, + ), + ); + const unknowns = mutations.map((mutation, index) => + createResidualUnknown( + { + approved: true, + question: `Unresolved replay ${index}`, + severity: "high", + domain: "reconstruction-verification", + supporting_evidence_ids: [comparison.evidence_id], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }, + mutation.evidence_id, + null, + ), + ); + const result = verifyReconstruction( + behavioralSpec(comparison), + createEvidenceBundle([left, right, comparison, ...mutations], unknowns), + 0, + 100, + ); + expect(result.status).toBe("unknown"); + expect(result.claims.items[0]?.unknown_ids).toHaveLength(100); + expect(result.claims.items[0]?.limitations).toContain( + "1 additional active residual unknowns affect this claim but are omitted by the 100-item display limit.", + ); + }); +}); diff --git a/tests/referenceSourceGraph.test.ts b/tests/referenceSourceGraph.test.ts new file mode 100644 index 000000000..e00e8c66c --- /dev/null +++ b/tests/referenceSourceGraph.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vitest"; + +import { + computeHistoricalSourceGraphSha256, + createHistoricalSourceGraph, + createHistoricalSourceManifest, + historicalSourceGraphSchema, + parseHistoricalSourceGraph, + parseHistoricalSourceManifest, + type HistoricalSourceGraphInput, +} from "../src/domain/referenceSourceGraph.js"; + +const HASH_A = "a".repeat(64); +const HASH_B = "b".repeat(64); +type Entry = HistoricalSourceGraphInput["entries"][number]; +type DirectoryEntry = Extract; +type FileEntry = Extract; +type SymlinkEntry = Extract; + +const directoryEntry = ( + treeState: DirectoryEntry["tree_state"] = "enumerated", +): DirectoryEntry => ({ + path: "src", + kind: "directory", + classifications: ["source"], + tree_state: treeState, + limitations: [], +}); + +const fileEntry = (overrides: Partial = {}): FileEntry => ({ + path: "src/main.ts", + kind: "file", + sha256: HASH_B, + size: 20, + language: "TypeScript", + classifications: ["source"], + content_state: "hashed", + limitations: [], + ...overrides, +}); + +const symlinkEntry = (overrides: Partial = {}): SymlinkEntry => ({ + path: "src/package.json", + kind: "symlink", + target: "main.ts", + target_state: "internal", + classifications: ["manifest"], + limitations: [], + ...overrides, +}); + +const graphInput = (): HistoricalSourceGraphInput => ({ + schema: "HistoricalSourceGraph/v1", + authority: "historical-reference", + root_alias: "$REFERENCE_ROOT", + inventory_state: "complete", + entries: [directoryEntry(), fileEntry(), symlinkEntry()], + relationships: [ + { + from_path: "src/main.ts", + to: "node:fs", + kind: "imports", + resolution: "external", + parse_state: "parsed", + }, + { + from_path: "src/main.ts", + to: "src", + kind: "references", + resolution: "internal", + parse_state: "parsed", + }, + ], + parse_failures: [], + exclusions: [], + languages: ["TypeScript"], + manifests: [], + vcs: { kind: "git", head: HASH_A, dirty: false }, + provenance: { importer: "rea", importer_version: "1", caller: "test" }, + limitations: [], +}); + +describe("historical source graph", () => { + it("builds and verifies internal root and manifest commitments", () => { + const graph = createHistoricalSourceGraph(graphInput()); + const first = createHistoricalSourceManifest(graph); + const second = createHistoricalSourceManifest(structuredClone(graph)); + + expect(first).toEqual(second); + expect(parseHistoricalSourceGraph(graph)).toEqual(graph); + expect(parseHistoricalSourceManifest(first)).toEqual(first); + expect(first.graph_sha256).toBe(computeHistoricalSourceGraphSha256(graph)); + }); + + it("rejects stale roots and commits file, directory, and symlink semantics", () => { + const original = createHistoricalSourceGraph(graphInput()); + expect(() => + parseHistoricalSourceGraph({ ...original, root_sha256: HASH_A }), + ).toThrow(/root commitment/u); + + for (const mutate of [ + (input: HistoricalSourceGraphInput) => { + input.entries[0] = directoryEntry("partial"); + input.inventory_state = "partial"; + }, + (input: HistoricalSourceGraphInput) => { + input.entries[1] = fileEntry({ sha256: "c".repeat(64) }); + }, + (input: HistoricalSourceGraphInput) => { + input.entries[2] = symlinkEntry({ + target: "other.json", + target_state: "missing", + }); + input.inventory_state = "partial"; + }, + (input: HistoricalSourceGraphInput) => { + input.exclusions = [ + { path: "private.env", reason: "configured-secret" }, + ]; + input.inventory_state = "partial"; + }, + ]) { + const changed = graphInput(); + mutate(changed); + expect(createHistoricalSourceGraph(changed).root_sha256).not.toBe( + original.root_sha256, + ); + } + }); + + it("derives language and manifest indexes and enforces code-point order", () => { + const input = graphInput(); + input.entries[1] = fileEntry({ classifications: ["source", "test"] }); + expect(() => + createHistoricalSourceGraph({ ...input, languages: [] }), + ).toThrow(/derived/u); + expect(() => + createHistoricalSourceGraph({ ...input, manifests: ["src/main.ts"] }), + ).toThrow(/derived/u); + expect(() => + createHistoricalSourceGraph({ + ...input, + entries: input.entries.toReversed(), + }), + ).toThrow(/Unicode code point/u); + expect(() => + createHistoricalSourceGraph({ + ...input, + entries: input.entries.map((entry) => + entry.path === "src/main.ts" + ? { ...entry, classifications: ["test", "source"] } + : entry, + ), + }), + ).toThrow(/Unicode code point/u); + }); + + it("preserves partial observations but rejects every partial complete graph", () => { + const partial = graphInput(); + partial.inventory_state = "partial"; + partial.entries[1] = fileEntry({ + sha256: null, + size: null, + content_state: "unreadable", + limitations: ["Permission denied"], + }); + partial.parse_failures = [ + { path: "src/main.ts", parser: "typescript", reason: "Malformed input" }, + ]; + expect(createHistoricalSourceGraph(partial).inventory_state).toBe( + "partial", + ); + + for (const change of [ + { entries: partial.entries }, + { parse_failures: partial.parse_failures }, + { + exclusions: [{ path: "secret", reason: "configured-secret" as const }], + }, + { limitations: ["Incomplete"] }, + { + relationships: [ + { + ...graphInput().relationships[0]!, + parse_state: "partial" as const, + }, + ], + }, + ]) + expect(() => + createHistoricalSourceGraph({ ...graphInput(), ...change }), + ).toThrow(/Complete inventory/u); + }); + + it("rejects strict unknown fields, invalid VCS, dangling internal edges, and false manifests", () => { + expect(() => + createHistoricalSourceGraph({ ...graphInput(), surprise: true }), + ).toThrow(); + expect(() => + createHistoricalSourceGraph({ + ...graphInput(), + vcs: { kind: "git", head: "main", dirty: false }, + }), + ).toThrow(); + expect(() => + createHistoricalSourceGraph({ + ...graphInput(), + relationships: [ + { ...graphInput().relationships[1]!, to: "missing.ts" }, + ], + }), + ).toThrow(/normalized and inventoried/u); + expect(() => + createHistoricalSourceGraph({ + ...graphInput(), + inventory_state: "partial", + entries: graphInput().entries.map((entry) => + entry.kind === "symlink" + ? { ...entry, target: "/etc/passwd", target_state: "external" } + : entry, + ), + }), + ).toThrow(); + expect(() => + createHistoricalSourceGraph({ + ...graphInput(), + inventory_state: "partial", + entries: graphInput().entries.map((entry) => + entry.kind === "symlink" + ? { + ...entry, + target: "", + target_state: "external", + } + : entry, + ), + }), + ).not.toThrow(); + expect(() => + createHistoricalSourceGraph({ + ...graphInput(), + relationships: [ + { ...graphInput().relationships[1]!, to: "../escape.ts" }, + ], + }), + ).toThrow(/normalized and inventoried/u); + expect(() => + historicalSourceGraphSchema.parse({ + ...createHistoricalSourceGraph(graphInput()), + extra: true, + }), + ).toThrow(); + + const manifest = createHistoricalSourceManifest( + createHistoricalSourceGraph(graphInput()), + ); + expect(() => + parseHistoricalSourceManifest({ ...manifest, entry_count: 999 }), + ).toThrow(/identifier does not match/u); + }); +}); diff --git a/tests/referenceSourceImport.test.ts b/tests/referenceSourceImport.test.ts new file mode 100644 index 000000000..900f527c0 --- /dev/null +++ b/tests/referenceSourceImport.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { add, commit, init } from "isomorphic-git"; +import { describe, expect, it } from "vitest"; + +import { importReferenceSource } from "../src/application/ReferenceSourceImport.js"; +import { createHistoricalSourceManifest } from "../src/domain/referenceSourceGraph.js"; + +const limits = { + maxBytes: 1024 * 1024, + maxEntries: 1_000, + maxDepth: 16, + maxPathBytes: 4_096, +}; + +const fixture = async (parent: string, name: string): Promise => { + const root = join(parent, name); + await mkdir(join(root, "src"), { recursive: true }); + await Promise.all([ + writeFile(join(root, "src", "main.ts"), 'import "./dep";\n'), + writeFile(join(root, "src", "dep.ts"), "export const value = 1;\n"), + writeFile(join(root, "src", "broken.ts"), "const = ;\n"), + writeFile(join(root, ".env"), "SECRET_SENTINEL=do-not-record\n"), + writeFile(join(root, "package.json"), '{"name":"fixture"}\n'), + ]); + return root; +}; + +const importTree = (root: string, approvedRoot: string, signal?: AbortSignal) => + importReferenceSource({ + root, + caller: "reference-import-test", + policy: { + roots: [approvedRoot], + secretPatterns: [".env", ".env.*"], + ...limits, + }, + limits, + ...(signal === undefined ? {} : { signal }), + }); + +describe("reference source import", () => { + it("is relocation-stable, resolves imports, and excludes secrets before capture", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-reference-import-")); + try { + const leftRoot = await fixture(parent, "left"); + const rightRoot = await fixture(parent, "right"); + const [left, right] = await Promise.all([ + importTree(leftRoot, parent), + importTree(rightRoot, parent), + ]); + expect(left.ok).toBe(true); + expect(right.ok).toBe(true); + if (!left.ok || !right.ok) throw new Error("expected imports to pass"); + expect(createHistoricalSourceManifest(left.value)).toEqual( + createHistoricalSourceManifest(right.value), + ); + expect(left.value.relationships).toContainEqual( + expect.objectContaining({ + from_path: "src/main.ts", + to: "src/dep.ts", + resolution: "internal", + }), + ); + expect(left.value.parse_failures).toHaveLength(1); + expect(left.value.exclusions).toContainEqual({ + path: ".env", + reason: "configured-secret", + }); + expect(JSON.stringify(left.value)).not.toContain("SECRET_SENTINEL"); + expect(JSON.stringify(left.value)).not.toContain(leftRoot); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); + + it("fails closed outside policy and honors cancellation", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-reference-policy-")); + const outside = await mkdtemp(join(tmpdir(), "rea-reference-outside-")); + try { + const root = await fixture(outside, "tree"); + const denied = await importTree(root, parent); + expect(denied).toMatchObject({ + ok: false, + error: { code: "policy" }, + }); + const controller = new AbortController(); + controller.abort(); + const cancelled = await importTree(root, outside, controller.signal); + expect(cancelled).toMatchObject({ + ok: false, + error: { code: "cancelled" }, + }); + } finally { + await Promise.all([ + rm(parent, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + } + }); + + it("records bounded local Git state without invoking Git", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-reference-git-")); + try { + const root = await fixture(parent, "repo"); + await init({ fs, dir: root, defaultBranch: "main" }); + for (const filepath of ["package.json", "src/main.ts", "src/dep.ts"]) + await add({ fs, dir: root, filepath }); + const oid = await commit({ + fs, + dir: root, + author: { name: "REA Test", email: "rea@example.invalid" }, + message: "fixture", + }); + const result = await importTree(root, parent); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: true }); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/referenceSourceReader.test.ts b/tests/referenceSourceReader.test.ts new file mode 100644 index 000000000..e1aa3746c --- /dev/null +++ b/tests/referenceSourceReader.test.ts @@ -0,0 +1,224 @@ +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { readReferenceSource } from "../src/reference/ReferenceSourceReader.js"; + +const limits = { + maxBytes: 1_024, + maxEntries: 10, + maxDepth: 4, + maxPathBytes: 100, +} as const; + +describe("readReferenceSource", () => { + it("returns explicit entries in canonical code-point path order", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-reference-")); + await mkdir(join(root, "nested")); + await writeFile(join(root, "z.js"), "z"); + await writeFile(join(root, "a.js"), "a"); + await writeFile(join(root, "nested", "b.js"), "b"); + await writeFile(join(root, "\u{e000}"), "p"); + await writeFile(join(root, "\u{10000}"), "a"); + + const result = await readReferenceSource(root, limits); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries.map(({ path }) => path)).toEqual([ + "a.js", + "nested", + "nested/b.js", + "z.js", + "", + "𐀀", + ]); + expect(result.value.bytesRead).toBe(5); + expect(result.value.truncated).toBe(false); + expect( + result.value.entries.map((entry) => + entry.status === "read" && entry.kind === "file" + ? Buffer.from(entry.bytes).toString() + : entry.status === "read" + ? entry.kind + : entry.code, + ), + ).toEqual(["a", "directory", "b", "z", "p", "a"]); + expect(result.value.limitations).toHaveLength(1); + }); + + it("sanitizes internal, external, and missing symlink targets", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-reference-")); + const outside = await mkdtemp(join(tmpdir(), "rea-outside-")); + await writeFile(join(outside, "secret"), "secret"); + await writeFile(join(root, "local"), "local"); + await symlink("local", join(root, "internal")); + await symlink(join(outside, "secret"), join(root, "external")); + await symlink("absent", join(root, "missing")); + + const result = await readReferenceSource(root, limits); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries).toEqual([ + { + status: "read", + kind: "symlink", + path: "external", + target: "", + targetState: "external", + }, + { + status: "read", + kind: "symlink", + path: "internal", + target: "local", + targetState: "internal", + }, + { + status: "read", + kind: "file", + path: "local", + bytes: expect.any(Uint8Array), + size: 5, + }, + { + status: "read", + kind: "symlink", + path: "missing", + target: "absent", + targetState: "missing", + }, + ]); + expect(result.value.bytesRead).toBe(5); + expect(JSON.stringify(result.value.entries)).not.toContain(outside); + }); + + it("makes byte, file, depth, and path limits explicit per entry", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-reference-")); + await writeFile(join(root, "a"), "too large"); + await writeFile(join(root, "b"), "b"); + await mkdir(join(root, "deep")); + await writeFile(join(root, "deep", "c"), "c"); + + const result = await readReferenceSource(root, { + maxBytes: 1, + maxEntries: 1, + maxDepth: 1, + maxPathBytes: 4, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.truncated).toBe(true); + expect(result.value.entries).toEqual([ + expect.objectContaining({ + path: "a", + kind: "file", + status: "failed", + code: "limit", + size: 9, + }), + ]); + expect(result.value.limitations).toContain( + "Traversal stopped because the entry limit was reached.", + ); + expect(result.value.entries).not.toContainEqual( + expect.objectContaining({ path: "" }), + ); + }); + + it("applies maxEntries as a global limit across every entry kind", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-reference-")); + await writeFile(join(root, "a"), "a"); + await writeFile(join(root, "b"), "b"); + await writeFile(join(root, "c"), "c"); + + const result = await readReferenceSource(root, { + ...limits, + maxEntries: 1, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.entries.map(({ path }) => path)).toEqual(["a"]); + expect(result.value.bytesRead).toBe(1); + expect(result.value.truncated).toBe(true); + }); + + it("applies exclusions to normalized paths before reading entries", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-reference-")); + await mkdir(join(root, "ignored")); + await writeFile(join(root, "ignored", "secret"), "secret"); + await writeFile(join(root, "kept"), "kept"); + const checked: string[] = []; + + const result = await readReferenceSource(root, limits, { + shouldExclude: (path) => { + checked.push(path); + return path === "ignored"; + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(checked).toEqual(["ignored", "kept"]); + expect(result.value.entries.map(({ path }) => path)).toEqual(["kept"]); + }); + + it("sanitizes exclusion callback failures", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-reference-")); + await writeFile(join(root, "file"), "value"); + const result = await readReferenceSource(root, limits, { + shouldExclude: () => { + throw new Error("private callback detail"); + }, + }); + + expect(result).toEqual({ + ok: false, + error: { + tag: "reference-source-reader", + code: "io", + message: "Reference source exclusion check failed", + }, + }); + }); + + it("sanitizes filesystem errors", async () => { + const missing = join(tmpdir(), "rea-secret-root-that-does-not-exist"); + const result = await readReferenceSource(missing, limits); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toBe( + "Reference source root could not be resolved", + ); + expect(result.error.message).not.toContain(missing); + }); + + it("returns typed failures for cancellation and invalid roots", async () => { + const controller = new AbortController(); + controller.abort(); + const cancelled = await readReferenceSource("/unused", limits, { + signal: controller.signal, + }); + expect(cancelled).toEqual({ + ok: false, + error: { + tag: "reference-source-reader", + code: "cancelled", + message: "Reference source traversal cancelled", + }, + }); + + const invalid = await readReferenceSource( + "/path/that/does/not/exist", + limits, + ); + expect(invalid.ok).toBe(false); + if (invalid.ok) return; + expect(invalid.error.code).toBe("invalid-root"); + }); +}); diff --git a/tests/residualUnknown.test.ts b/tests/residualUnknown.test.ts new file mode 100644 index 000000000..db3bf9f61 --- /dev/null +++ b/tests/residualUnknown.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; + +import { EvidenceLedger } from "../src/application/EvidenceLedger.js"; +import { createEvidence } from "../src/domain/evidence.js"; +import { + createEvidenceBundle, + parseEvidenceBundle, + serializeEvidenceBundle, +} from "../src/domain/evidenceBundle.js"; +import { + createResidualUnknown, + recordUnknownInputSchema, + updateUnknownInputSchema, + type RecordUnknownInput, + type ResidualUnknown, +} from "../src/domain/residualUnknown.js"; + +const provider = { id: "fixture", name: "Fixture", version: "1" }; +const ledger = (): EvidenceLedger => + new EvidenceLedger({ maxRecords: 1_000, maxBytes: 4 * 1024 * 1024 }); +const evidence = ( + label: string, + confidence: "observed" | "derived" | "inferred" = "derived", +) => + createEvidence(undefined, provider, { + operation: label, + parameters: {}, + result: { label }, + confidence, + authority: + confidence === "observed" ? "controlled-replay" : "analyst-inference", + environment: + confidence === "observed" + ? { + id: "fixture-linux", + platform: "linux", + architecture: "x86_64", + isolation: "process", + } + : null, + }); +const input = ( + question: string, + overrides: Partial = {}, +): RecordUnknownInput => + recordUnknownInputSchema.parse({ + approved: true, + question, + severity: "high", + domain: "protocol", + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + ...overrides, + }); +const mutation = (label: string) => evidence(`mutation-${label}`); +const update = ( + unknown: ResidualUnknown, + overrides: Readonly> = {}, +) => + updateUnknownInputSchema.parse({ + approved: true, + unknown_id: unknown.unknown_id, + expected_revision: unknown.revision, + status: "investigating", + severity: unknown.severity, + supporting_evidence_ids: unknown.supporting_evidence_ids, + contradicting_evidence_ids: unknown.contradicting_evidence_ids, + required_authority: unknown.required_authority, + required_confidence: unknown.required_confidence, + required_environment: unknown.required_environment, + recommended_probes: unknown.recommended_probes, + relationships: unknown.relationships, + resolution: null, + ...overrides, + }); + +describe("residual unknown registry", () => { + it("requires approval, derives stable IDs, filters heads, and rejects duplicate creation", () => { + expect(() => + recordUnknownInputSchema.parse({ + ...input("Is branch live?"), + approved: false, + }), + ).toThrow(); + const store = ledger(); + const first = store.recordUnknown( + input(" Is branch live? "), + mutation("one"), + ); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect(first.value.unknown_id).toMatch(/^unk_[a-f0-9]{64}$/u); + expect(first.value.revision).toBe(1); + expect(store.listUnknowns({ severity: "high" })).toHaveLength(1); + expect(store.listUnknowns({ status: "resolved" })).toEqual([]); + const duplicate = store.recordUnknown( + input("Is branch live?"), + mutation("two"), + ); + expect(duplicate.ok).toBe(false); + if (!duplicate.ok) + expect(duplicate.error._tag).toBe("UnknownRegistryError"); + const mutationId = `ev_${"a".repeat(64)}`; + const normalized = createResidualUnknown( + input("A scoped question"), + mutationId, + "0".repeat(64), + ); + expect( + createResidualUnknown( + input("A scoped question"), + mutationId, + "0".repeat(64), + ).unknown_id, + ).toBe(normalized.unknown_id); + expect( + createResidualUnknown( + input("A scoped question"), + mutationId, + "1".repeat(64), + ).unknown_id, + ).not.toBe(normalized.unknown_id); + }); + + it("uses immutable CAS revisions and rejects a stale concurrent writer atomically", () => { + const store = ledger(); + const created = store.recordUnknown( + input("Which codec wins?"), + mutation("create"), + ); + if (!created.ok) throw created.error; + const command = update(created.value); + const winner = store.updateUnknown(command, mutation("winner")); + expect(winner.ok).toBe(true); + const loser = store.updateUnknown(command, mutation("loser")); + expect(loser.ok).toBe(false); + if (!loser.ok) expect(loser.error.message).toContain("revision-conflict"); + expect(store.export().unknowns.map(({ revision }) => revision)).toEqual([ + 1, 2, + ]); + }); + + it("rejects inference for observed resolution, then accepts matching live evidence", () => { + const store = ledger(); + const inferred = evidence("inference", "inferred"); + expect(store.record(inferred).ok).toBe(true); + const created = store.recordUnknown( + input("Does replay confirm response?", { + supporting_evidence_ids: [inferred.evidence_id], + required_environment: { + id: "fixture-linux", + platform: "linux", + architecture: "x86_64", + isolation: "process", + }, + }), + mutation("create-resolution"), + ); + if (!created.ok) throw created.error; + const invalid = store.updateUnknown( + update(created.value, { + status: "resolved", + resolution: { + disposition: "verified", + rationale: "Inference alone is insufficient.", + evidence_ids: [inferred.evidence_id], + }, + }), + mutation("invalid-resolution"), + ); + expect(invalid.ok).toBe(false); + expect(store.listUnknowns()[0]?.revision).toBe(1); + + const observed = evidence("replay", "observed"); + expect(store.record(observed).ok).toBe(true); + const valid = store.updateUnknown( + update(created.value, { + status: "resolved", + supporting_evidence_ids: [inferred.evidence_id, observed.evidence_id], + resolution: { + disposition: "verified", + rationale: "Controlled replay directly observed response.", + evidence_ids: [observed.evidence_id], + }, + }), + mutation("valid-resolution"), + ); + expect(valid.ok).toBe(true); + if (valid.ok) + expect(store.verifyUnknownResolution(valid.value.unknown_id)).toEqual({ + ok: true, + value: { valid: true, truthVerified: true, unknown: valid.value }, + }); + }); + + it("rejects broken references, dependency cycles, deletion, and tampering", () => { + const store = ledger(); + const first = store.recordUnknown(input("First?"), mutation("first")); + if (!first.ok) throw first.error; + const second = store.recordUnknown( + input("Second?", { + relationships: [ + { type: "depends-on", unknown_id: first.value.unknown_id }, + ], + }), + mutation("second"), + ); + if (!second.ok) throw second.error; + const cycle = store.updateUnknown( + update(first.value, { + relationships: [ + { type: "depends-on", unknown_id: second.value.unknown_id }, + ], + }), + mutation("cycle"), + ); + expect(cycle.ok).toBe(false); + + const bundle = store.export(); + expect(parseEvidenceBundle(bundle)).toEqual(bundle); + expect(serializeEvidenceBundle(bundle)).toBe( + serializeEvidenceBundle( + createEvidenceBundle( + [...bundle.records].reverse(), + [...bundle.unknowns].reverse(), + ), + ), + ); + expect(() => + parseEvidenceBundle({ ...bundle, records: bundle.records.slice(1) }), + ).toThrow(/missing evidence/u); + expect(() => + parseEvidenceBundle({ + ...bundle, + unknowns: bundle.unknowns.map((unknown, index) => + index === 0 ? { ...unknown, question: "tampered" } : unknown, + ), + }), + ).toThrow(); + }); + + it("merges independent histories commutatively and rejects divergent heads atomically", () => { + const left = ledger(); + const right = ledger(); + expect(left.recordUnknown(input("Left?"), mutation("left")).ok).toBe(true); + expect(right.recordUnknown(input("Right?"), mutation("right")).ok).toBe( + true, + ); + const leftThenRight = ledger(); + const rightThenLeft = ledger(); + expect(leftThenRight.import(left.export()).ok).toBe(true); + expect(leftThenRight.import(right.export()).ok).toBe(true); + expect(rightThenLeft.import(right.export()).ok).toBe(true); + expect(rightThenLeft.import(left.export()).ok).toBe(true); + expect(serializeEvidenceBundle(leftThenRight.export())).toBe( + serializeEvidenceBundle(rightThenLeft.export()), + ); + + const base = ledger(); + const created = base.recordUnknown(input("Divergent?"), mutation("base")); + if (!created.ok) throw created.error; + const branch = ledger(); + expect(branch.import(base.export()).ok).toBe(true); + expect( + base.updateUnknown(update(created.value), mutation("branch-left")).ok, + ).toBe(true); + expect( + branch.updateUnknown( + update(created.value, { severity: "critical" }), + mutation("branch-right"), + ).ok, + ).toBe(true); + const before = serializeEvidenceBundle(base.export()); + expect(base.import(branch.export()).ok).toBe(false); + expect(serializeEvidenceBundle(base.export())).toBe(before); + + const bounded = new EvidenceLedger({ maxRecords: 1, maxBytes: 1_000_000 }); + expect( + bounded.recordUnknown(input("Too many?"), mutation("bounded")).ok, + ).toBe(false); + expect(bounded.export()).toMatchObject({ records: [], unknowns: [] }); + }); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index a1d7dc798..e8e0bb475 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -10,7 +10,7 @@ const fixturePath = fileURLToPath( ); describe("production stdio runtime", () => { - it("starts the built entrypoint, lists 50 tools, calls one, and shuts down", async () => { + it("starts the built entrypoint, lists 68 tools, calls one, and shuts down", async () => { const transport = new StdioClientTransport({ command: process.execPath, args: [mainPath], @@ -32,7 +32,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); const tools = await client.listTools(); - expect(tools.tools).toHaveLength(50); + expect(tools.tools).toHaveLength(68); const result = await client.callTool({ name: "current_document", arguments: {}, @@ -58,7 +58,7 @@ describe("production stdio runtime", () => { ); expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON"); expect(stderr).not.toContain(fixturePath); - }); + }, 20_000); it("honors the configured kind for an initial database target", async () => { const transport = new StdioClientTransport({ @@ -78,7 +78,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); - expect((await client.listTools()).tools).toHaveLength(50); + expect((await client.listTools()).tools).toHaveLength(68); } finally { await client.close(); await transport.close(); diff --git a/tests/safeOutputTree.test.ts b/tests/safeOutputTree.test.ts new file mode 100644 index 000000000..9333bfb0b --- /dev/null +++ b/tests/safeOutputTree.test.ts @@ -0,0 +1,39 @@ +import { access, mkdtemp, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; + +import { describe, expect, it } from "vitest"; + +import { SafeOutputTree } from "../src/artifacts/SafeOutputTree.js"; + +const LIMITS = { + maxEntries: 10, + maxTotalBytes: 1_024, + maxEntryBytes: 512, + maxCompressionRatio: 10, + maxDepth: 8, + maxPathBytes: 256, +} as const; + +describe("safe artifact output transaction", () => { + it("removes only its owned tree after digest failure and proves absence", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-")); + const output = join(parent, "published"); + const transaction = await SafeOutputTree.create(output, LIMITS); + await expect( + transaction.write( + "nested/file.txt", + Readable.from(Buffer.from("unexpected")), + "0".repeat(64), + ), + ).rejects.toThrow(/disagrees/u); + expect(await transaction.rollback()).toMatchObject({ + attempted: true, + verified: true, + residualPaths: [], + }); + await expect(access(output)).rejects.toThrow(); + expect(await readdir(parent)).toEqual([]); + }); +}); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index d3333ce54..79cbaec08 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -8,9 +8,10 @@ import { z } from "zod"; import { BinarySession } from "../src/application/BinarySession.js"; import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; -import { ok } from "../src/domain/result.js"; +import { observed as ok } from "./fixtures/analysisExecution.js"; import { createServer } from "../src/server/createServer.js"; import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js"; +import { silentLogger } from "../src/logger.js"; const resources: Array<{ close(): Promise }> = []; let directory: string | undefined; @@ -30,7 +31,16 @@ describe("target-free MCP lifecycle", () => { await writeFile(second, "two"); const closed: string[] = []; const session = new BinarySession((target) => client(target.path, closed)); - const server = createServer(session, session); + const server = createServer(session, session, { + logger: silentLogger, + evidenceFilePolicy: { + roots: [directory], + maxBytes: 1024 * 1024, + maxDepth: 68, + maxStringLength: 1024, + maxNodes: 10_000, + }, + }); const mcp = new Client({ name: "session-test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -44,7 +54,7 @@ describe("target-free MCP lifecycle", () => { }); expect(before.isError).toBe(true); expect(text(before)).toContain("NoBinaryOpenError"); - expect((await mcp.listTools()).tools).toHaveLength(50); + expect((await mcp.listTools()).tools).toHaveLength(68); const deniedCapture = await mcp.callTool({ name: "capture_process_scenario", arguments: { @@ -68,14 +78,89 @@ describe("target-free MCP lifecycle", () => { }); const bundle = evidenceBundleSchema.parse(structured(exported).result); expect(bundle.records).toHaveLength(1); + const evidencePath = join(directory, "evidence.json"); + expect( + structured( + await mcp.callTool({ + name: "export_evidence_bundle", + arguments: { path: evidencePath }, + }), + ).result, + ).toMatchObject({ path: evidencePath, records: 1 }); expect( structured( await mcp.callTool({ name: "import_evidence_bundle", - arguments: { bundle }, + arguments: { path: evidencePath }, }), ).result, ).toEqual({ imported: 0, total: 1 }); + const recordedUnknown = z + .object({ + result: z.object({ + unknown_id: z.string(), + revision: z.number(), + }), + }) + .parse( + structured( + await mcp.callTool({ + name: "record_unknown", + arguments: { + approved: true, + question: "Does the alternate branch execute?", + severity: "medium", + domain: "control-flow", + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }, + }), + ), + ).result; + expect(recordedUnknown.revision).toBe(1); + expect( + z + .object({ result: z.array(z.unknown()) }) + .parse( + structured( + await mcp.callTool({ name: "list_unknowns", arguments: {} }), + ), + ).result, + ).toHaveLength(1); + const resolved = await mcp.callTool({ + name: "update_unknown", + arguments: { + approved: true, + unknown_id: recordedUnknown.unknown_id, + expected_revision: 1, + status: "resolved", + severity: "medium", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "controlled-replay", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + resolution: { + disposition: "out-of-scope", + rationale: "Operator explicitly excluded this branch from scope.", + evidence_ids: [], + }, + }, + }); + expect(resolved.isError).not.toBe(true); + expect( + structured( + await mcp.callTool({ + name: "verify_unknown_resolution", + arguments: { unknown_id: recordedUnknown.unknown_id }, + }), + ).result, + ).toMatchObject({ valid: true, truthVerified: false }); await mcp.callTool({ name: "open_binary", arguments: { path: second } }); expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true); expect( @@ -85,6 +170,53 @@ describe("target-free MCP lifecycle", () => { expect( text(await mcp.callTool({ name: "binary_session", arguments: {} })), ).toContain('"open": false'); + }, 20_000); + + it("records approved process residuals in the unknown registry", async () => { + const session = new BinarySession(() => client("fixture", [])); + const server = createServer(session, session, { + logger: silentLogger, + processPolicy: { + enabled: true, + executableRoots: ["/usr/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: true, + }, + }); + const mcp = new Client({ name: "process-unknown", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + + const captured = await mcp.callTool({ + name: "capture_process_scenario", + arguments: { + approved: true, + unknown_registry_approved: true, + executable: "/usr/bin/printf", + arguments: ["abcdef"], + working_directory: "/tmp", + limits: { output_bytes: 1 }, + }, + }); + expect(captured.isError).not.toBe(true); + const listed = z + .object({ + result: z.array(z.object({ question: z.string(), domain: z.string() })), + }) + .parse( + structured( + await mcp.callTool({ name: "list_unknowns", arguments: {} }), + ), + ).result; + expect(listed).toContainEqual({ + question: + "network: External network isolation is not enforced by this adapter.", + domain: "process-network", + }); }); }); diff --git a/tests/setupSkill.test.ts b/tests/setupSkill.test.ts new file mode 100644 index 000000000..97c981272 --- /dev/null +++ b/tests/setupSkill.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { installCanonicalSkill } from "../src/application/Setup.js"; +import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true })), + ); +}); + +describe("canonical skill transaction", () => { + it("backs up and upgrades a stale managed skill without touching siblings", async () => { + const home = await mkdtemp(join(tmpdir(), "rea-skill-test-")); + roots.push(home); + const destination = join(home, ".agents/skills/rea-analysis/SKILL.md"); + const sibling = join(home, ".agents/skills/unrelated/SKILL.md"); + await mkdir(dirname(destination), { recursive: true }); + await mkdir(dirname(sibling), { recursive: true }); + await writeFile(destination, "stale managed skill\n"); + await writeFile(sibling, "unrelated skill\n"); + + expect(await installCanonicalSkill(home)).toBe("installed"); + expect(await readFile(`${destination}.rea.backup`, "utf8")).toBe( + "stale managed skill\n", + ); + expect(await readFile(destination, "utf8")).toContain('version: "10"'); + expect(await readFile(destination, "utf8")).toContain( + `tool_count: ${String(TOOL_CONTRACTS.length)}`, + ); + expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n"); + expect(await installCanonicalSkill(home)).toBe("unchanged"); + }); +}); diff --git a/tests/staticRuntimeCorrelation.test.ts b/tests/staticRuntimeCorrelation.test.ts new file mode 100644 index 000000000..d4b5016db --- /dev/null +++ b/tests/staticRuntimeCorrelation.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; + +import { INVESTIGATION_EXAMPLES } from "../src/contracts/investigationExamples.js"; +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import type { JsonValue } from "../src/domain/jsonValue.js"; +import { + correlateStaticAndRuntime, + staticRuntimeCorrelationResultSchema, +} from "../src/domain/staticRuntimeCorrelation.js"; + +const staticComparison = + INVESTIGATION_EXAMPLES.find_changed_behavior.comparisons[0]; +if (staticComparison === undefined) + throw new Error("Missing static comparison fixture"); + +const capture = (digit: string): Evidence => + createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + predicateType: "rea.process-capture/v2", + operation: "capture_process_scenario", + parameters: {}, + result: { digit }, + authority: "controlled-replay", + environment: { + id: `fixture-${digit}`, + platform: "test", + architecture: "test", + isolation: "process", + }, + }, + ); + +const leftCapture = capture("1"); +const rightCapture = capture("2"); + +const processComparison = ( + terminal: "unchanged" | "changed" | "unknown" | "truncated", +): Evidence => { + const result = { + status: terminal, + terminal, + exit: terminal === "truncated" ? "truncated" : "unchanged", + filesystem: terminal === "truncated" ? "truncated" : "unchanged", + protocol: terminal === "truncated" ? "truncated" : "unchanged", + process: terminal === "truncated" ? "truncated" : "unchanged", + limitations: terminal === "unknown" ? ["Terminal unavailable."] : [], + } satisfies JsonValue; + return createEvidence( + undefined, + { + id: "rea-process", + name: "REA deterministic process harness", + version: "1", + }, + { + predicateType: "rea.process-comparison/v1", + operation: "compare_process_captures", + parameters: { + left_evidence_id: leftCapture.evidence_id, + right_evidence_id: rightCapture.evidence_id, + }, + result, + confidence: "derived", + authority: "analyst-inference", + evidenceLinks: [leftCapture.evidence_id, rightCapture.evidence_id], + }, + ); +}; + +const mapping = ( + runtime: Evidence, + expected: "cochanged" | "static_only" | "runtime_only" | "both_unchanged", + dimension: "terminal" | "exit" = "terminal", +) => ({ + static_comparisons: [staticComparison], + runtime_comparisons: [runtime], + mappings: [ + { + static: { + comparison_evidence_id: staticComparison.evidence_id, + selector: { + kind: "function" as const, + dimension: "pseudocode" as const, + }, + }, + runtime: { + comparison_evidence_id: runtime.evidence_id, + dimension, + }, + side_alignment: "left_to_left" as const, + hypothesis: { + statement: "The static edit is associated with terminal behavior.", + expected_pattern: expected, + }, + }, + ], +}); + +describe("static/runtime correlation", () => { + it("keeps consistent cochange as a hypothesis with complete citations", () => { + const runtime = processComparison("changed"); + const result = correlateStaticAndRuntime(mapping(runtime, "cochanged")); + expect(staticRuntimeCorrelationResultSchema.parse(result)).toMatchObject({ + status: "correlated", + summary: { hypotheses: 1, contradictions: 0, unresolved: 0 }, + correlations: { + total: 1, + items: [ + { + observed_pattern: "cochanged", + classification: "hypothesis", + side_alignment: "left_to_left", + }, + ], + }, + }); + expect(result.correlations.items[0]?.evidence_links).toEqual( + expect.arrayContaining([ + staticComparison.evidence_id, + runtime.evidence_id, + leftCapture.evidence_id, + rightCapture.evidence_id, + ]), + ); + expect(result.limitations.join(" ")).toMatch( + /does not establish causality/u, + ); + }); + + it("classifies an explicit expectation mismatch as contradiction", () => { + const result = correlateStaticAndRuntime( + mapping(processComparison("unchanged"), "cochanged"), + ); + expect(result).toMatchObject({ + status: "contradicted", + summary: { hypotheses: 0, contradictions: 1, unresolved: 0 }, + correlations: { + items: [ + { observed_pattern: "static_only", classification: "contradiction" }, + ], + }, + }); + }); + + it("lets unknown and truncation dominate rather than imply equivalence", () => { + const unknown = correlateStaticAndRuntime( + mapping(processComparison("unknown"), "cochanged"), + ); + const truncated = correlateStaticAndRuntime( + mapping(processComparison("truncated"), "cochanged"), + ); + expect(unknown).toMatchObject({ + status: "unknown", + correlations: { items: [{ classification: "unresolved_branch" }] }, + }); + expect(truncated).toMatchObject({ + status: "truncated", + correlations: { items: [{ observed_pattern: "truncated" }] }, + }); + }); + + it("is order-stable, paginated, and rejects duplicates and absent selectors", () => { + const runtime = processComparison("changed"); + const base = mapping(runtime, "cochanged"); + const second = { + ...base.mappings[0], + runtime: { ...base.mappings[0]!.runtime, dimension: "exit" as const }, + hypothesis: { + statement: "The edit is associated with exit behavior.", + expected_pattern: "static_only" as const, + }, + }; + const forward = correlateStaticAndRuntime({ + ...base, + mappings: [base.mappings[0], second], + offset: 0, + limit: 1, + }); + const reversed = correlateStaticAndRuntime({ + ...base, + mappings: [second, base.mappings[0]], + offset: 0, + limit: 1, + }); + expect(reversed).toEqual(forward); + expect(forward.correlations).toMatchObject({ total: 2, next_offset: 1 }); + expect(() => + correlateStaticAndRuntime({ + ...base, + mappings: [base.mappings[0], base.mappings[0]], + }), + ).toThrow(/duplicate mappings/u); + expect(() => + correlateStaticAndRuntime({ + ...base, + mappings: [ + { + ...base.mappings[0], + static: { + ...base.mappings[0]!.static, + selector: { kind: "function", dimension: "assembly" }, + }, + }, + ], + }), + ).not.toThrow(); + expect(() => + correlateStaticAndRuntime({ + ...base, + mappings: [ + { + ...base.mappings[0], + static: { + comparison_evidence_id: runtime.evidence_id, + selector: { kind: "function", dimension: "pseudocode" }, + }, + }, + ], + }), + ).toThrow(/absent static comparison/u); + }); + + it("rejects comparison citations that disagree with source parameters", () => { + const runtime = processComparison("changed"); + const forged = createEvidence(undefined, runtime.provider, { + predicateType: runtime.predicate_type, + operation: runtime.operation, + parameters: { + left_evidence_id: leftCapture.evidence_id, + right_evidence_id: staticComparison.evidence_id, + }, + result: runtime.normalized_result, + confidence: runtime.confidence, + authority: runtime.authority, + evidenceLinks: runtime.evidence_links, + }); + const input = mapping(forged, "cochanged"); + expect(() => correlateStaticAndRuntime(input)).toThrow( + /closure disagrees with its source parameters/u, + ); + }); +}); diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index aabaae799..d0ff3f8e0 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -6,11 +6,15 @@ import { SESSION_TOOL_CONTRACTS, TOOL_CONTRACTS, } from "../src/contracts/toolContracts.js"; +import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; describe("tool contract inventory", () => { - it("publishes 43 analysis contracts and seven session tools", () => { + it("publishes 50 analysis contracts and eighteen session tools", () => { expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(33); expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(10); + expect(NATIVE_TOOL_CONTRACTS).toHaveLength(5); + expect(ARTIFACT_TOOL_CONTRACTS).toHaveLength(2); expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([ "open_binary", "close_binary", @@ -19,9 +23,20 @@ describe("tool contract inventory", () => { "import_evidence_bundle", "capture_process_scenario", "compare_process_captures", + "compare_artifacts", + "compare_functions", + "compare_bundles", + "find_changed_behavior", + "build_call_path", + "correlate_static_and_runtime", + "verify_reconstruction", + "list_unknowns", + "record_unknown", + "update_unknown", + "verify_unknown_resolution", ]); - expect(TOOL_CONTRACTS).toHaveLength(50); - expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(50); + expect(TOOL_CONTRACTS).toHaveLength(68); + expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(68); }); it("retains documented enhanced-tool limits at the input boundary", () => { diff --git a/tests/toolResult.test.ts b/tests/toolResult.test.ts new file mode 100644 index 000000000..cffd8e73a --- /dev/null +++ b/tests/toolResult.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { ToolContract } from "../src/contracts/toolContracts.js"; +import { ok } from "../src/domain/result.js"; +import { toCallToolResult } from "../src/server/toolResult.js"; + +const contract: ToolContract = { + name: "provider_neutral_fixture", + description: "Fixture contract for provider-neutral output validation.", + kind: "enhanced", + inputSchema: z.object({}), + outputSchema: z.object({ value: z.string() }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + examples: [{ title: "Example fixture request", input: {} }], +}; + +describe("tool result projection", () => { + it("classifies output contract failures without naming a provider", () => { + expect(toCallToolResult(ok({ value: 42 }), contract)).toEqual({ + content: [ + { + type: "text", + text: "AnalysisOutputError: Analysis output does not match the tool contract", + }, + ], + isError: true, + }); + }); +}); From 567ddc3f4d02e283bb62f2e3a52b1232f4be4a66 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:14:04 +0000 Subject: [PATCH 2/8] Refresh completion metadata --- metadata/completion-manifest.json | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json index cc237cdb1..cae1f5f43 100644 --- a/metadata/completion-manifest.json +++ b/metadata/completion-manifest.json @@ -114,10 +114,6 @@ "path": "src/contracts/processCaptureExample.ts", "sha256": "68e52c7378b9b7da34cb31a93eebd44f6425ea40971b589f0399afefbc5709b3" }, - { - "path": "src/contracts/referenceSourceContracts.ts", - "sha256": "5f6a75bb335267a788b9977b253f788a42c3a5b65947f06b532096b1cfda1c27" - }, { "path": "src/contracts/enhancedInputs.ts", "sha256": "76bc9bc963a7a65800fe4ecbfec17c2f57410a42c116b6f5fe65a3b033fdb29e" @@ -184,7 +180,7 @@ }, { "path": "src/domain/reconstructionVerification.ts", - "sha256": "4ed0b8b06d5c8b3a10d56acef222fbe2a615b0277eed7997f158e16d8e108e8d" + "sha256": "b8dcc08f19e4bbcecda80a0ee92cd02add105052b93956026b25200049cc5ebd" }, { "path": "src/domain/reconstructionVerificationSchemas.ts", @@ -196,15 +192,15 @@ }, { "path": "src/domain/referenceSourceGraph.ts", - "sha256": "c736fd9d85c5ab145ea56d3b20442ec19f2477eb09563b9b6c3b287e88cd40a0" + "sha256": "394048ce022159ac67ca5811303b1e221e546d4364d5d0102230e5b69d88c166" }, { "path": "src/domain/referenceSourceClassification.ts", - "sha256": "9ad269b9ba0a0abe13c157d40d219c7cc461aa2c8b99a71baad62b42f03e5741" + "sha256": "63e3cd3cb2856fe4c85c5e46160a417b9f9bf4a58997984fcfea1c898532d58e" }, { "path": "src/domain/referenceSourceImportParsing.ts", - "sha256": "ef2a7246ecc75672324f745cba4dbe7f941d5186f7936d8724611590c9e014e9" + "sha256": "ebdfd576d9b1a151424c633c588309a8c1cc47d5247380b2a5956c636e147aa7" }, { "path": "src/domain/referenceSourcePolicy.ts", @@ -224,7 +220,7 @@ }, { "path": "src/domain/processCapture.ts", - "sha256": "ef003dfd187a7963e7e9a3362125a75b44cea3bbc2ba3fdc69ea23d4b46b05dd" + "sha256": "cfdbac456aa868d06c50a0b410c181ed19892521f2d53d8bb254fcc1b92bbb3f" }, { "path": "src/domain/residualUnknown.ts", @@ -372,11 +368,11 @@ }, { "path": "src/reference/ReferenceSourceReaderValidate.ts", - "sha256": "2deb2a5ea85a440c6aec5dd6b647086d9c5cdb98cf6628e8aefe214227ca1643" + "sha256": "e14a620a4e5fa350635158c584989707d877dc25f9da793cec1d82e7667f85fe" }, { "path": "src/reference/ReferenceSourceReader.ts", - "sha256": "fad544b78324dcbb66dd9cd67bfd0050c8466f958ae4569377a74132bab5fdd3" + "sha256": "75c83b5c233a4ceda4233e33a19f7e421796623d337cf3ac737e6a94d0126bdc" } ], "package": { From 77278e5e56f7f5b68839abe0846cbba373f6d7ed Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:15:50 +0000 Subject: [PATCH 3/8] Align completion metadata with the canonical skill --- metadata/completion-manifest.json | 6 +-- skills/rea-analysis/SKILL.md | 75 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json index cae1f5f43..3286da2b7 100644 --- a/metadata/completion-manifest.json +++ b/metadata/completion-manifest.json @@ -24,7 +24,7 @@ }, { "path": "skills/rea-analysis/SKILL.md", - "sha256": "d5a3a5329e7db590a06f5b0b1af6551704579b78711d9c6a229715b5a832906e" + "sha256": "a33b0eeb8dad412249d11c696c712390e3da29b2f73d49dd94c424ac7438a8e7" }, { "path": "src/application/AnalysisProvider.ts", @@ -192,11 +192,11 @@ }, { "path": "src/domain/referenceSourceGraph.ts", - "sha256": "394048ce022159ac67ca5811303b1e221e546d4364d5d0102230e5b69d88c166" + "sha256": "4900f382215ad95e70ece8efe25f1ebe3b978fd262da48253a1808a779a93724" }, { "path": "src/domain/referenceSourceClassification.ts", - "sha256": "63e3cd3cb2856fe4c85c5e46160a417b9f9bf4a58997984fcfea1c898532d58e" + "sha256": "bc2876db3b58dbd624561808be25d2bf666aed9e6278994d5f9a74c92a83c898" }, { "path": "src/domain/referenceSourceImportParsing.ts", diff --git a/skills/rea-analysis/SKILL.md b/skills/rea-analysis/SKILL.md index d4c1bec59..f316fe0da 100644 --- a/skills/rea-analysis/SKILL.md +++ b/skills/rea-analysis/SKILL.md @@ -1,6 +1,9 @@ --- name: rea-analysis description: Reverse engineer apps with REA. Explore how features work, then build a version tailored to your project. +metadata: + version: "10" + tool_count: 68 --- # REA @@ -33,8 +36,80 @@ REA accepts a `.app` bundle directly. Do not expose its internal `Contents/MacOS Briefly tell the user what you will investigate. Open the app with `open_binary`, begin with `binary_overview`, and narrow the investigation around the requested feature. Use decompilation, strings, names, callers, callees, and cross-references as needed. +For applications, ZIP/APK/IPA packages, or Electron ASAR archives, call +`inventory_artifact` before extraction. Follow its deterministic occurrence +pages and cite graph manifest IDs. `extract_artifact` requires explicit user +approval, an absent absolute output root, and selected occurrence IDs; never +extract every entry implicitly. Symlinks and encrypted entries are inventory +facts, not extractable files. Nested containers require a separate deliberate +inventory step. + Explain conclusions in plain language. Point to the relevant decompiled code, strings, names, and connections so the user can see how the explanation was reached. Do not claim to recover original source code or automatically clone an application. +Search uses bounded deterministic pages. Prefer literal mode; use regex mode only +when regex semantics are needed, and continue from `next_offset` while +`has_more` is true. Treat nullable permissions and explicit unavailable metadata +as unknown, never as `false`. + +Every successful analysis result is Evidence v2. Cite evidence IDs and preserve +limitations and residual unknowns. Process capture is disabled by default, +requires per-call approval plus operator policy, and uses host networking only +when the operator explicitly permits it. It is behavioral evidence, not a +security sandbox. + +Do not let unanswered questions disappear. Use `record_unknown` only with +explicit approval, attach supporting and contradicting evidence IDs, and record +the authority/environment still required. Use `update_unknown` with the current +`expected_revision`; stale updates must be re-read, not retried blindly. A +verified resolution needs qualifying observed evidence. Inference, withdrawn, +and out-of-scope dispositions are never substitutes for observed behavior. +Set `unknown_registry_approved: true` on `trace_feature` or +`capture_process_scenario` only when the user approves durable automatic +recording of their bounded residuals. The same flag on a direct operation +records typed provider unavailability, and on `compare_process_captures` +records an observed disagreement as a contradiction. + +Use `compare_artifacts` with one record or bounded arrays of +`inventory_artifact` Evidence pages per side. Pages must share one manifest; +collect all node, occurrence, and edge pages for exhaustive comparison. It +compares stable occurrence paths, content, metadata, and graph relations, +cites both evidence sets on every delta, and paginates changes. Incomplete +inventories are truncated or unknown, never equivalent. Set +`unknown_registry_approved: true` only with approval to preserve the +disagreement or missing evidence as a residual unknown. + +Use `compare_functions` with explicit `analyze_function` Evidence page sets; +it does not perform fuzzy whole-binary matching. Collect every pseudocode, +assembly, collection, and CFG page when exhaustive comparison matters. +Absolute addresses are volatile only in CFG topology; pseudocode constants are +never stripped. Omitted assembly, truncated scans, unavailable reference kinds, +and cross-provider text remain unknown rather than equal. + +Use `compare_bundles` for canonical Evidence v2 bundle membership and +residual-unknown history changes. Pair cross-version observations explicitly; +the tool never guesses record identity. One-sided records prove only bundle +inclusion or omission, not behavioral absence. Use the returned canonical +bundle digests to anchor paginated reports. + +Use `find_changed_behavior` to combine existing comparison Evidence. Runtime +process differences are observed changes; artifact and function differences +remain static candidates, not causal proof. Supply complete comparison pages. + +Use `build_call_path` with explicit `analyze_function` Evidence groups from one +artifact and provider. Select endpoints by exact address. Missing dossiers, +incomplete callee pages, or a depth frontier make absence unknown; found paths +remain valid and cite each contributing dossier. + +Use `correlate_static_and_runtime` only with explicit mappings between exact +static and runtime comparison findings. A matching pattern is a hypothesis, +never proof of causality. Declare side alignment; unmapped similarities are not +correlated. + +Use `verify_reconstruction` with a finite typed specification and canonical +Evidence bundle. Pass means all declared claims passed with comparable +authority, not global implementation equivalence. Missing, limited, active- +unknown, or incompatible evidence stays unknown; observed differences fail. + ## Build When requested, use normal coding tools to build a version suited to the user's project, stack, interface, and requirements. Keep the implementation tied to what the investigation established, and distinguish observed behavior from assumptions or design choices. From 7a964bee64cfc1221d2edf7bf20832b359bb35d0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:16:53 +0000 Subject: [PATCH 4/8] Refresh canonical skill hash --- metadata/completion-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json index 3286da2b7..466771386 100644 --- a/metadata/completion-manifest.json +++ b/metadata/completion-manifest.json @@ -24,7 +24,7 @@ }, { "path": "skills/rea-analysis/SKILL.md", - "sha256": "a33b0eeb8dad412249d11c696c712390e3da29b2f73d49dd94c424ac7438a8e7" + "sha256": "d5a3a5329e7db590a06f5b0b1af6551704579b78711d9c6a229715b5a832906e" }, { "path": "src/application/AnalysisProvider.ts", From babe8b9e3be152d1b58bfa899505c35fd67df043 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:20:39 +0000 Subject: [PATCH 5/8] Refresh metadata after main merge --- metadata/completion-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json index 466771386..db0be3e64 100644 --- a/metadata/completion-manifest.json +++ b/metadata/completion-manifest.json @@ -52,7 +52,7 @@ }, { "path": "src/application/DirectAnalysis.ts", - "sha256": "0d986965684d024bb51446d64998b996c16aa26a919e4903ce09070c533be5f1" + "sha256": "2422397e8670bd6807f1c28c4ede264cdb6c9cfd089e3ae8fae2e3f19f7da4c0" }, { "path": "src/application/ProcessSampling.ts", From 77db2ecc1c812a0f814135afceaeb0e99a9053ce Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:22:47 +0000 Subject: [PATCH 6/8] Document the 68-tool contract --- README.md | 59 ++++++++++++++++++++++++++++++++++++++++++---------- README_ar.md | 12 ++++++----- README_ja.md | 18 +++++++++------- README_ko.md | 18 +++++++++------- README_zh.md | 18 +++++++++------- 5 files changed, 85 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index aae5db2f2..708d32655 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-tools-for-investigation) +[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-tools-for-investigation) [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [50 tools](#50-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works) +[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [68 tools](#68-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
@@ -104,6 +104,14 @@ If macOS or an installer asks for confirmation, complete the prompt and run the If process capture reports that its native PTY backend is unavailable, install Xcode command-line tools and run `npm run rebuild:native`. Linux source builds require Python, `make`, and a C++ toolchain. Compatible packaged binaries do not require this rebuild. +Process capture is disabled by default. Enabling it requires +`REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in +`REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an +environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. The current PTY adapter +uses host networking, so it additionally requires the explicit operator grant +`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`. Without that grant the adapter +refuses to launch; loopback replay alone does not constitute network isolation. + You do not need to install the reverse-engineering tools manually. Setup installs Homebrew and [Hopper](https://www.hopperapp.com/) when needed, configures detected Claude Desktop and Cursor installations, and installs the REA skill. Hopper is separate software and requires its own license; setup installs it but does not provide a license. If something is not working, run: @@ -114,10 +122,23 @@ npx -y rea-agents doctor ### CLI or coding agent? -| If you want to… | Use | -| --------------------------------------------------------- | ------------------------------------------ | -| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent | -| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` | +| If you want to… | Use | +| --------------------------------------------------------- | ---------------------------------------------- | +| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent | +| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` | +| Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` | + +Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots: + +```bash +export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]' +rea evidence-import /absolute/path/to/evidence/bundle.json +rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json +``` + +Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence v2 ID and manifest, and never execute bundle content. + +Completion metadata is verifier-owned. Run `npm run completion:generate` after changing contracts, schemas, providers, scenarios, package metadata, verifier inputs, or the managed skill. CI runs `npm run completion:check`; it detects stale or tampered hashes and never rewrites a branch. Unsupported and unknown outcomes remain separate from passes, and a pass or failure requires indexed Evidence v2 IDs. ## One prompt, a full investigation @@ -151,26 +172,38 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its - Analyze Swift and Objective-C metadata without manually untangling every mangled symbol. - Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other. -## 50 tools for investigation +## 68 tools for investigation | Tool family | Count | Examples | | ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------- | | Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations | | Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery | -| Workspace and observation | 7 | target lifecycle, Evidence v2 bundle import/export, deterministic process capture and comparison | +| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing | +| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected transactional extraction | +| Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle | -The public interface describes what the agent is trying to learn. Providers decide how to answer. Hopper currently implements the native-analysis capabilities; the process harness implements controlled behavioral capture. Future providers can satisfy the same capability without changing the agent's investigation workflow. +The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture. ## Current status REA is already useful for native application investigation on macOS: -- Open Mach-O, ELF, PE, `.app`, and Hopper database targets. +- Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets. +- Traverse content-addressed artifact graphs without extraction; materialize only approved occurrences into absent output roots. - Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names. - Search and trace features across symbols, strings, metadata, references, and call paths. - Record every successful result as deterministic Evidence v2 with artifact and provider identity, confidence, authority, limitations, and locations. - Export and import evidence bundles across sessions. - Capture approved PTY scenarios, child processes, filesystem changes, and loopback HTTP/WebSocket exchanges, then compare normalized captures. +- Compare complete artifact inventories by stable path, content, metadata, and relations; incomplete evidence never implies equivalence. +- Compare explicit function dossiers across text, calls, references, strings, and address-normalized CFG topology with per-facet unknowns. +- Compare canonical Evidence bundles by exact membership, explicit observation pairs, and residual-unknown histories without turning omissions into behavioral absence. +- Aggregate runtime comparisons into observed behavior changes while keeping static artifact/function differences labeled as candidates. +- Build bounded, Evidence-cited direct call paths by exact address without treating missing dossiers as graph leaves. +- Correlate exact static/runtime findings through explicit hypotheses without claiming causality from cochange. +- Verify finite behavioral and structural reconstruction specifications with pass, fail, and unknown kept distinct. +- Track residual unknowns through immutable CAS revisions, evidence-qualified resolution, contradictions, probes, and validated dependency relationships. +- With explicit `unknown_registry_approved: true`, record bounded trace/capture residuals, typed provider unavailability, and capture disagreements automatically. Hopper is the first provider, not the boundary of the project. Some current workflows still require Hopper and macOS; every evidence record identifies the provider and limitations behind its result. @@ -215,10 +248,14 @@ flowchart LR REA --> Workspace["Investigation workspace
evidence + artifacts + captures"] Workspace --> Router["Capability router"] Router --> Hopper["Hopper provider"] + Router --> Native["Native macOS provider"] + Router --> Artifact["Artifact graph provider"] Router --> Process["Process capture provider"] - Router -. roadmap .-> More["Artifact, browser, dynamic,
and additional static providers"] + Router -. roadmap .-> More["Browser, dynamic,
and additional static providers"] Hopper --> Target["Target software"] Process --> Target + Native --> Target + Artifact --> Target ``` The CLI and MCP server use the same application workflows and evidence contracts. A provider declares which capabilities it supports and the side effects those capabilities may have. Terminal commands are short-lived; an MCP session can retain an active target and evidence ledger across an investigation. diff --git a/README_ar.md b/README_ar.md index cabf5b3d6..836f85cd0 100644 --- a/README_ar.md +++ b/README_ar.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#منصة-من-50-أداة) +[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#منصة-من-68-أداة) [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [50 أداة](#منصة-من-50-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة) +[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [68 أداة](#منصة-من-68-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
@@ -145,13 +145,15 @@ npx -y rea-agents doctor افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة. -## منصة من 50 أداة +## منصة من 68 أداة | عائلة الأدوات | العدد | أمثلة | | --------------------- | ----: | ----------------------------------------------------------------------------------- | | فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات | | التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | -| جلسة الملف التنفيذي | 7 | فتح الأهداف وحزم الأدلة والتقاط العمليات ومقارنتها | +| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper | +| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة | +| جلسة الملف التنفيذي | 18 | فتح الأهداف وحزم الأدلة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية | ## استخدام REA مع وكلاء برمجة آخرين @@ -200,7 +202,7 @@ npx -y rea-agents analyze /Applications/Notes.app لتجنب مربع اختيار معمارية ملف شامل، حدد معطيات المحمل مسبقًا: ```bash -export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch64"]' +export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch68"]' ``` يدير REA دورة حياة Hopper ويحاول إغلاق العمليات التي بدأها عند انتهاء الأمر أو جلسة MCP. diff --git a/README_ja.md b/README_ja.md index 184553d6f..e90ff7899 100644 --- a/README_ja.md +++ b/README_ja.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-ツールのワークベンチ) +[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-ツールのワークベンチ) [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [50 ツール](#50-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq) +[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [68 ツール](#68-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
@@ -135,13 +135,15 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ - Swift / Objective-C のメタデータを解析する。 - Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。 -## 50 ツールのワークベンチ +## 68 ツールのワークベンチ -| ツール群 | 数 | 例 | -| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- | -| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 | -| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 | -| バイナリセッション | 7 | `open_binary`、`binary_session`、証拠バンドル、プロセス取得と比較 | +| ツール群 | 数 | 例 | +| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------- | +| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 | +| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 | +| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 | +| アーティファクトグラフ | 2 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 | +| バイナリセッション | 18 | `open_binary`、`binary_session`、証拠バンドル、プロセス/成果物/関数比較、残余未知レジストリ | ## 他のコーディングエージェントで使う diff --git a/README_ko.md b/README_ko.md index cb720ea32..50f419c1d 100644 --- a/README_ko.md +++ b/README_ko.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50개-도구로-구성된-워크벤치) +[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68개-도구로-구성된-워크벤치) [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [50개 도구](#50개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq) +[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [68개 도구](#68개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
@@ -135,13 +135,15 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이 - Swift 및 Objective-C 메타데이터를 분석합니다. - Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다. -## 50개 도구로 구성된 워크벤치 +## 68개 도구로 구성된 워크벤치 -| 도구 그룹 | 수 | 예시 | -| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- | -| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 | -| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 | -| 바이너리 세션 | 7 | `open_binary`, `binary_session`, 증거 번들, 프로세스 캡처 및 비교 | +| 도구 그룹 | 수 | 예시 | +| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------- | +| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 | +| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 | +| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 | +| 아티팩트 그래프 | 2 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 | +| 바이너리 세션 | 18 | `open_binary`, `binary_session`, 증거 번들, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 | ## 다른 코딩 에이전트에서 사용하기 diff --git a/README_zh.md b/README_zh.md index 89650781c..f1fc965a4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -10,11 +10,11 @@ [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-个工具组成的工作台) +[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-个工具组成的工作台) [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) -[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [50 个工具](#50-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题) +[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [68 个工具](#68-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
@@ -135,13 +135,15 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常 - 分析 Swift 和 Objective-C 元数据。 - 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。 -## 50 个工具组成的工作台 +## 68 个工具组成的工作台 -| 工具类别 | 数量 | 示例 | -| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- | -| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | -| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | -| 二进制会话 | 7 | `open_binary`、`binary_session`、证据包、进程捕获与比较 | +| 工具类别 | 数量 | 示例 | +| -------------- | ---: | -------------------------------------------------------------------------------------------------------------------- | +| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | +| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | +| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper | +| 制品图谱 | 2 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 | +| 二进制会话 | 18 | `open_binary`、`binary_session`、证据包、进程、制品与函数比较、残余未知项注册表 | ## 与其他编程智能体一起使用 From fbe8ec1f13f864c46f75c30cc4c5b41dd754eefe Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:34:05 +0000 Subject: [PATCH 7/8] Expose historical source import through CLI --- README.md | 8 ++++++++ metadata/completion-manifest.json | 2 +- scripts/verify-package.mjs | 34 ++++++++++++++++++++++++++++++- src/cli.ts | 32 +++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 708d32655..1b1e9b837 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ npx -y rea-agents doctor | Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent | | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` | | Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` | +| Import source as historical reference | `rea import-reference-source` | Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots: @@ -136,6 +137,13 @@ rea evidence-import /absolute/path/to/evidence/bundle.json rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json ``` +Historical source import requires a separate allowlist and never treats source as current behavioral authority: + +```bash +export REA_REFERENCE_ROOTS_JSON='["/absolute/path/to/source"]' +rea import-reference-source /absolute/path/to/source +``` + Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence v2 ID and manifest, and never execute bundle content. Completion metadata is verifier-owned. Run `npm run completion:generate` after changing contracts, schemas, providers, scenarios, package metadata, verifier inputs, or the managed skill. CI runs `npm run completion:check`; it detects stale or tampered hashes and never rewrites a branch. Unsupported and unknown outcomes remain separate from passes, and a pass or failure requires indexed Evidence v2 IDs. diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json index db0be3e64..d1febdcf3 100644 --- a/metadata/completion-manifest.json +++ b/metadata/completion-manifest.json @@ -16,7 +16,7 @@ }, { "path": "scripts/verify-package.mjs", - "sha256": "ffad65be3d5dde87c1e2d7b8886949066531e5b82933596b947507aab5f45b58" + "sha256": "b3809e3b7593fcaf16c9f962fbfaea105caca168c2ed8fd07336804ebb581174" }, { "path": "scripts/verify-real-hopper.mjs", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 7ae784565..cf0c74032 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -24,6 +24,7 @@ const exec = promisify(execFile); const root = process.cwd(); const workspace = await mkdtemp(join(tmpdir(), "rea-package-")); const evidenceRoot = join(workspace, "evidence"); +const referenceRoot = join(workspace, "reference-source"); let tarball; try { @@ -44,7 +45,21 @@ try { const home = join(workspace, "home"); const fakeBin = join(workspace, "bin"); await mkdir(fakeBin, { recursive: true }); - await mkdir(evidenceRoot, { recursive: true }); + await Promise.all([ + mkdir(evidenceRoot, { recursive: true }), + mkdir(join(referenceRoot, "src"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(referenceRoot, "src", "main.ts"), + 'import "./dependency";\n', + ), + writeFile( + join(referenceRoot, "src", "dependency.ts"), + "export const value = 1;\n", + ), + writeFile(join(referenceRoot, ".env"), "PACKAGE_SECRET_SENTINEL=1\n"), + ]); const brew = join(fakeBin, "brew"); const swVers = join(fakeBin, "sw_vers"); const npx = join(fakeBin, "npx"); @@ -84,6 +99,8 @@ try { HOPPER_LAUNCHER_PATH: hopper, REA_NPX_LOG: npxLog, REA_EVIDENCE_ROOTS_JSON: JSON.stringify([evidenceRoot]), + REA_REFERENCE_ROOTS_JSON: JSON.stringify([referenceRoot]), + REA_REFERENCE_SECRET_PATTERNS_JSON: JSON.stringify([".env"]), }; await exec( "npm", @@ -99,6 +116,7 @@ try { !help.includes("setup") || !help.includes("inventory-artifact") || !help.includes("extract-artifact") || + !help.includes("import-reference-source") || !llms.includes("decompile") || !llms.includes("inventory-artifact") || doctor.healthy !== expectedDoctorHealth || @@ -124,6 +142,20 @@ try { artifactInventory.normalized_result?.manifest?.root_format !== "zip" ) throw new Error("packaged artifact inventory CLI failed"); + const referenceImport = json( + await run( + cli, + ["import-reference-source", referenceRoot, "--json"], + environment, + ), + ); + if ( + referenceImport.authority !== "historical-reference" || + referenceImport.root_alias !== "$REFERENCE_ROOT" || + referenceImport.relationships?.[0]?.resolution !== "internal" || + JSON.stringify(referenceImport).includes("PACKAGE_SECRET_SENTINEL") + ) + throw new Error("packaged historical reference import CLI failed"); const { createEvidence } = await import( new URL("../dist/domain/evidence.js", import.meta.url) ); diff --git a/src/cli.ts b/src/cli.ts index b976dc031..05bc1f20b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js"; +import { importReferenceSource } from "./application/ReferenceSourceImport.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -96,9 +97,40 @@ export const createCli = (): ReturnType => { registerNativeCommands(cli, logger); registerArtifactCommands(cli, logger); registerEvidenceCommands(cli, logger); + registerReferenceSourceCommand(cli, logger); return cli; }; +const registerReferenceSourceCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("import-reference-source", { + description: "Import a bounded source tree as historical reference only", + args: z.object({ + root: z + .string() + .describe("Source root allowed by REA_REFERENCE_ROOTS_JSON"), + }), + run: ({ args }) => + logCliCommand(logger, "import-reference-source", async () => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const imported = await importReferenceSource({ + root: args.root, + caller: "rea-cli", + policy: config.value.referenceSourcePolicy, + importer: PRODUCT_IDENTITY.packageName, + importerVersion: null, + }); + return imported.ok + ? imported.value + : { error: imported.error.code, message: imported.error.message }; + }), + }); +}; + const registerArtifactCommands = ( cli: ReturnType, logger: Logger, From 293cea772334724275185918fdfb96ec012e2688 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:45:26 +0000 Subject: [PATCH 8/8] Simplify and harden investigation workflows --- .github/workflows/ci.yml | 12 - README.md | 4 +- metadata/completion-manifest.json | 450 ------------------- package-lock.json | 6 +- package.json | 8 +- scripts/generate-completion-manifest.mjs | 283 ------------ src/application/ArtifactExtraction.ts | 72 +-- src/application/ArtifactInventory.ts | 57 ++- src/application/FilesystemSnapshot.ts | 70 ++- src/application/ProcessHarness.ts | 66 ++- src/application/ProcessSampling.ts | 34 +- src/application/ReferenceSourceImport.ts | 2 +- src/application/ReferenceSourceVcsAdapter.ts | 64 ++- src/artifacts/ArtifactProvider.ts | 2 +- src/artifacts/ArtifactReader.ts | 2 +- src/artifacts/SafeOutputTree.ts | 12 +- src/domain/artifactGraph.ts | 2 +- src/domain/referenceSourceGraph.ts | 2 +- src/server/registerArtifactComparisonTool.ts | 32 +- src/server/registerBundleComparisonTool.ts | 15 +- src/server/registerEvidenceTools.ts | 8 +- src/server/registerFunctionComparisonTool.ts | 35 +- src/server/registerProcessComparisonTool.ts | 8 +- src/server/sessionEvidence.ts | 45 ++ src/server/toolRegistrationOptions.ts | 9 + tests/artifactInventorySnapshot.test.ts | 55 +++ tests/artifactMcp.test.ts | 39 ++ tests/bundleComparisonMcp.test.ts | 39 ++ tests/completionManifest.test.ts | 49 -- tests/functionComparisonMcp.test.ts | 39 ++ tests/processCapture.test.ts | 15 + tests/referenceSourceImport.test.ts | 2 +- tests/runtime.test.ts | 2 +- tests/safeOutputTree.test.ts | 30 +- tests/sessionMcp.test.ts | 2 +- 35 files changed, 531 insertions(+), 1041 deletions(-) delete mode 100644 metadata/completion-manifest.json delete mode 100644 scripts/generate-completion-manifest.mjs create mode 100644 src/server/sessionEvidence.ts create mode 100644 src/server/toolRegistrationOptions.ts create mode 100644 tests/artifactInventorySnapshot.test.ts delete mode 100644 tests/completionManifest.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abf73b862..3f8f83813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,18 +79,6 @@ jobs: - run: npm ci - run: npm run scan:todos - completion-metadata: - name: Completion Metadata Drift - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run completion:check - test: name: Test (coverage + timing) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 1b1e9b837..2ff10e445 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,6 @@ rea import-reference-source /absolute/path/to/source Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence v2 ID and manifest, and never execute bundle content. -Completion metadata is verifier-owned. Run `npm run completion:generate` after changing contracts, schemas, providers, scenarios, package metadata, verifier inputs, or the managed skill. CI runs `npm run completion:check`; it detects stale or tampered hashes and never rewrites a branch. Unsupported and unknown outcomes remain separate from passes, and a pass or failure requires indexed Evidence v2 IDs. - ## One prompt, a full investigation ```text @@ -187,7 +185,7 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its | Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations | | Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery | | Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing | -| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected transactional extraction | +| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree | | Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle | The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture. diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json deleted file mode 100644 index d1febdcf3..000000000 --- a/metadata/completion-manifest.json +++ /dev/null @@ -1,450 +0,0 @@ -{ - "schema_version": 1, - "inputs": { - "artifacts": [ - { - "path": "bridge/hopper_bridge.py", - "sha256": "ca3a5029485b5ded221d4c0e3ccde91a06cb48246ca44d9cac35be298b1ace8d" - }, - { - "path": "package-lock.json", - "sha256": "29e6cf54c4e58f0fefc98f8454715cb89de51bbc8ddded7b2b3906e7d7ba6d7f" - }, - { - "path": "scripts/verify-conformance-fixtures.mjs", - "sha256": "7d0a3cb47332076c28a2214833459f78b2809d69b624b7e22d53bd130531a545" - }, - { - "path": "scripts/verify-package.mjs", - "sha256": "b3809e3b7593fcaf16c9f962fbfaea105caca168c2ed8fd07336804ebb581174" - }, - { - "path": "scripts/verify-real-hopper.mjs", - "sha256": "dfcb025fc12e2b6b6bc1e7984d797471b9239f3abf4361a9e1118cac0261fe1d" - }, - { - "path": "skills/rea-analysis/SKILL.md", - "sha256": "d5a3a5329e7db590a06f5b0b1af6551704579b78711d9c6a229715b5a832906e" - }, - { - "path": "src/application/AnalysisProvider.ts", - "sha256": "0871b9a0bcb1d29c4442d904ee4c1932a6a598cf296811529de4bdebf69f729f" - }, - { - "path": "src/application/ArtifactExtraction.ts", - "sha256": "6683c35a361ac680b1f9ec557376b80acbde65a1418315f5359afe4e0e0ff7ff" - }, - { - "path": "src/application/ArtifactGraphConstruction.ts", - "sha256": "151651a3df972591f556e3546a536a5cc87fdb1d3f38d52ea58ad8476e1cf9ec" - }, - { - "path": "src/application/ArtifactInventory.ts", - "sha256": "cc8109ff0b4b64d379211d997be50c936854e9aa1bba27f5180d586d709512b7" - }, - { - "path": "src/application/CompositeProvider.ts", - "sha256": "af2d8565d442665a3e697886d880998336e1a79043152858d83ce4aed51eb28c" - }, - { - "path": "src/application/EvidenceLedger.ts", - "sha256": "8650bc0965bcd4e66cca78465d9387d5f436ab9b9064fb50b012d48048862875" - }, - { - "path": "src/application/DirectAnalysis.ts", - "sha256": "2422397e8670bd6807f1c28c4ede264cdb6c9cfd089e3ae8fae2e3f19f7da4c0" - }, - { - "path": "src/application/ProcessSampling.ts", - "sha256": "032c65bb3ee269fc865ba5da8aed651a4edd4b677644ba7893da2e486d3d8a7c" - }, - { - "path": "src/application/ReferenceSourceImport.ts", - "sha256": "9303a27e47d0e20f66c14b3586638b811146a44d0dc40d852c440c398e323b51" - }, - { - "path": "src/application/ReferenceSourceImportEntries.ts", - "sha256": "078272e9cdbd86166c89bffca03e3088b0fdc524e2a907c3d21814dca0229377" - }, - { - "path": "src/application/ReferenceSourceImportPolicy.ts", - "sha256": "70d2c4805dc4e1d7b2527a55a13c11da61374daebe203f3b94656fe9f9d614dd" - }, - { - "path": "src/application/ReferenceSourceImportTypes.ts", - "sha256": "cbb3ebd4d6a8e624fe8c5ef7b92fee19b60a9abe5a284a9e18c0f2a04bc4d8cd" - }, - { - "path": "src/application/ReferenceSourceVcsAdapter.ts", - "sha256": "cc0cb9333a77c17da55c4f55f81426819686722b5ccf3be3a48208f7eb0ac530" - }, - { - "path": "src/application/RealHopperAssertions.ts", - "sha256": "3ac4c9a6432c7bfc1f9f7733424edb087d8703f3b46a8cc9e2063bd7c9026e7c" - }, - { - "path": "src/application/runtime.ts", - "sha256": "af814fb413b083c07340939d49bb7db090656ff26fb1aebace53d4ee422d1652" - }, - { - "path": "src/contracts/nativeToolContracts.ts", - "sha256": "6c2a6120f842693430b200ec0ff1a384132d0f810d99b9e451a5fbb4fa48da8e" - }, - { - "path": "src/contracts/artifactToolContracts.ts", - "sha256": "92c6b74125c8312e7cffe9c8927bd9092a9a16696c5e57e21b3ab11a789664b6" - }, - { - "path": "src/contracts/artifactComparisonExample.ts", - "sha256": "77b9eaa5deda38e11af2dca7e79ed43397b3ed57407169dc2799e300d6a9dc4f" - }, - { - "path": "src/contracts/functionComparisonExample.ts", - "sha256": "c64d9166b9377e5f305dfdc5468a66fceb143919e11fd8145e4878ab591d28bd" - }, - { - "path": "src/contracts/investigationExamples.ts", - "sha256": "d4abbe096c1463918fb3ced25420b81f9d06333094b2d4563f777cfa3adefbd4" - }, - { - "path": "src/contracts/toolContractExamples.ts", - "sha256": "b03fa4ea9bbc5f5fcace06b4863d3121c28e6ef9e3c3b2e304402f837d328c9d" - }, - { - "path": "src/contracts/processCaptureExample.ts", - "sha256": "68e52c7378b9b7da34cb31a93eebd44f6425ea40971b589f0399afefbc5709b3" - }, - { - "path": "src/contracts/enhancedInputs.ts", - "sha256": "76bc9bc963a7a65800fe4ecbfec17c2f57410a42c116b6f5fe65a3b033fdb29e" - }, - { - "path": "src/contracts/toolContracts.ts", - "sha256": "de3252f67aae3514b478b2d5a3e8dffa3dcc1cb96eaa0f4ba3cad1267fdc92e3" - }, - { - "path": "src/contracts/toolOutputSchemas.ts", - "sha256": "40e69c1e2aad9708eee9a456f764c4125a6ffd8d031e5e501a7329734cdb9d99" - }, - { - "path": "src/contracts/unknownContractExamples.ts", - "sha256": "11a777f711d8876391ea7a22f3e29c3b461b0be048e9ddc3c6cf506324c6844c" - }, - { - "path": "src/domain/evidence.ts", - "sha256": "4603f33b7b20c24ee36440b66ed89fa57f5ffeb7aaf9a8a1be0dc5bcfc04ad27" - }, - { - "path": "src/domain/artifactGraph.ts", - "sha256": "7ffb5692fa22ecda416b5662788d1f530a320f0ec82706bc960d77213d1f7cbd" - }, - { - "path": "src/domain/artifactComparison.ts", - "sha256": "35e652cf283e5c58584dfc6e2d63d6c0f18e01e9bbea45404cfbbdd820519732" - }, - { - "path": "src/domain/artifactInventoryEvidence.ts", - "sha256": "c605faf52d674ed18568606cdc98fd9cbbd48228779d6afd1979b672b4ed237a" - }, - { - "path": "src/domain/functionComparison.ts", - "sha256": "b55bdfd7ad8581d793ff1816d6b9142efa45a00ec1161e9c88ab093c0cbbe76a" - }, - { - "path": "src/domain/functionComparisonNormalization.ts", - "sha256": "8ec6893102e1f3326415934d801f33ceac92dc0f04b01afe5b6527fa2866de16" - }, - { - "path": "src/domain/functionComparisonResults.ts", - "sha256": "01941b2174c7b206255058387bba847c6c313ccec09870e331c8594aca8d2cb5" - }, - { - "path": "src/domain/functionComparisonSchemas.ts", - "sha256": "ca40e147bd4a754978766f88d8bb3ad51eff22bce1a6194dc483ae3765d80ba8" - }, - { - "path": "src/domain/bundleComparison.ts", - "sha256": "83f6a56479eda9253e726c88c85fc4eef466f1c5eee6dd6cc94b54164c5e1c3e" - }, - { - "path": "src/domain/changedBehavior.ts", - "sha256": "8beda9f951f497a3947c234fc4f756cd6f908fba49c8cd3f1ff0986faa8ac912" - }, - { - "path": "src/domain/callPath.ts", - "sha256": "f08c5bf656efc0f569ca3895171f9473cb1c62e5381dc890209002d0f379b940" - }, - { - "path": "src/domain/staticRuntimeCorrelation.ts", - "sha256": "c38cc12c468ab1b9899e1f431969dc0936d6273ad6ef585f56a63efd172f32b5" - }, - { - "path": "src/domain/reconstructionVerification.ts", - "sha256": "b8dcc08f19e4bbcecda80a0ee92cd02add105052b93956026b25200049cc5ebd" - }, - { - "path": "src/domain/reconstructionVerificationSchemas.ts", - "sha256": "0c35560e358ff0b00f3dbcacc40a198b6a561eaaf23fb5c8a50eac35d4031207" - }, - { - "path": "src/domain/reconstructionUnknowns.ts", - "sha256": "5a174aa799877e4aa171fbfbfb3d19e5e4ee1737d2049d0ce7c1297b651f6b64" - }, - { - "path": "src/domain/referenceSourceGraph.ts", - "sha256": "4900f382215ad95e70ece8efe25f1ebe3b978fd262da48253a1808a779a93724" - }, - { - "path": "src/domain/referenceSourceClassification.ts", - "sha256": "bc2876db3b58dbd624561808be25d2bf666aed9e6278994d5f9a74c92a83c898" - }, - { - "path": "src/domain/referenceSourceImportParsing.ts", - "sha256": "ebdfd576d9b1a151424c633c588309a8c1cc47d5247380b2a5956c636e147aa7" - }, - { - "path": "src/domain/referenceSourcePolicy.ts", - "sha256": "f7e49b8ffdb231b2f4fd18a4efdc50d87187a45915a02fa3ee6eb4d9d0dd55d4" - }, - { - "path": "src/domain/functionDossierEvidence.ts", - "sha256": "74310ca13cd3c3b53f658899608482e15e32853ae2f0dd12f8093e3ce1d06ea3" - }, - { - "path": "src/domain/evidenceBundle.ts", - "sha256": "6f75b6f6b2ca583a33c003a7fd3575015021fbf3290284f86474c0af2ebfbd3a" - }, - { - "path": "src/domain/nativeInspection.ts", - "sha256": "b800c075d10033555cd5279cd8e019bdd935fe7389b2919e148bebdfc6f6b52e" - }, - { - "path": "src/domain/processCapture.ts", - "sha256": "cfdbac456aa868d06c50a0b410c181ed19892521f2d53d8bb254fcc1b92bbb3f" - }, - { - "path": "src/domain/residualUnknown.ts", - "sha256": "ab99fb3d8129655531e7791c2f69f4283ad5d5cdbd0f36ffcb7f2c7441428ac8" - }, - { - "path": "src/server/sessionToolPolicies.ts", - "sha256": "c972c399d40aef70510ad8fe33ec30eac046095195c1fb2185636f32fd062067" - }, - { - "path": "src/server/registerEnhancedTools.ts", - "sha256": "5e3b912090d77a6a03dbd4824163bbc2140b6f091fb302161c3ddb12f2d9cd7f" - }, - { - "path": "src/server/registerEvidenceTools.ts", - "sha256": "b295f94cacc911e416240c9bd46ccf3438ebc28169286e08e14937f02b8403cf" - }, - { - "path": "src/server/registerArtifactTools.ts", - "sha256": "67a9245471a1c5506a861925265dffc0b6cafa034ad2cb55996050b33497c9ed" - }, - { - "path": "src/server/registerArtifactComparisonTool.ts", - "sha256": "1cac756ded85945b31abafa956e01ed8732f472537687690fe6126a4ee884e32" - }, - { - "path": "src/server/registerFunctionComparisonTool.ts", - "sha256": "79ba262147e695ff0f84eb438807e574decb9cffd6e37189c7512b1ef0e7ac77" - }, - { - "path": "src/server/registerBundleComparisonTool.ts", - "sha256": "8ca624ddeb0d53ffa8a776954cb02e5a7612d5bfd435d11f07f19af708cca889" - }, - { - "path": "src/server/registerInvestigationTools.ts", - "sha256": "406d128beb86b1587f1eb4cd8292ab4b5eeb0e388cb1ad9ca10e7da67f36769b" - }, - { - "path": "src/server/recordDerivedEvidence.ts", - "sha256": "d5bcd67234b62483d4761520f1e1e8189940c81d6059816cc179cd5da5ff1cfe" - }, - { - "path": "src/server/registerNativeTools.ts", - "sha256": "96fe0e62b3d60ad3c74d3abcadc746ff6359a71cd98e6b922b5b6dbeb3b4423f" - }, - { - "path": "src/server/registerProcessComparisonTool.ts", - "sha256": "0771f98bf838bf5005c47534473d5eaa80b89d00b95d003c8ffa6e44764cf763" - }, - { - "path": "src/server/registerSessionTools.ts", - "sha256": "31decb6704f75cfc1e1a60a9ac1987a20a8530817ec919a679547d93606a94cd" - }, - { - "path": "src/native/CommandRunner.ts", - "sha256": "161ce2fea8b0dda8595b878889f78925e3a157c008ad0baaec7fafea584d8f39" - }, - { - "path": "src/native/NativeMacOSProvider.ts", - "sha256": "ed5fe6f58bbeda1e1811583ed943b948a70f784f39170d35b466efd22353a088" - }, - { - "path": "src/native/NativeMachoInspection.ts", - "sha256": "515bf5cbdf686b282058d2f38a54dacfb6f753d2401100fbc3ada5b21a61978c" - }, - { - "path": "src/native/parsers/codesign.ts", - "sha256": "6b14a03a65fe9597d680e4ed228523cd36bf7b314816be21b59483bfcfacb638" - }, - { - "path": "src/native/parsers/demangle.ts", - "sha256": "73d45dcb695a39cb047db02ae7f811db770fa0df5281b6eadc7f364be32c9985" - }, - { - "path": "src/native/parsers/dyldInfo.ts", - "sha256": "76c8359906a119e14bbc916c6167aa5a83ddc275fa02881616af374a7e6cfbd2" - }, - { - "path": "src/native/parsers/lipo.ts", - "sha256": "54c6963d0a58c55a677ee7ef8896248dc99a437cad672290df8ec2c52a88badb" - }, - { - "path": "src/native/parsers/otool.ts", - "sha256": "201588a480e4cd4522e01fbedfa783245b1de0d573b17ad9a1e71816cc104a9d" - }, - { - "path": "src/native/parsers/plist.ts", - "sha256": "ef3c59896c438f801db10de82418a72b35786ceca3ec2ee2556bc54e48559fe5" - }, - { - "path": "src/artifacts/ArtifactPaths.ts", - "sha256": "4338bae33e495e6065d7cd9f021209ca72b9172f22084a00745d2abf6075d69f" - }, - { - "path": "src/artifacts/ArtifactProvider.ts", - "sha256": "8230a07b52378e545dd3988b984c6ed0c80ee0e81d538981ef3a70bb8a3a19e3" - }, - { - "path": "src/artifacts/ArtifactReader.ts", - "sha256": "8a792031af0f689fc3978e696f591fb67d0e17a454726d8d3aaef700ba256b45" - }, - { - "path": "src/artifacts/AsarArtifactReader.ts", - "sha256": "1363467183c4ac264bdaaccf55a7b6e48243ce85f138feffe901e34042d1b520" - }, - { - "path": "src/artifacts/DirectoryArtifactReader.ts", - "sha256": "7643b591cb21f6c11119625816e7a863c40fbc5c9925e20b9bda85d7d11c3973" - }, - { - "path": "src/artifacts/MachOSliceArtifactReader.ts", - "sha256": "8d8b03250dd3fc9f83fd3a155a561335acd6a4e88274e95974144b89030f8c63" - }, - { - "path": "src/artifacts/SafeOutputTree.ts", - "sha256": "0cc8db23d75cc19edc6072fc6b17f63074afe52d65f51a4599c268c2d233d8f6" - }, - { - "path": "src/artifacts/StreamBytes.ts", - "sha256": "af717ba28133a48c0bb95c415aab7432c221a3e1fbf73277d981d49059cae32a" - }, - { - "path": "src/artifacts/ZipArtifactReader.ts", - "sha256": "4d8581522d795578b3446ede27642b35c49879505cb019fe9b929073f7261139" - }, - { - "path": "src/reference/ReferenceSourceReaderEntries.ts", - "sha256": "90ebecd4847bfa335fa806d957067a9885f572b5392bbf6148ca9a7c0b068ca4" - }, - { - "path": "src/reference/ReferenceSourceReaderErrors.ts", - "sha256": "c168294b8e4279d2cfee2e5a2692df7a0af8cd819f2757683328fc553a9bd402" - }, - { - "path": "src/reference/ReferenceSourceReaderFile.ts", - "sha256": "3b95030cb842505d7742f7c225eb7141e0de42611a0d7b7cbec88394a27210e5" - }, - { - "path": "src/reference/ReferenceSourceReaderPaths.ts", - "sha256": "7b4b903f6115573b4219615133bd87d15a2a6a07a336d056145bcfbae1e3e87f" - }, - { - "path": "src/reference/ReferenceSourceReaderTypes.ts", - "sha256": "dbf56e376ea4106ae87153248a0207e6e7940d56f1009b59d536addc29c331a2" - }, - { - "path": "src/reference/ReferenceSourceReaderValidate.ts", - "sha256": "e14a620a4e5fa350635158c584989707d877dc25f9da793cec1d82e7667f85fe" - }, - { - "path": "src/reference/ReferenceSourceReader.ts", - "sha256": "75c83b5c233a4ceda4233e33a19f7e421796623d337cf3ac737e6a94d0126bdc" - } - ], - "package": { - "name": "rea-agents", - "version": "0.3.0" - }, - "providers": [ - { - "id": "hopper", - "version": null, - "authority": "shipped-artifact" - }, - { - "id": "native-macos", - "version": null, - "authority": "shipped-artifact" - }, - { - "id": "rea-artifact-graph", - "version": "1", - "authority": "shipped-artifact" - } - ], - "environments": [ - { - "id": "package-isolation", - "isolation": "process" - }, - { - "id": "real-hopper-macos", - "isolation": "process" - } - ], - "scenarios": [ - { - "id": "conformance-fixtures", - "version": 1 - }, - { - "id": "package-install", - "version": 1 - }, - { - "id": "real-hopper-two-binary", - "version": 2 - } - ], - "schemas": { - "evidence": 2, - "evidence_bundle": 2, - "completion": 1 - }, - "tool_count": 68 - }, - "evidence_index": [], - "outcomes": { - "pass": [], - "fail": [], - "unsupported": [ - { - "claim_id": "real-hopper-verification", - "reason": "Generated repository metadata contains no checked-in real-Hopper capture.", - "evidence_ids": [] - } - ], - "truncated": [], - "unknown": [ - { - "claim_id": "semantic-conformance-complete", - "reason": "Completion requires external verifier evidence and cannot be inferred from test counts.", - "evidence_ids": [] - } - ] - } -} diff --git a/package-lock.json b/package-lock.json index 795959ffb..42c75a8f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", - "write-file-atomic": "^8.0.0", + "write-file-atomic": "8.0.0", "ws": "8.21.0", "zod": "4.4.3" }, @@ -32,10 +32,10 @@ "rea": "scripts/rea.mjs" }, "devDependencies": { - "@fast-check/vitest": "^0.4.1", + "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.3", "@types/node": "24.10.1", - "@types/write-file-atomic": "^4.0.3", + "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", "husky": "9.1.7", diff --git a/package.json b/package.json index a95109026..fba3daf30 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,6 @@ "build:fixtures": "node scripts/build-conformance-fixtures.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", "verify:package": "npm run build && node scripts/verify-package.mjs", - "completion:generate": "node scripts/generate-completion-manifest.mjs", - "completion:check": "node scripts/generate-completion-manifest.mjs --check", "config:print": "node scripts/print-mcp-config.mjs", "docs:generate": "typedoc", "prepack": "npm run build", @@ -97,15 +95,15 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", - "write-file-atomic": "^8.0.0", + "write-file-atomic": "8.0.0", "ws": "8.21.0", "zod": "4.4.3" }, "devDependencies": { - "@fast-check/vitest": "^0.4.1", + "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.3", "@types/node": "24.10.1", - "@types/write-file-atomic": "^4.0.3", + "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", "husky": "9.1.7", diff --git a/scripts/generate-completion-manifest.mjs b/scripts/generate-completion-manifest.mjs deleted file mode 100644 index 5ca72288c..000000000 --- a/scripts/generate-completion-manifest.mjs +++ /dev/null @@ -1,283 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { dirname, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import writeFileAtomic from "write-file-atomic"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const outputPath = resolve(root, "metadata/completion-manifest.json"); -const check = process.argv.slice(2).includes("--check"); -const unexpected = process.argv - .slice(2) - .filter((argument) => argument !== "--check"); -if (unexpected.length > 0) - throw new Error(`Unknown argument: ${unexpected.join(" ")}`); - -const artifactPaths = [ - "bridge/hopper_bridge.py", - "package-lock.json", - "scripts/verify-conformance-fixtures.mjs", - "scripts/verify-package.mjs", - "scripts/verify-real-hopper.mjs", - "skills/rea-analysis/SKILL.md", - "src/application/AnalysisProvider.ts", - "src/application/ArtifactExtraction.ts", - "src/application/ArtifactGraphConstruction.ts", - "src/application/ArtifactInventory.ts", - "src/application/CompositeProvider.ts", - "src/application/EvidenceLedger.ts", - "src/application/DirectAnalysis.ts", - "src/application/ProcessSampling.ts", - "src/application/ReferenceSourceImport.ts", - "src/application/ReferenceSourceImportEntries.ts", - "src/application/ReferenceSourceImportPolicy.ts", - "src/application/ReferenceSourceImportTypes.ts", - "src/application/ReferenceSourceVcsAdapter.ts", - "src/application/RealHopperAssertions.ts", - "src/application/runtime.ts", - "src/contracts/nativeToolContracts.ts", - "src/contracts/artifactToolContracts.ts", - "src/contracts/artifactComparisonExample.ts", - "src/contracts/functionComparisonExample.ts", - "src/contracts/investigationExamples.ts", - "src/contracts/toolContractExamples.ts", - "src/contracts/processCaptureExample.ts", - "src/contracts/enhancedInputs.ts", - "src/contracts/toolContracts.ts", - "src/contracts/toolOutputSchemas.ts", - "src/contracts/unknownContractExamples.ts", - "src/domain/evidence.ts", - "src/domain/artifactGraph.ts", - "src/domain/artifactComparison.ts", - "src/domain/artifactInventoryEvidence.ts", - "src/domain/functionComparison.ts", - "src/domain/functionComparisonNormalization.ts", - "src/domain/functionComparisonResults.ts", - "src/domain/functionComparisonSchemas.ts", - "src/domain/bundleComparison.ts", - "src/domain/changedBehavior.ts", - "src/domain/callPath.ts", - "src/domain/staticRuntimeCorrelation.ts", - "src/domain/reconstructionVerification.ts", - "src/domain/reconstructionVerificationSchemas.ts", - "src/domain/reconstructionUnknowns.ts", - "src/domain/referenceSourceGraph.ts", - "src/domain/referenceSourceClassification.ts", - "src/domain/referenceSourceImportParsing.ts", - "src/domain/referenceSourcePolicy.ts", - "src/domain/functionDossierEvidence.ts", - "src/domain/evidenceBundle.ts", - "src/domain/nativeInspection.ts", - "src/domain/processCapture.ts", - "src/domain/residualUnknown.ts", - "src/server/sessionToolPolicies.ts", - "src/server/registerEnhancedTools.ts", - "src/server/registerEvidenceTools.ts", - "src/server/registerArtifactTools.ts", - "src/server/registerArtifactComparisonTool.ts", - "src/server/registerFunctionComparisonTool.ts", - "src/server/registerBundleComparisonTool.ts", - "src/server/registerInvestigationTools.ts", - "src/server/recordDerivedEvidence.ts", - "src/server/registerNativeTools.ts", - "src/server/registerProcessComparisonTool.ts", - "src/server/registerSessionTools.ts", - "src/native/CommandRunner.ts", - "src/native/NativeMacOSProvider.ts", - "src/native/NativeMachoInspection.ts", - "src/native/parsers/codesign.ts", - "src/native/parsers/demangle.ts", - "src/native/parsers/dyldInfo.ts", - "src/native/parsers/lipo.ts", - "src/native/parsers/otool.ts", - "src/native/parsers/plist.ts", - "src/artifacts/ArtifactPaths.ts", - "src/artifacts/ArtifactProvider.ts", - "src/artifacts/ArtifactReader.ts", - "src/artifacts/AsarArtifactReader.ts", - "src/artifacts/DirectoryArtifactReader.ts", - "src/artifacts/MachOSliceArtifactReader.ts", - "src/artifacts/SafeOutputTree.ts", - "src/artifacts/StreamBytes.ts", - "src/artifacts/ZipArtifactReader.ts", - "src/reference/ReferenceSourceReaderEntries.ts", - "src/reference/ReferenceSourceReaderErrors.ts", - "src/reference/ReferenceSourceReaderFile.ts", - "src/reference/ReferenceSourceReaderPaths.ts", - "src/reference/ReferenceSourceReaderTypes.ts", - "src/reference/ReferenceSourceReaderValidate.ts", - "src/reference/ReferenceSourceReader.ts", -]; - -const artifacts = await Promise.all( - artifactPaths.map(async (path) => ({ - path, - sha256: sha256(await readFile(resolve(root, path))), - })), -); -const packageJson = JSON.parse( - await readFile(resolve(root, "package.json"), "utf8"), -); -const toolSource = await readFile( - resolve(root, "src/contracts/toolContracts.ts"), - "utf8", -); -const nativeToolSource = await readFile( - resolve(root, "src/contracts/nativeToolContracts.ts"), - "utf8", -); -const artifactToolSource = await readFile( - resolve(root, "src/contracts/artifactToolContracts.ts"), - "utf8", -); -const skillSource = await readFile( - resolve(root, "skills/rea-analysis/SKILL.md"), - "utf8", -); -const toolCount = - countContracts(toolSource) + - countProviderContracts(nativeToolSource, "NATIVE", "native") + - countProviderContracts(artifactToolSource, "ARTIFACT", "artifact"); -const skillToolCount = Number(/tool_count:\s*(\d+)/u.exec(skillSource)?.[1]); -if (!Number.isSafeInteger(skillToolCount) || skillToolCount !== toolCount) - throw new Error( - `Skill tool_count drift: skill=${String(skillToolCount)} contracts=${toolCount}`, - ); - -const manifest = { - schema_version: 1, - inputs: { - artifacts, - package: { name: packageJson.name, version: packageJson.version }, - providers: [ - { - id: "hopper", - version: null, - authority: "shipped-artifact", - }, - { - id: "native-macos", - version: null, - authority: "shipped-artifact", - }, - { - id: "rea-artifact-graph", - version: "1", - authority: "shipped-artifact", - }, - ], - environments: [ - { id: "package-isolation", isolation: "process" }, - { id: "real-hopper-macos", isolation: "process" }, - ], - scenarios: [ - { id: "conformance-fixtures", version: 1 }, - { id: "package-install", version: 1 }, - { id: "real-hopper-two-binary", version: 2 }, - ], - schemas: { evidence: 2, evidence_bundle: 2, completion: 1 }, - tool_count: toolCount, - }, - evidence_index: [], - outcomes: { - pass: [], - fail: [], - unsupported: [ - { - claim_id: "real-hopper-verification", - reason: - "Generated repository metadata contains no checked-in real-Hopper capture.", - evidence_ids: [], - }, - ], - truncated: [], - unknown: [ - { - claim_id: "semantic-conformance-complete", - reason: - "Completion requires external verifier evidence and cannot be inferred from test counts.", - evidence_ids: [], - }, - ], - }, -}; -validateOutcomeEvidence(manifest); -const serialized = `${JSON.stringify(manifest, null, 2)}\n`; - -if (check) { - let existing; - try { - existing = await readFile(outputPath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") - throw new Error( - `Missing generated manifest: ${relative(root, outputPath)}`, - ); - throw error; - } - if (existing !== serialized) - throw new Error( - `Generated completion metadata is stale or tampered: run npm run completion:generate`, - ); -} else { - await writeFileAtomic(outputPath, serialized, { encoding: "utf8" }); -} - -process.stdout.write( - `${JSON.stringify({ mode: check ? "check" : "generate", path: relative(root, outputPath), toolCount })}\n`, -); - -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); -} - -function countContracts(source) { - const sections = [ - ["OFFICIAL", "official"], - ["ENHANCED", "enhanced"], - ["SESSION", "session"], - ]; - return sections.reduce((total, [section, constructor]) => { - const match = new RegExp( - `export const ${section}_TOOL_CONTRACTS = \\[([\\s\\S]*?)\\n\\] as const satisfies`, - "u", - ).exec(source); - if (match?.[1] === undefined) - throw new Error(`Cannot derive ${section.toLowerCase()} tool contracts`); - return ( - total + - [...match[1].matchAll(new RegExp(`^ ${constructor}\\($`, "gmu"))].length - ); - }, 0); -} - -function countProviderContracts(source, section, constructor) { - const match = new RegExp( - `export const ${section}_TOOL_CONTRACTS = \\[([\\s\\S]*?)\\n\\] as const satisfies`, - "u", - ).exec(source); - if (match?.[1] === undefined) - throw new Error(`Cannot derive ${section.toLowerCase()} tool contracts`); - return [...match[1].matchAll(new RegExp(`^ ${constructor}\\($`, "gmu"))] - .length; -} - -function validateOutcomeEvidence(value) { - const evidenceIds = new Set( - value.evidence_index.map(({ evidence_id }) => evidence_id), - ); - for (const status of ["pass", "fail"]) { - for (const outcome of value.outcomes[status]) { - if (outcome.evidence_ids.length === 0) - throw new Error( - `${status} outcome ${outcome.claim_id} has no evidence`, - ); - for (const evidenceId of outcome.evidence_ids) - if (!evidenceIds.has(evidenceId)) - throw new Error( - `${status} outcome ${outcome.claim_id} references missing evidence ${evidenceId}`, - ); - } - } -} diff --git a/src/application/ArtifactExtraction.ts b/src/application/ArtifactExtraction.ts index 0d0a13018..e3e85cc49 100644 --- a/src/application/ArtifactExtraction.ts +++ b/src/application/ArtifactExtraction.ts @@ -23,7 +23,7 @@ import { type ArtifactOccurrence, } from "../domain/artifactGraph.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; -import { inventoryArtifact } from "./ArtifactInventory.js"; +import { scanArtifactInventory } from "./ArtifactInventory.js"; import { digestCanonical, pageOf, @@ -43,7 +43,7 @@ export interface ArtifactExtractionInput { readonly limits: ArtifactLimits; } -/** Extract only explicitly selected inventory occurrences into an absent root. */ +/** Extract selected inventory occurrences into an exclusively owned absent root. */ export const extractArtifact = async ( input: ArtifactExtractionInput, signal?: AbortSignal, @@ -172,13 +172,6 @@ const materializeSelection = async ({ selected, extracted, ); - const verifiedSource = await inventoryArtifact( - sourcePath, - input.limits, - pageInput(0, 0), - signal, - ); - assertSameManifest(inventory.manifest, verifiedSource.manifest); await output.commit(); return result; } catch (cause: unknown) { @@ -233,62 +226,20 @@ const loadInventory = async ( selectedIds: ReadonlySet, signal?: AbortSignal, ): Promise => { - const first = await inventoryArtifact(path, limits, pageInput(0, 0), signal); + const snapshot = await scanArtifactInventory(path, limits, signal); const occurrences = new Map(); const neededNodes = new Set(); collectOccurrences( - first.occurrences.items, + snapshot.occurrences, selectedIds, occurrences, neededNodes, ); - for ( - let offset = first.occurrences.next_offset; - offset !== null && occurrences.size < selectedIds.size; - offset += PAGE_SIZE - ) { - const page = await inventoryArtifact( - path, - limits, - pageInput(0, offset), - signal, - ); - assertSameManifest(first.manifest, page.manifest); - collectOccurrences( - page.occurrences.items, - selectedIds, - occurrences, - neededNodes, - ); - } const nodes = new Map(); - collectNodes(first.nodes.items, neededNodes, nodes); - for ( - let offset = first.nodes.next_offset; - offset !== null && nodes.size < neededNodes.size; - offset += PAGE_SIZE - ) { - const page = await inventoryArtifact( - path, - limits, - pageInput(offset, 0), - signal, - ); - assertSameManifest(first.manifest, page.manifest); - collectNodes(page.nodes.items, neededNodes, nodes); - } - return { manifest: first.manifest, occurrences, nodes }; + collectNodes(snapshot.nodes, neededNodes, nodes); + return { manifest: snapshot.manifest, occurrences, nodes }; }; -const pageInput = (nodeOffset: number, occurrenceOffset: number) => ({ - nodeOffset, - nodeLimit: PAGE_SIZE, - occurrenceOffset, - occurrenceLimit: PAGE_SIZE, - edgeOffset: 0, - edgeLimit: 1, -}); - const collectOccurrences = ( items: readonly ArtifactOccurrence[], selected: ReadonlySet, @@ -363,14 +314,3 @@ const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => { `Selected artifact exceeds compression ratio limit: ${entry.path}`, ); }; - -const assertSameManifest = ( - expected: ArtifactGraphManifest, - observed: ArtifactGraphManifest, -): void => { - if (expected.manifest_id !== observed.manifest_id) - throw new ArtifactReaderFailure( - "integrity", - "Artifact changed while resolving extraction selection", - ); -}; diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index 56bf13751..080de5458 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -49,6 +49,19 @@ interface InventoryPageInput { readonly edgeLimit: number; } +/** Immutable inventory produced by one complete artifact scan. */ +export interface ArtifactInventorySnapshot { + readonly manifest: ArtifactInventoryResult["manifest"]; + readonly nodes: readonly ArtifactNode[]; + readonly occurrences: ArtifactInventoryResult["occurrences"]["items"]; + readonly edges: ArtifactInventoryResult["edges"]["items"]; + readonly limits: ArtifactInventoryResult["limits"]; + readonly provenance: ReadonlyArray< + ArtifactInventoryResult["provenance"][number] + >; + readonly limitations: readonly string[]; +} + /** Inventory one local artifact without extracting or mounting it. */ export const inventoryArtifact = async ( inputPath: string, @@ -56,6 +69,16 @@ export const inventoryArtifact = async ( page: InventoryPageInput, signal?: AbortSignal, ): Promise => { + const snapshot = await scanArtifactInventory(inputPath, limits, signal); + return paginateArtifactInventory(snapshot, page); +}; + +/** Scan an artifact once and retain the complete immutable graph for projection. */ +export const scanArtifactInventory = async ( + inputPath: string, + limits: ArtifactLimits, + signal?: AbortSignal, +): Promise => { abortIfNeeded(signal); const path = await realpath(inputPath); const metadata = await lstat(path); @@ -122,8 +145,8 @@ export const inventoryArtifact = async ( root_artifact_id: rootNode.artifact_id, graph_sha256: graphSha256, })}`; - return artifactInventoryResultSchema.parse({ - manifest: { + return { + manifest: artifactInventoryResultSchema.shape.manifest.parse({ schema_version: 1, manifest_id: manifestId, root_artifact_id: rootNode.artifact_id, @@ -133,23 +156,35 @@ export const inventoryArtifact = async ( node_count: orderedNodes.length, occurrence_count: orderedOccurrences.length, edge_count: orderedEdges.length, - }, - nodes: pageOf(orderedNodes, page.nodeOffset, page.nodeLimit), - occurrences: pageOf( - orderedOccurrences, - page.occurrenceOffset, - page.occurrenceLimit, - ), - edges: pageOf(orderedEdges, page.edgeOffset, page.edgeLimit), + }), + nodes: orderedNodes, + occurrences: orderedOccurrences, + edges: orderedEdges, limits: toOutputLimits(limits), provenance: reader?.provenance() ?? [], limitations: inventoryLimitations(rootFormat, reader), - }); + }; } finally { await reader?.close(); } }; +/** Project independently paged graph collections from one inventory snapshot. */ +export const paginateArtifactInventory = ( + snapshot: ArtifactInventorySnapshot, + page: InventoryPageInput, +): ArtifactInventoryResult => + artifactInventoryResultSchema.parse({ + ...snapshot, + nodes: pageOf(snapshot.nodes, page.nodeOffset, page.nodeLimit), + occurrences: pageOf( + snapshot.occurrences, + page.occurrenceOffset, + page.occurrenceLimit, + ), + edges: pageOf(snapshot.edges, page.edgeOffset, page.edgeLimit), + }); + const inventoryLimitations = ( format: ArtifactNode["format"], reader: ArtifactReader | undefined, diff --git a/src/application/FilesystemSnapshot.ts b/src/application/FilesystemSnapshot.ts index 8b1dd4866..6cdd36987 100644 --- a/src/application/FilesystemSnapshot.ts +++ b/src/application/FilesystemSnapshot.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import { lstat, open, readdir, readlink } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import type { FileState, ProcessScenario } from "../domain/processCapture.js"; +import type { Stats } from "node:fs"; export interface SnapshotResult { readonly files: readonly FileState[]; @@ -13,9 +14,29 @@ const isWithin = (candidate: string, root: string): boolean => candidate === root || candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`); +const hasSameIdentity = ( + before: Stats, + after: Stats, + expected: "directory" | "symlink", +): boolean => + before.dev === after.dev && + before.ino === after.ino && + (expected === "directory" ? after.isDirectory() : after.isSymbolicLink()); + +const lstatIfPresent = async (path: string): Promise => { + try { + return await lstat(path); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return undefined; + throw cause; + } +}; + const hashFile = async ( path: string, maxBytes: number, + signal?: AbortSignal, ): Promise => { const handle = await open( path, @@ -24,9 +45,25 @@ const hashFile = async ( try { const stats = await handle.stat(); if (!stats.isFile() || stats.size > maxBytes) return null; - return createHash("sha256") - .update(await handle.readFile()) - .digest("hex"); + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes)); + let position = 0; + while (position < stats.size) { + signal?.throwIfAborted(); + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, stats.size - position), + position, + ); + if (bytesRead === 0) return null; + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + const after = await handle.stat(); + if (after.size !== stats.size || after.mtimeMs !== stats.mtimeMs) + return null; + return hash.digest("hex"); } finally { await handle.close(); } @@ -35,6 +72,7 @@ const hashFile = async ( /** Capture bounded, root-aliased filesystem state without following symlinks. */ export const snapshotRoots = async ( scenario: ProcessScenario, + signal?: AbortSignal, ): Promise => { const entries: FileState[] = []; let remainingBytes = scenario.limits.file_bytes; @@ -45,6 +83,7 @@ export const snapshotRoots = async ( path: string, depth: number, ): Promise => { + signal?.throwIfAborted(); if ( entries.length >= scenario.limits.files || depth > scenario.limits.filesystem_depth @@ -52,17 +91,16 @@ export const snapshotRoots = async ( truncated = true; return; } - let stats; - try { - stats = await lstat(path); - } catch (error: unknown) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") - return; - throw error; - } + const stats = await lstatIfPresent(path); + if (stats === undefined) return; const relativePath = relative(root, path) || "."; if (stats.isSymbolicLink()) { const target = resolve(dirname(path), await readlink(path)); + const afterRead = await lstat(path); + if (!hasSameIdentity(stats, afterRead, "symlink")) { + truncated = true; + return; + } const safeTarget = isWithin(target, root) ? relative(root, target) || "." : ""; @@ -80,7 +118,7 @@ export const snapshotRoots = async ( if (stats.isFile()) { const sha256 = remainingBytes >= stats.size - ? await hashFile(path, remainingBytes) + ? await hashFile(path, remainingBytes, signal) : null; remainingBytes -= sha256 === null ? 0 : stats.size; if (sha256 === null) truncated = true; @@ -104,7 +142,13 @@ export const snapshotRoots = async ( symlink_target: null, }); if (type !== "directory") return; - for (const child of (await readdir(path)).sort()) + const children = await readdir(path); + const afterRead = await lstat(path); + if (!hasSameIdentity(stats, afterRead, "directory")) { + truncated = true; + return; + } + for (const child of children.sort()) await visit(root, rootAlias, join(path, child), depth + 1); }; for (const [index, root] of scenario.filesystem_roots.entries()) diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 21b4097f6..5ec13a6f4 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -183,6 +183,7 @@ interface CaptureResultOptions { readonly truncated: boolean; readonly scenario: ProcessScenario; readonly rootPid: number; + readonly samplingPartial: boolean; } const classifyFilesystemEffects = ( @@ -236,6 +237,9 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ truncated: options.truncated, limitations: [ "Process trees are sampled and may omit short-lived descendants.", + ...(options.samplingPartial + ? ["Process-tree sampling ended with an incomplete observation."] + : []), "Filesystem observations are before/after snapshots, not syscall traces.", "The harness does not enforce external network isolation.", ], @@ -269,7 +273,14 @@ const awaitTerminalExit = async ({ }> => new Promise((resolveExit) => { let reason: "exited" | "timeout" | "idle_timeout" | "cancelled" = "exited"; - terminal.onExit((exit) => resolveExit({ ...exit, reason })); + terminal.onExit((exit) => { + for (const timer of timers) { + clearTimeout(timer); + clearInterval(timer); + } + timers.clear(); + resolveExit({ ...exit, reason }); + }); const timeout = setInterval(() => { if (signal?.aborted === true) { reason = "cancelled"; @@ -361,34 +372,51 @@ const resolveProcessResult = ( executionFailure: unknown, cleanupFailure: string | undefined, ): ProcessCapture => { - if (cleanupFailure !== undefined) - throw new ProcessCaptureError(cleanupFailure); if (executionFailure instanceof ProcessCaptureError) throw executionFailure; if (executionFailure !== undefined) throw new ProcessCaptureError("process capture failed", { cause: executionFailure, }); + if (cleanupFailure !== undefined) + throw new ProcessCaptureError(cleanupFailure); if (capture === undefined) throw new ProcessCaptureError("process capture produced no result"); return capture; }; +const prepareProcessCapture = async ( + scenario: ProcessScenario, + policy: ProcessExecutionPolicy, + signal: AbortSignal | undefined, +): Promise<{ + readonly temporaryRoot: string; + readonly runId: string; + readonly home: string; + readonly before: SnapshotResult; +}> => { + const decision = authorizeProcessScenario(scenario, policy); + if (!decision.allowed) throw new ProcessCaptureError(decision.reason); + await assertRealPathAuthority(scenario, policy); + assertNotCancelled(signal); + const before = await snapshotRoots(scenario, signal); + const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-")); + const runId = randomUUID(); + const home = join(temporaryRoot, "home"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(home)); + return { temporaryRoot, runId, home, before }; +}; + /** Execute one authorized scenario and return bounded observations. */ const runProcessScenario = async ( scenario: ProcessScenario, policy: ProcessExecutionPolicy, signal?: AbortSignal, ): Promise => { - const decision = authorizeProcessScenario(scenario, policy); - if (!decision.allowed) throw new ProcessCaptureError(decision.reason); - await assertRealPathAuthority(scenario, policy); - assertNotCancelled(signal); - - const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-")); - const runId = randomUUID(); - const home = join(temporaryRoot, "home"); - await import("node:fs/promises").then(({ mkdir }) => mkdir(home)); - const before = await snapshotRoots(scenario); + const { temporaryRoot, runId, home, before } = await prepareProcessCapture( + scenario, + policy, + signal, + ); const frames: TerminalFrame[] = []; const samples: ProcessSample[] = []; let truncated = before.truncated; @@ -400,7 +428,8 @@ const runProcessScenario = async ( let capture: ProcessCapture | undefined; let executionFailure: unknown; let framesTruncated = (): boolean => false; - let stopSampler: () => Promise = async () => undefined; + let stopSampler = async () => ({ partial: false }); + let samplingPartial = false; try { replay = await startLoopbackReplay(scenario); @@ -439,18 +468,20 @@ const runProcessScenario = async ( timers, }); const exitReason = exit.reason; - await stopSampler(); + samplingPartial = (await stopSampler()).partial; if (exitReason === "cancelled") throw new ProcessCaptureError("process capture was cancelled"); await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms), ); - const after = await snapshotRoots(scenario); + assertNotCancelled(signal); + const after = await snapshotRoots(scenario, signal); truncated ||= after.truncated || replay.truncated || framesTruncated() || samples.length >= scenario.limits.processes; + truncated ||= samplingPartial; capture = buildCaptureResult({ frames, exit: { ...exit, reason: exitReason }, @@ -461,12 +492,13 @@ const runProcessScenario = async ( truncated, scenario, rootPid: terminal.pid, + samplingPartial, }); } catch (cause: unknown) { terminal?.kill("SIGKILL"); executionFailure = cause; } - await stopSampler(); + samplingPartial ||= (await stopSampler()).partial; const cleanupFailure = await releaseProcessResources({ timers, replay, diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts index df307f4e8..87f963916 100644 --- a/src/application/ProcessSampling.ts +++ b/src/application/ProcessSampling.ts @@ -26,6 +26,19 @@ interface SampleProcessContext { readonly signal: AbortSignal; } +const isExpectedProcReadFailure = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || + cause.code === "ESRCH" || + cause.code === "EACCES" || + cause.code === "EPERM"); + +const handleProcReadFailure = (cause: unknown): undefined => { + if (isExpectedProcReadFailure(cause)) return undefined; + throw cause; +}; + const parseProcStat = ( identifier: string, stat: string, @@ -66,8 +79,8 @@ const readProcessStat = async ( signal, }); return parseProcStat(String(pid), stat); - } catch { - return undefined; + } catch (cause: unknown) { + return handleProcReadFailure(cause); } }; @@ -77,8 +90,8 @@ const readProcessCommand = async ( ): Promise => { try { return await readFile(`/proc/${String(pid)}/cmdline`, { signal }); - } catch { - return undefined; + } catch (cause: unknown) { + return handleProcReadFailure(cause); } }; @@ -92,8 +105,8 @@ const readLinuxChildren = async ( encoding: "utf8", signal, }); - } catch { - return undefined; + } catch (cause: unknown) { + return handleProcReadFailure(cause); } return text .trim() @@ -303,12 +316,13 @@ export const startProcessSampler = ( started: number, limit: number, samples: ProcessSample[], -): (() => Promise) => { +): (() => Promise<{ readonly partial: boolean }>) => { const identities = new Map(); const sampledPids = new Set(samples.map(({ pid }) => pid)); let pending: Promise | undefined; let stopped = false; let abortCurrent: (() => void) | undefined; + let partial = false; const sample = (): void => { if (stopped || pending !== undefined || sampledPids.size >= limit) return; @@ -331,7 +345,10 @@ export const startProcessSampler = ( samples.push(value); } }) - .catch(() => undefined) + .catch((cause: unknown) => { + if (!(cause instanceof Error && cause.name === "AbortError")) + partial = true; + }) .then(() => { abortCurrent = undefined; pending = undefined; @@ -346,5 +363,6 @@ export const startProcessSampler = ( clearInterval(timer); abortCurrent?.(); await pending; + return { partial }; }; }; diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index 023c09d11..d68a651a1 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -175,7 +175,7 @@ export const importReferenceSource = async ( ...(options.signal === undefined ? {} : { signal: options.signal }), shouldExclude, }), - readReferenceSourceVcs(root), + readReferenceSourceVcs(root, options.signal), ]); if (!readResult.ok) { diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts index e30e475c0..c33f6d267 100644 --- a/src/application/ReferenceSourceVcsAdapter.ts +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -1,9 +1,15 @@ import fs from "node:fs"; +import { lstat } from "node:fs/promises"; +import { join } from "node:path"; -import { log, statusMatrix } from "isomorphic-git"; +import { resolveRef } from "isomorphic-git"; export type ReferenceSourceVcsInfo = - | { readonly kind: "git"; readonly head: string; readonly dirty: boolean } + | { + readonly kind: "git"; + readonly head: string; + readonly dirty: boolean | null; + } | { readonly kind: "none"; readonly head: null; readonly dirty: null } | { readonly kind: "unknown"; readonly head: null; readonly dirty: null }; @@ -14,42 +20,28 @@ export type ReferenceSourceVcsInfo = */ export const readReferenceSourceVcs = async ( root: string, + signal?: AbortSignal, ): Promise => { + if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; try { - const commits = await log({ - fs, - dir: root, - ref: "HEAD", - depth: 1, - cache: {}, - }); - if (commits.length === 0) - return { kind: "unknown", head: null, dirty: null }; - - const head = commits[0]?.oid; - if (head === undefined) return { kind: "unknown", head: null, dirty: null }; - - let dirty = false; - try { - const matrix = await statusMatrix({ - fs, - dir: root, - ref: head, - cache: {}, - }); - for (const row of matrix) { - const [, headStatus, workdirStatus, stageStatus] = row; - if (headStatus !== 1 || workdirStatus !== 1 || stageStatus !== 1) { - dirty = true; - break; - } - } - } catch { - dirty = true; - } - - return { kind: "git", head, dirty }; + await lstat(join(root, ".git")); + } catch (cause: unknown) { + return errorCode(cause) === "ENOENT" + ? { kind: "none", head: null, dirty: null } + : { kind: "unknown", head: null, dirty: null }; + } + try { + const head = await resolveRef({ fs, dir: root, ref: "HEAD" }); + if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; + return { kind: "git", head, dirty: null }; } catch { - return { kind: "none", head: null, dirty: null }; + return { kind: "unknown", head: null, dirty: null }; } }; + +const errorCode = (cause: unknown): string | undefined => + typeof cause === "object" && cause !== null && "code" in cause + ? String(cause.code) + : undefined; + +const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index bd16fe8b5..a2151185e 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -33,7 +33,7 @@ const IDENTITY: ProviderIdentity = Object.freeze({ version: "1", }); -/** Read-only inventory and transaction-owned extraction provider. */ +/** Read-only inventory and exclusively owned extraction provider. */ export class ArtifactProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze( ARTIFACT_TOOL_CONTRACTS.map((contract) => diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 0b1d5720b..9f2f07163 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -29,7 +29,7 @@ export interface ArtifactReader { close(): Promise; } -/** Hard traversal limits shared by every reader and extraction transaction. */ +/** Hard traversal limits shared by every reader and extraction operation. */ export interface ArtifactLimits { readonly maxEntries: number; readonly maxTotalBytes: number; diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index a2e67a3fc..a023f1f94 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -14,7 +14,7 @@ import { type ArtifactLimits, } from "./ArtifactReader.js"; -/** One file durably written to a transaction-owned output tree. */ +/** One file durably written to an operation-owned output tree. */ export interface SafeOutputFile { readonly relativePath: string; readonly sha256: string; @@ -28,7 +28,7 @@ export interface SafeOutputCleanup { readonly residualPaths: readonly string[]; } -/** Transactional, symlink-resistant materialization beneath an absent path. */ +/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */ export class SafeOutputTree { readonly #registry = new ArtifactPathRegistry(); readonly #limits: ArtifactLimits; @@ -52,7 +52,7 @@ export class SafeOutputTree { this.#limits = limits; } - /** Exclusively create the absent destination as this transaction's owned tree. */ + /** Exclusively create the absent destination as this operation's owned tree. */ static async create( outputRoot: string, limits: ArtifactLimits, @@ -177,7 +177,7 @@ export class SafeOutputTree { } } - /** Durably commit the exclusively-created output tree. */ + /** Sync the owned output tree and prevent further writes through this instance. */ async commit(): Promise { this.#assertWritable(); const parent = await open( @@ -195,7 +195,7 @@ export class SafeOutputTree { } catch (cause: unknown) { throw new ArtifactReaderFailure( "path", - "Could not durably commit extraction output", + "Could not durably sync extraction output", { cause }, ); } finally { @@ -203,7 +203,7 @@ export class SafeOutputTree { } } - /** Remove only this transaction's uncommitted tree and verify absence. */ + /** Remove only this operation's unsealed tree and verify absence. */ async rollback(): Promise { if (this.#published) return this.#cleanup; await rm(this.#stagingRoot, { recursive: true, force: true }); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 10accebb8..a83d733f6 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -189,7 +189,7 @@ const extractedArtifactSchema = z.object({ created: z.boolean(), }); -/** Path-independent commitment to one selected extraction transaction. */ +/** Path-independent commitment to one selected extraction operation. */ const artifactExtractionManifestSchema = z.object({ schema_version: z.literal(1), extraction_id: extractionIdSchema, diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 3df3170fc..b9689c217 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -145,7 +145,7 @@ const vcsSchema = z z.strictObject({ kind: z.literal("git"), head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u), - dirty: z.boolean(), + dirty: z.boolean().nullable(), }), z.strictObject({ kind: z.literal("none"), diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 3f85545c6..f96ce8cd8 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -7,13 +7,13 @@ import { compareArtifacts, } from "../domain/artifactComparison.js"; import { createEvidence } from "../domain/evidence.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; -import { err } from "../domain/result.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { resolveSessionEvidence } from "./sessionEvidence.js"; import { ARTIFACT_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register Evidence-backed deterministic artifact comparison. */ export const registerArtifactComparisonTool = ( @@ -23,35 +23,21 @@ export const registerArtifactComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = artifactComparisonInputSchema.parse(input); + const left = resolveSessionEvidence(session, parsed.left); + if (!left.ok) return toCallToolResult(left, contract); + const right = resolveSessionEvidence(session, parsed.right); + if (!right.ok) return toCallToolResult(right, contract); const comparison = compareArtifacts( - parsed.left, - parsed.right, + left.value, + right.value, parsed.offset, parsed.limit, ); const leftEvidenceIds = evidenceIds(parsed.left); const rightEvidenceIds = evidenceIds(parsed.right); - if ( - [...leftEvidenceIds, ...rightEvidenceIds].some( - (evidenceId) => !session.hasEvidence(evidenceId), - ) - ) - return toCallToolResult( - err( - new EvidenceIntegrityError( - "Artifact comparison input Evidence is not present in this session", - ), - ), - contract, - ); const evidence = createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { predicateType: "rea.artifact-comparison/v1", operation: contract.name, diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index 5f95a45dc..eca1d414d 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -12,6 +12,8 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; import { BUNDLE_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; +import { isSessionEvidence } from "./sessionEvidence.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register canonical Evidence bundle comparison. */ export const registerBundleComparisonTool = ( @@ -21,12 +23,7 @@ export const registerBundleComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = bundleComparisonInputSchema.parse(input); const sourceIds = [ @@ -35,7 +32,11 @@ export const registerBundleComparisonTool = ( ...parsed.right.records.map(({ evidence_id: id }) => id), ]), ].sort(); - if (sourceIds.some((evidenceId) => !session.hasEvidence(evidenceId))) + if ( + [...parsed.left.records, ...parsed.right.records].some( + (evidence) => !isSessionEvidence(session, evidence), + ) + ) return toCallToolResult( err( new EvidenceIntegrityError( diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 22760569c..6a509cee0 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -12,6 +12,7 @@ import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import { toCallToolResult } from "./toolResult.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; interface EvidenceToolRegistration { readonly logger: Logger; @@ -29,12 +30,7 @@ export const registerEvidenceTools = ( for (const contract of contracts) { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), async (input, context) => { const parameters = jsonObject(contract.inputSchema.parse(input)); const execution = await logToolExecution( diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index aa7f782e0..ee8f5f991 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -7,13 +7,13 @@ import { functionComparisonInputSchema, } from "../domain/functionComparison.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; -import { err } from "../domain/result.js"; import { FUNCTION_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { resolveSessionEvidence } from "./sessionEvidence.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register explicit Evidence-backed function comparison. */ export const registerFunctionComparisonTool = ( @@ -23,31 +23,18 @@ export const registerFunctionComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = functionComparisonInputSchema.parse(input); - const leftIds = evidenceIds(parsed.left); - const rightIds = evidenceIds(parsed.right); - const missing = [...leftIds, ...rightIds].filter( - (evidenceId) => !session.hasEvidence(evidenceId), - ); - if (missing.length > 0) - return toCallToolResult( - err( - new EvidenceIntegrityError( - "Function comparison input Evidence is not present in this session", - ), - ), - contract, - ); + const left = resolveSessionEvidence(session, parsed.left); + if (!left.ok) return toCallToolResult(left, contract); + const right = resolveSessionEvidence(session, parsed.right); + if (!right.ok) return toCallToolResult(right, contract); + const leftIds = evidenceIds(left.value); + const rightIds = evidenceIds(right.value); const comparison = compareFunctions( - parsed.left, - parsed.right, + left.value, + right.value, parsed.offset, parsed.limit, ); diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index de647ecf9..c7a45ec49 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -16,6 +16,7 @@ import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; import { err } from "../domain/result.js"; import { PROCESS_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register deterministic process-capture comparison and contradiction tracking. */ export const registerProcessComparisonTool = ( @@ -25,12 +26,7 @@ export const registerProcessComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = z .object({ diff --git a/src/server/sessionEvidence.ts b/src/server/sessionEvidence.ts new file mode 100644 index 000000000..d0cef780c --- /dev/null +++ b/src/server/sessionEvidence.ts @@ -0,0 +1,45 @@ +import canonicalize from "canonicalize"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { parseEvidence, type Evidence } from "../domain/evidence.js"; +import { err, ok, type Result } from "../domain/result.js"; + +type EvidenceAuthorityResult = Result; + +/** Resolve caller-supplied records to their immutable session-owned values. */ +export const resolveSessionEvidence = ( + session: BinarySessionPort, + input: Evidence | readonly Evidence[], +): EvidenceAuthorityResult => { + const supplied = Array.isArray(input) ? input : [input]; + const authoritative: Evidence[] = []; + for (const item of supplied) { + const parsed = parseEvidence(item); + const owned = session.evidenceById(parsed.evidence_id); + if (owned === undefined || canonicalJson(owned) !== canonicalJson(parsed)) + return err( + new EvidenceIntegrityError( + "Comparison input does not match its session-owned Evidence", + ), + ); + authoritative.push(owned); + } + return ok(authoritative); +}; + +/** Check exact ownership for a single Evidence record. */ +export const isSessionEvidence = ( + session: BinarySessionPort, + input: Evidence, +): boolean => { + const owned = session.evidenceById(input.evidence_id); + return owned !== undefined && canonicalJson(owned) === canonicalJson(input); +}; + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Evidence could not be canonicalized"); + return encoded; +}; diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts new file mode 100644 index 000000000..4a852ed84 --- /dev/null +++ b/src/server/toolRegistrationOptions.ts @@ -0,0 +1,9 @@ +import type { ToolContract } from "../contracts/toolContracts.js"; + +/** Project the public contract fields accepted by MCP tool registration. */ +export const toolRegistrationOptions = (contract: ToolContract) => ({ + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, +}); diff --git a/tests/artifactInventorySnapshot.test.ts b/tests/artifactInventorySnapshot.test.ts new file mode 100644 index 000000000..32aa95764 --- /dev/null +++ b/tests/artifactInventorySnapshot.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + paginateArtifactInventory, + scanArtifactInventory, +} from "../src/application/ArtifactInventory.js"; + +const LIMITS = { + maxEntries: 10, + maxTotalBytes: 1_024, + maxEntryBytes: 512, + maxCompressionRatio: 10, + maxDepth: 8, + maxPathBytes: 256, +} as const; + +describe("artifact inventory snapshot", () => { + it("projects every page from the same completed scan", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-inventory-snapshot-")); + await writeFile(join(root, "a.txt"), "a"); + await writeFile(join(root, "b.txt"), "b"); + const snapshot = await scanArtifactInventory(root, LIMITS); + + await rm(join(root, "b.txt")); + const first = paginateArtifactInventory(snapshot, { + nodeOffset: 0, + nodeLimit: 1, + occurrenceOffset: 0, + occurrenceLimit: 2, + edgeOffset: 0, + edgeLimit: 1, + }); + const second = paginateArtifactInventory(snapshot, { + nodeOffset: 1, + nodeLimit: 10, + occurrenceOffset: 2, + occurrenceLimit: 2, + edgeOffset: 1, + edgeLimit: 10, + }); + + expect(first.manifest).toEqual(second.manifest); + expect( + [...first.occurrences.items, ...second.occurrences.items].map( + ({ logical_path: path }) => path, + ), + ).toEqual([".", "a.txt", "b.txt"]); + expect(first.occurrences.total).toBe(3); + expect(second.occurrences.total).toBe(3); + }); +}); diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts index 23d07ba0d..8c6e8bbe4 100644 --- a/tests/artifactMcp.test.ts +++ b/tests/artifactMcp.test.ts @@ -16,6 +16,45 @@ import { createServer } from "../src/server/createServer.js"; import { observed } from "./fixtures/analysisExecution.js"; describe("artifact graph MCP integration", () => { + it("rejects altered payloads that reuse session Evidence IDs", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.left); + session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.right); + const server = createServer(session, session); + const client = new Client({ + name: "artifact-authority-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_artifacts", + arguments: { + ...ARTIFACT_COMPARISON_EXAMPLE, + left: { + ...ARTIFACT_COMPARISON_EXAMPLE.left, + limitations: ["caller altered this record"], + }, + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + it("rejects comparison Evidence that is not owned by the session", async () => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), diff --git a/tests/bundleComparisonMcp.test.ts b/tests/bundleComparisonMcp.test.ts index b250c029f..767d5210b 100644 --- a/tests/bundleComparisonMcp.test.ts +++ b/tests/bundleComparisonMcp.test.ts @@ -23,6 +23,45 @@ const sourceEvidence = (label: string) => ); describe("bundle comparison MCP integration", () => { + it("rejects altered records that reuse session Evidence IDs", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const leftRecord = sourceEvidence("left-authority"); + const rightRecord = sourceEvidence("right-authority"); + session.recordEvidence(leftRecord); + session.recordEvidence(rightRecord); + const altered = { + ...leftRecord, + limitations: ["caller altered this record"], + }; + const server = createServer(session, session); + const client = new Client({ name: "bundle-authority-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_bundles", + arguments: { + left: createEvidenceBundle([altered]), + right: createEvidenceBundle([rightRecord]), + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + it("rejects unknown histories that are not owned by the session", async () => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), diff --git a/tests/completionManifest.test.ts b/tests/completionManifest.test.ts deleted file mode 100644 index dfeb7521c..000000000 --- a/tests/completionManifest.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; -import { promisify } from "node:util"; - -import { afterEach, describe, expect, it } from "vitest"; - -const exec = promisify(execFile); -const manifestPath = "metadata/completion-manifest.json"; -let original: string | undefined; - -afterEach(async () => { - if (original !== undefined) await writeFile(manifestPath, original); - original = undefined; -}); - -describe("generated completion manifest", () => { - it("is deterministic, current, path-independent, and never promotes unknowns", async () => { - await exec(process.execPath, ["scripts/generate-completion-manifest.mjs"]); - const first = await readFile(manifestPath, "utf8"); - await exec(process.execPath, ["scripts/generate-completion-manifest.mjs"]); - expect(await readFile(manifestPath, "utf8")).toBe(first); - await expect( - exec(process.execPath, [ - "scripts/generate-completion-manifest.mjs", - "--check", - ]), - ).resolves.toBeDefined(); - expect(first).not.toContain(process.cwd()); - const manifest = JSON.parse(first) as { - outcomes: { pass: unknown[]; unsupported: unknown[]; unknown: unknown[] }; - }; - expect(manifest.outcomes.pass).toEqual([]); - expect(manifest.outcomes.unsupported).toHaveLength(1); - expect(manifest.outcomes.unknown).toHaveLength(1); - }); - - it("check mode detects manual tampering without rewriting it", async () => { - original = await readFile(manifestPath, "utf8"); - const tampered = `${original.trimEnd()} `; - await writeFile(manifestPath, tampered); - await expect( - exec(process.execPath, [ - "scripts/generate-completion-manifest.mjs", - "--check", - ]), - ).rejects.toThrow(/stale or tampered/u); - expect(await readFile(manifestPath, "utf8")).toBe(tampered); - }); -}); diff --git a/tests/functionComparisonMcp.test.ts b/tests/functionComparisonMcp.test.ts index 31c83b9c4..b75eaca1f 100644 --- a/tests/functionComparisonMcp.test.ts +++ b/tests/functionComparisonMcp.test.ts @@ -97,4 +97,43 @@ describe("function comparison MCP integration", () => { ]); } }); + + it("rejects altered payloads that reuse session Evidence IDs", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left); + session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.right); + const server = createServer(session, session); + const client = new Client({ + name: "function-authority-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_functions", + arguments: { + left: { + ...FUNCTION_COMPARISON_EXAMPLE.left, + limitations: ["caller altered this record"], + }, + right: FUNCTION_COMPARISON_EXAMPLE.right, + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); }); diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index 4dd4ed9cd..b00106b38 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -12,6 +12,7 @@ import { probeProcessCaptureCapability, ProcessCaptureError, } from "../src/application/ProcessHarness.js"; +import { snapshotRoots } from "../src/application/FilesystemSnapshot.js"; import { authorizeProcessScenario, compareProcessCaptures, @@ -31,6 +32,20 @@ describe("process capture domain", () => { working_directory: "/tmp", }; + it("cancels filesystem snapshots before traversing declared roots", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + snapshotRoots( + parseProcessScenario({ + ...base, + filesystem_roots: ["/tmp"], + }), + controller.signal, + ), + ).rejects.toMatchObject({ name: "AbortError" }); + }); + it("parses bounded scenarios and rejects unordered events", () => { expect(parseProcessScenario(base).timeout_ms).toBe(30_000); expect(() => diff --git a/tests/referenceSourceImport.test.ts b/tests/referenceSourceImport.test.ts index 900f527c0..087ad9713 100644 --- a/tests/referenceSourceImport.test.ts +++ b/tests/referenceSourceImport.test.ts @@ -118,7 +118,7 @@ describe("reference source import", () => { const result = await importTree(root, parent); expect(result.ok).toBe(true); if (!result.ok) throw result.error; - expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: true }); + expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: null }); } finally { await rm(parent, { recursive: true, force: true }); } diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index e8e0bb475..bbb48f542 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -58,7 +58,7 @@ describe("production stdio runtime", () => { ); expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON"); expect(stderr).not.toContain(fixturePath); - }, 20_000); + }, 15_000); it("honors the configured kind for an initial database target", async () => { const transport = new StdioClientTransport({ diff --git a/tests/safeOutputTree.test.ts b/tests/safeOutputTree.test.ts index 9333bfb0b..ab8ed3665 100644 --- a/tests/safeOutputTree.test.ts +++ b/tests/safeOutputTree.test.ts @@ -1,4 +1,5 @@ -import { access, mkdtemp, readdir } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { access, mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; @@ -16,19 +17,19 @@ const LIMITS = { maxPathBytes: 256, } as const; -describe("safe artifact output transaction", () => { +describe("safe artifact output tree", () => { it("removes only its owned tree after digest failure and proves absence", async () => { const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-")); const output = join(parent, "published"); - const transaction = await SafeOutputTree.create(output, LIMITS); + const tree = await SafeOutputTree.create(output, LIMITS); await expect( - transaction.write( + tree.write( "nested/file.txt", Readable.from(Buffer.from("unexpected")), "0".repeat(64), ), ).rejects.toThrow(/disagrees/u); - expect(await transaction.rollback()).toMatchObject({ + expect(await tree.rollback()).toMatchObject({ attempted: true, verified: true, residualPaths: [], @@ -36,4 +37,23 @@ describe("safe artifact output transaction", () => { await expect(access(output)).rejects.toThrow(); expect(await readdir(parent)).toEqual([]); }); + + it("publishes files while building and preserves them after sealing", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-")); + const output = join(parent, "published"); + const tree = await SafeOutputTree.create(output, LIMITS); + const bytes = Buffer.from("visible before seal"); + const digest = createHash("sha256").update(bytes).digest("hex"); + + await tree.write("file.txt", Readable.from(bytes), digest); + expect(await readFile(join(output, "file.txt"), "utf8")).toBe( + "visible before seal", + ); + + await tree.commit(); + expect(await tree.rollback()).toMatchObject({ attempted: false }); + expect(await readFile(join(output, "file.txt"), "utf8")).toBe( + "visible before seal", + ); + }); }); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 79cbaec08..7ef75c713 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -170,7 +170,7 @@ describe("target-free MCP lifecycle", () => { expect( text(await mcp.callTool({ name: "binary_session", arguments: {} })), ).toContain('"open": false'); - }, 20_000); + }, 10_000); it("records approved process residuals in the unknown registry", async () => { const session = new BinarySession(() => client("fixture", []));