diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f8f838133..abf73b862d 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 6287de4fec..de9d3297bd 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 0000000000..cc237cdb1d --- /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 48216e4ed2..795959ffbb 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 58c371aa09..a951090265 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 0000000000..5ca72288c0 --- /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 0eca8aecba..d89bd084ce 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 b51c26a2c3..7ae7845655 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 5ef7f6888d..ff18bf4c79 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 1d9e031be7..72414cdabb 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 0000000000..0d0a130188 --- /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 0000000000..3f09dfb0f5 --- /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 0000000000..56bf13751c --- /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 cd0d7c3e17..ebf44969a6 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 0000000000..5eb3bc0d97 --- /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 740c4d260c..cc3c912a88 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 3cda3e8618..aa9cdf9497 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 0000000000..76860677fa --- /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 0000000000..be2cd2915d --- /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 d2007a8b08..942537d086 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 0000000000..8b1dd48668 --- /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 e34878ccc1..f3e772d391 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 1283817eba..21b4097f6d 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 0000000000..121edd50c1 --- /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 0000000000..dbd96fea0c --- /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 0000000000..df307f4e83 --- /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 0000000000..1e815f865d --- /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 0000000000..023c09d113 --- /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 0000000000..2cf527cee7 --- /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 0000000000..d10a5282d9 --- /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 0000000000..3ea671c7a5 --- /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 0000000000..e30e475c0e --- /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 36685bb3a9..a17fc200fe 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 c49580b173..dcef837798 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 0000000000..a45f364935 --- /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 0000000000..bd16fe8b58 --- /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 0000000000..0b1d5720b5 --- /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 0000000000..41865e5075 --- /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 0000000000..e6cc745241 --- /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 0000000000..c29436de90 --- /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 0000000000..a2e67a3fc1 --- /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 0000000000..9723f0650d --- /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 0000000000..088c3af503 --- /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 448752d2b8..b976dc0316 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 0830c3936f..2d13d61bc2 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 0000000000..d8e80ed80d --- /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 0000000000..c42af930d3 --- /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 3b36e5fe97..2167d3ac5d 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 0000000000..bc7fa4e574 --- /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 0000000000..c7ec1e3f47 --- /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 0000000000..4ed43be981 --- /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 0000000000..2144cfac68 --- /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 0000000000..d6d4529601 --- /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 bf6d213d2f..20d7661e32 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 f2bd991b35..ac323bd715 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 0000000000..b854df325b --- /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 0000000000..0df4803d5a --- /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 0000000000..10accebb80 --- /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 0000000000..652efff925 --- /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 749193fd43..c777b565ad 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 0000000000..e11fa47b20 --- /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 0000000000..f5c178e2eb --- /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 0000000000..7c7b316d31 --- /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 1ce11aa290..d53376e62b 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 74a863065e..8e487cfbe4 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 7b4a9ce03c..b278fe5ec4 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 0000000000..ccfbe99eb5 --- /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 0000000000..ea7d6cf731 --- /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 0000000000..f51945c11b --- /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 0000000000..224414a774 --- /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 0000000000..8cbcc00bcc --- /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 a1870e27d9..79f5511202 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 0000000000..7e93dc3b66 --- /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 a542574693..ed8554b862 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 0000000000..e7b541a683 --- /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 0000000000..5ade6219be --- /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 0000000000..83c523697d --- /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 0000000000..df3ef1bfee --- /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 0000000000..25950f7eaa --- /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 0000000000..3df3170fc8 --- /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 0000000000..f05c9aedaa --- /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 0000000000..baf57bd8f9 --- /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 0000000000..8d0b4b964c --- /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 0000000000..70f1157dff --- /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 9a1d6f40b0..2f7d1f448b 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 1bfd9101a6..04aa73ec7b 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 7f014c32b2..9adae876e9 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 d309317117..dc9889101b 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 0538c2e443..4956551cbd 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 0000000000..42e8b5a7df --- /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 0000000000..130505ff91 --- /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 0000000000..0fcfe07a34 --- /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 0000000000..b00d72f356 --- /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 0000000000..c72b312dd7 --- /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 0000000000..04f1817d72 --- /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 0000000000..263affada4 --- /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 0000000000..879f78396c --- /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 0000000000..b077090246 --- /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 0000000000..c345f46fec --- /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 0000000000..8d7f8cc991 --- /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 0000000000..94c6cec11a --- /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 0000000000..64bffdb389 --- /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 0000000000..968d98e821 --- /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 0000000000..6d601e8514 --- /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 0000000000..45986b678f --- /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 bad8e9ecbe..b1020dbd2f 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 0000000000..99a761c425 --- /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 0000000000..3f85545c67 --- /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 0000000000..f56069dce4 --- /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 0000000000..5f95a45dca --- /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 e3fe133d44..c287d407ea 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 0000000000..22760569c4 --- /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 0000000000..aa7f782e0e --- /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 0000000000..956be39f34 --- /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 0000000000..10c37b5a91 --- /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 0df413f9ee..d633c65646 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 0000000000..de647ecf9b --- /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 7b504aeae6..7eeb02dc91 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 0000000000..72a9164bdd --- /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 8a36f70717..cd965b4332 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 0000000000..fd06838d12 --- /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 0000000000..23d07ba0d4 --- /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 0000000000..f6a2a9417f --- /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 9ae1889bda..7a0b2e214f 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 baaa4a07a3..330c41576e 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 d39499d937..e843180531 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 0000000000..69e0f5a3eb --- /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 0000000000..b250c029f3 --- /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 0000000000..7bdc062973 --- /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 0000000000..41f227bed3 --- /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 0000000000..dfeb7521cb --- /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 0000000000..bf4d9a3d04 --- /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 cba55c1173..eb62bea699 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 536f75fa7c..eea4777a71 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 2aacfc3c3f..6a90801143 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 f0a55fb764..cf7b261030 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 0000000000..2a087c6b84 --- /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 4952023c92..02f30be11b 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 0000000000..28546ac244 --- /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 0000000000..94fe941fa4 --- /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 84f63216a8..cea23a29c2 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 0e182ba7d1..c9db990c7f 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 0000000000..9c6b35d4f4 --- /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 0000000000..6a4b7a0112 --- /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 0000000000..84611393ff --- /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 0000000000..4ef562401b --- /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 0000000000..7366ffe71d --- /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 0000000000..b8a8d411b6 --- /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 0000000000..fc18405c82 --- /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 0000000000..2889de7387 --- /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 0000000000..ff906502ca --- /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 0000000000..0d48648458 --- /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 0000000000..0864bbae5b --- /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 0000000000..31c83b9c44 --- /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 2793b94b28..4f9f1833bd 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 75d016f67a..14a6c2262e 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 57336ca63c..70e4efa791 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 65dc85dddd..73e4106401 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 0000000000..5bda642966 --- /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 0000000000..95ad3389a5 --- /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 c5d137a4d5..9aaa1a8371 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 482a49a0e5..4dd4ed9cd9 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 0000000000..04e2abfdb2 --- /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 11b0468e2a..54844b7dbf 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 107013dace..0eac0b074f 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 0000000000..0140ac591e --- /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 0000000000..801d071207 --- /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 0000000000..e00e8c66c7 --- /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 0000000000..900f527c07 --- /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 0000000000..e1aa3746c7 --- /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 0000000000..db3bf9f618 --- /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 a1d7dc798e..e8e0bb4752 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 0000000000..9333bfb0bf --- /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 d3333ce54d..79cbaec080 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 0000000000..97c981272f --- /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 0000000000..d4b5016db5 --- /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 aabaae7999..d0ff3f8e06 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 0000000000..cffd8e73a8 --- /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, + }); + }); +});