From 293cea772334724275185918fdfb96ec012e2688 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:45:26 +0000 Subject: [PATCH] Simplify and harden investigation workflows --- .github/workflows/ci.yml | 12 - README.md | 4 +- metadata/completion-manifest.json | 450 ------------------- package-lock.json | 6 +- package.json | 8 +- scripts/generate-completion-manifest.mjs | 283 ------------ src/application/ArtifactExtraction.ts | 72 +-- src/application/ArtifactInventory.ts | 57 ++- src/application/FilesystemSnapshot.ts | 70 ++- src/application/ProcessHarness.ts | 66 ++- src/application/ProcessSampling.ts | 34 +- src/application/ReferenceSourceImport.ts | 2 +- src/application/ReferenceSourceVcsAdapter.ts | 64 ++- src/artifacts/ArtifactProvider.ts | 2 +- src/artifacts/ArtifactReader.ts | 2 +- src/artifacts/SafeOutputTree.ts | 12 +- src/domain/artifactGraph.ts | 2 +- src/domain/referenceSourceGraph.ts | 2 +- src/server/registerArtifactComparisonTool.ts | 32 +- src/server/registerBundleComparisonTool.ts | 15 +- src/server/registerEvidenceTools.ts | 8 +- src/server/registerFunctionComparisonTool.ts | 35 +- src/server/registerProcessComparisonTool.ts | 8 +- src/server/sessionEvidence.ts | 45 ++ src/server/toolRegistrationOptions.ts | 9 + tests/artifactInventorySnapshot.test.ts | 55 +++ tests/artifactMcp.test.ts | 39 ++ tests/bundleComparisonMcp.test.ts | 39 ++ tests/completionManifest.test.ts | 49 -- tests/functionComparisonMcp.test.ts | 39 ++ tests/processCapture.test.ts | 15 + tests/referenceSourceImport.test.ts | 2 +- tests/runtime.test.ts | 2 +- tests/safeOutputTree.test.ts | 30 +- tests/sessionMcp.test.ts | 2 +- 35 files changed, 531 insertions(+), 1041 deletions(-) delete mode 100644 metadata/completion-manifest.json delete mode 100644 scripts/generate-completion-manifest.mjs create mode 100644 src/server/sessionEvidence.ts create mode 100644 src/server/toolRegistrationOptions.ts create mode 100644 tests/artifactInventorySnapshot.test.ts delete mode 100644 tests/completionManifest.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abf73b862..3f8f83813 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,18 +79,6 @@ jobs: - run: npm ci - run: npm run scan:todos - completion-metadata: - name: Completion Metadata Drift - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run completion:check - test: name: Test (coverage + timing) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 1b1e9b837..2ff10e445 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,6 @@ rea import-reference-source /absolute/path/to/source Exports never replace an existing file unless `--overwrite` is explicit. Imports are size/depth bounded, validate every Evidence v2 ID and manifest, and never execute bundle content. -Completion metadata is verifier-owned. Run `npm run completion:generate` after changing contracts, schemas, providers, scenarios, package metadata, verifier inputs, or the managed skill. CI runs `npm run completion:check`; it detects stale or tampered hashes and never rewrites a branch. Unsupported and unknown outcomes remain separate from passes, and a pass or failure requires indexed Evidence v2 IDs. - ## One prompt, a full investigation ```text @@ -187,7 +185,7 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its | Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations | | Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery | | Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing | -| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected transactional extraction | +| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree | | Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle | The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture. diff --git a/metadata/completion-manifest.json b/metadata/completion-manifest.json deleted file mode 100644 index d1febdcf3..000000000 --- a/metadata/completion-manifest.json +++ /dev/null @@ -1,450 +0,0 @@ -{ - "schema_version": 1, - "inputs": { - "artifacts": [ - { - "path": "bridge/hopper_bridge.py", - "sha256": "ca3a5029485b5ded221d4c0e3ccde91a06cb48246ca44d9cac35be298b1ace8d" - }, - { - "path": "package-lock.json", - "sha256": "29e6cf54c4e58f0fefc98f8454715cb89de51bbc8ddded7b2b3906e7d7ba6d7f" - }, - { - "path": "scripts/verify-conformance-fixtures.mjs", - "sha256": "7d0a3cb47332076c28a2214833459f78b2809d69b624b7e22d53bd130531a545" - }, - { - "path": "scripts/verify-package.mjs", - "sha256": "b3809e3b7593fcaf16c9f962fbfaea105caca168c2ed8fd07336804ebb581174" - }, - { - "path": "scripts/verify-real-hopper.mjs", - "sha256": "dfcb025fc12e2b6b6bc1e7984d797471b9239f3abf4361a9e1118cac0261fe1d" - }, - { - "path": "skills/rea-analysis/SKILL.md", - "sha256": "d5a3a5329e7db590a06f5b0b1af6551704579b78711d9c6a229715b5a832906e" - }, - { - "path": "src/application/AnalysisProvider.ts", - "sha256": "0871b9a0bcb1d29c4442d904ee4c1932a6a598cf296811529de4bdebf69f729f" - }, - { - "path": "src/application/ArtifactExtraction.ts", - "sha256": "6683c35a361ac680b1f9ec557376b80acbde65a1418315f5359afe4e0e0ff7ff" - }, - { - "path": "src/application/ArtifactGraphConstruction.ts", - "sha256": "151651a3df972591f556e3546a536a5cc87fdb1d3f38d52ea58ad8476e1cf9ec" - }, - { - "path": "src/application/ArtifactInventory.ts", - "sha256": "cc8109ff0b4b64d379211d997be50c936854e9aa1bba27f5180d586d709512b7" - }, - { - "path": "src/application/CompositeProvider.ts", - "sha256": "af2d8565d442665a3e697886d880998336e1a79043152858d83ce4aed51eb28c" - }, - { - "path": "src/application/EvidenceLedger.ts", - "sha256": "8650bc0965bcd4e66cca78465d9387d5f436ab9b9064fb50b012d48048862875" - }, - { - "path": "src/application/DirectAnalysis.ts", - "sha256": "2422397e8670bd6807f1c28c4ede264cdb6c9cfd089e3ae8fae2e3f19f7da4c0" - }, - { - "path": "src/application/ProcessSampling.ts", - "sha256": "032c65bb3ee269fc865ba5da8aed651a4edd4b677644ba7893da2e486d3d8a7c" - }, - { - "path": "src/application/ReferenceSourceImport.ts", - "sha256": "9303a27e47d0e20f66c14b3586638b811146a44d0dc40d852c440c398e323b51" - }, - { - "path": "src/application/ReferenceSourceImportEntries.ts", - "sha256": "078272e9cdbd86166c89bffca03e3088b0fdc524e2a907c3d21814dca0229377" - }, - { - "path": "src/application/ReferenceSourceImportPolicy.ts", - "sha256": "70d2c4805dc4e1d7b2527a55a13c11da61374daebe203f3b94656fe9f9d614dd" - }, - { - "path": "src/application/ReferenceSourceImportTypes.ts", - "sha256": "cbb3ebd4d6a8e624fe8c5ef7b92fee19b60a9abe5a284a9e18c0f2a04bc4d8cd" - }, - { - "path": "src/application/ReferenceSourceVcsAdapter.ts", - "sha256": "cc0cb9333a77c17da55c4f55f81426819686722b5ccf3be3a48208f7eb0ac530" - }, - { - "path": "src/application/RealHopperAssertions.ts", - "sha256": "3ac4c9a6432c7bfc1f9f7733424edb087d8703f3b46a8cc9e2063bd7c9026e7c" - }, - { - "path": "src/application/runtime.ts", - "sha256": "af814fb413b083c07340939d49bb7db090656ff26fb1aebace53d4ee422d1652" - }, - { - "path": "src/contracts/nativeToolContracts.ts", - "sha256": "6c2a6120f842693430b200ec0ff1a384132d0f810d99b9e451a5fbb4fa48da8e" - }, - { - "path": "src/contracts/artifactToolContracts.ts", - "sha256": "92c6b74125c8312e7cffe9c8927bd9092a9a16696c5e57e21b3ab11a789664b6" - }, - { - "path": "src/contracts/artifactComparisonExample.ts", - "sha256": "77b9eaa5deda38e11af2dca7e79ed43397b3ed57407169dc2799e300d6a9dc4f" - }, - { - "path": "src/contracts/functionComparisonExample.ts", - "sha256": "c64d9166b9377e5f305dfdc5468a66fceb143919e11fd8145e4878ab591d28bd" - }, - { - "path": "src/contracts/investigationExamples.ts", - "sha256": "d4abbe096c1463918fb3ced25420b81f9d06333094b2d4563f777cfa3adefbd4" - }, - { - "path": "src/contracts/toolContractExamples.ts", - "sha256": "b03fa4ea9bbc5f5fcace06b4863d3121c28e6ef9e3c3b2e304402f837d328c9d" - }, - { - "path": "src/contracts/processCaptureExample.ts", - "sha256": "68e52c7378b9b7da34cb31a93eebd44f6425ea40971b589f0399afefbc5709b3" - }, - { - "path": "src/contracts/enhancedInputs.ts", - "sha256": "76bc9bc963a7a65800fe4ecbfec17c2f57410a42c116b6f5fe65a3b033fdb29e" - }, - { - "path": "src/contracts/toolContracts.ts", - "sha256": "de3252f67aae3514b478b2d5a3e8dffa3dcc1cb96eaa0f4ba3cad1267fdc92e3" - }, - { - "path": "src/contracts/toolOutputSchemas.ts", - "sha256": "40e69c1e2aad9708eee9a456f764c4125a6ffd8d031e5e501a7329734cdb9d99" - }, - { - "path": "src/contracts/unknownContractExamples.ts", - "sha256": "11a777f711d8876391ea7a22f3e29c3b461b0be048e9ddc3c6cf506324c6844c" - }, - { - "path": "src/domain/evidence.ts", - "sha256": "4603f33b7b20c24ee36440b66ed89fa57f5ffeb7aaf9a8a1be0dc5bcfc04ad27" - }, - { - "path": "src/domain/artifactGraph.ts", - "sha256": "7ffb5692fa22ecda416b5662788d1f530a320f0ec82706bc960d77213d1f7cbd" - }, - { - "path": "src/domain/artifactComparison.ts", - "sha256": "35e652cf283e5c58584dfc6e2d63d6c0f18e01e9bbea45404cfbbdd820519732" - }, - { - "path": "src/domain/artifactInventoryEvidence.ts", - "sha256": "c605faf52d674ed18568606cdc98fd9cbbd48228779d6afd1979b672b4ed237a" - }, - { - "path": "src/domain/functionComparison.ts", - "sha256": "b55bdfd7ad8581d793ff1816d6b9142efa45a00ec1161e9c88ab093c0cbbe76a" - }, - { - "path": "src/domain/functionComparisonNormalization.ts", - "sha256": "8ec6893102e1f3326415934d801f33ceac92dc0f04b01afe5b6527fa2866de16" - }, - { - "path": "src/domain/functionComparisonResults.ts", - "sha256": "01941b2174c7b206255058387bba847c6c313ccec09870e331c8594aca8d2cb5" - }, - { - "path": "src/domain/functionComparisonSchemas.ts", - "sha256": "ca40e147bd4a754978766f88d8bb3ad51eff22bce1a6194dc483ae3765d80ba8" - }, - { - "path": "src/domain/bundleComparison.ts", - "sha256": "83f6a56479eda9253e726c88c85fc4eef466f1c5eee6dd6cc94b54164c5e1c3e" - }, - { - "path": "src/domain/changedBehavior.ts", - "sha256": "8beda9f951f497a3947c234fc4f756cd6f908fba49c8cd3f1ff0986faa8ac912" - }, - { - "path": "src/domain/callPath.ts", - "sha256": "f08c5bf656efc0f569ca3895171f9473cb1c62e5381dc890209002d0f379b940" - }, - { - "path": "src/domain/staticRuntimeCorrelation.ts", - "sha256": "c38cc12c468ab1b9899e1f431969dc0936d6273ad6ef585f56a63efd172f32b5" - }, - { - "path": "src/domain/reconstructionVerification.ts", - "sha256": "b8dcc08f19e4bbcecda80a0ee92cd02add105052b93956026b25200049cc5ebd" - }, - { - "path": "src/domain/reconstructionVerificationSchemas.ts", - "sha256": "0c35560e358ff0b00f3dbcacc40a198b6a561eaaf23fb5c8a50eac35d4031207" - }, - { - "path": "src/domain/reconstructionUnknowns.ts", - "sha256": "5a174aa799877e4aa171fbfbfb3d19e5e4ee1737d2049d0ce7c1297b651f6b64" - }, - { - "path": "src/domain/referenceSourceGraph.ts", - "sha256": "4900f382215ad95e70ece8efe25f1ebe3b978fd262da48253a1808a779a93724" - }, - { - "path": "src/domain/referenceSourceClassification.ts", - "sha256": "bc2876db3b58dbd624561808be25d2bf666aed9e6278994d5f9a74c92a83c898" - }, - { - "path": "src/domain/referenceSourceImportParsing.ts", - "sha256": "ebdfd576d9b1a151424c633c588309a8c1cc47d5247380b2a5956c636e147aa7" - }, - { - "path": "src/domain/referenceSourcePolicy.ts", - "sha256": "f7e49b8ffdb231b2f4fd18a4efdc50d87187a45915a02fa3ee6eb4d9d0dd55d4" - }, - { - "path": "src/domain/functionDossierEvidence.ts", - "sha256": "74310ca13cd3c3b53f658899608482e15e32853ae2f0dd12f8093e3ce1d06ea3" - }, - { - "path": "src/domain/evidenceBundle.ts", - "sha256": "6f75b6f6b2ca583a33c003a7fd3575015021fbf3290284f86474c0af2ebfbd3a" - }, - { - "path": "src/domain/nativeInspection.ts", - "sha256": "b800c075d10033555cd5279cd8e019bdd935fe7389b2919e148bebdfc6f6b52e" - }, - { - "path": "src/domain/processCapture.ts", - "sha256": "cfdbac456aa868d06c50a0b410c181ed19892521f2d53d8bb254fcc1b92bbb3f" - }, - { - "path": "src/domain/residualUnknown.ts", - "sha256": "ab99fb3d8129655531e7791c2f69f4283ad5d5cdbd0f36ffcb7f2c7441428ac8" - }, - { - "path": "src/server/sessionToolPolicies.ts", - "sha256": "c972c399d40aef70510ad8fe33ec30eac046095195c1fb2185636f32fd062067" - }, - { - "path": "src/server/registerEnhancedTools.ts", - "sha256": "5e3b912090d77a6a03dbd4824163bbc2140b6f091fb302161c3ddb12f2d9cd7f" - }, - { - "path": "src/server/registerEvidenceTools.ts", - "sha256": "b295f94cacc911e416240c9bd46ccf3438ebc28169286e08e14937f02b8403cf" - }, - { - "path": "src/server/registerArtifactTools.ts", - "sha256": "67a9245471a1c5506a861925265dffc0b6cafa034ad2cb55996050b33497c9ed" - }, - { - "path": "src/server/registerArtifactComparisonTool.ts", - "sha256": "1cac756ded85945b31abafa956e01ed8732f472537687690fe6126a4ee884e32" - }, - { - "path": "src/server/registerFunctionComparisonTool.ts", - "sha256": "79ba262147e695ff0f84eb438807e574decb9cffd6e37189c7512b1ef0e7ac77" - }, - { - "path": "src/server/registerBundleComparisonTool.ts", - "sha256": "8ca624ddeb0d53ffa8a776954cb02e5a7612d5bfd435d11f07f19af708cca889" - }, - { - "path": "src/server/registerInvestigationTools.ts", - "sha256": "406d128beb86b1587f1eb4cd8292ab4b5eeb0e388cb1ad9ca10e7da67f36769b" - }, - { - "path": "src/server/recordDerivedEvidence.ts", - "sha256": "d5bcd67234b62483d4761520f1e1e8189940c81d6059816cc179cd5da5ff1cfe" - }, - { - "path": "src/server/registerNativeTools.ts", - "sha256": "96fe0e62b3d60ad3c74d3abcadc746ff6359a71cd98e6b922b5b6dbeb3b4423f" - }, - { - "path": "src/server/registerProcessComparisonTool.ts", - "sha256": "0771f98bf838bf5005c47534473d5eaa80b89d00b95d003c8ffa6e44764cf763" - }, - { - "path": "src/server/registerSessionTools.ts", - "sha256": "31decb6704f75cfc1e1a60a9ac1987a20a8530817ec919a679547d93606a94cd" - }, - { - "path": "src/native/CommandRunner.ts", - "sha256": "161ce2fea8b0dda8595b878889f78925e3a157c008ad0baaec7fafea584d8f39" - }, - { - "path": "src/native/NativeMacOSProvider.ts", - "sha256": "ed5fe6f58bbeda1e1811583ed943b948a70f784f39170d35b466efd22353a088" - }, - { - "path": "src/native/NativeMachoInspection.ts", - "sha256": "515bf5cbdf686b282058d2f38a54dacfb6f753d2401100fbc3ada5b21a61978c" - }, - { - "path": "src/native/parsers/codesign.ts", - "sha256": "6b14a03a65fe9597d680e4ed228523cd36bf7b314816be21b59483bfcfacb638" - }, - { - "path": "src/native/parsers/demangle.ts", - "sha256": "73d45dcb695a39cb047db02ae7f811db770fa0df5281b6eadc7f364be32c9985" - }, - { - "path": "src/native/parsers/dyldInfo.ts", - "sha256": "76c8359906a119e14bbc916c6167aa5a83ddc275fa02881616af374a7e6cfbd2" - }, - { - "path": "src/native/parsers/lipo.ts", - "sha256": "54c6963d0a58c55a677ee7ef8896248dc99a437cad672290df8ec2c52a88badb" - }, - { - "path": "src/native/parsers/otool.ts", - "sha256": "201588a480e4cd4522e01fbedfa783245b1de0d573b17ad9a1e71816cc104a9d" - }, - { - "path": "src/native/parsers/plist.ts", - "sha256": "ef3c59896c438f801db10de82418a72b35786ceca3ec2ee2556bc54e48559fe5" - }, - { - "path": "src/artifacts/ArtifactPaths.ts", - "sha256": "4338bae33e495e6065d7cd9f021209ca72b9172f22084a00745d2abf6075d69f" - }, - { - "path": "src/artifacts/ArtifactProvider.ts", - "sha256": "8230a07b52378e545dd3988b984c6ed0c80ee0e81d538981ef3a70bb8a3a19e3" - }, - { - "path": "src/artifacts/ArtifactReader.ts", - "sha256": "8a792031af0f689fc3978e696f591fb67d0e17a454726d8d3aaef700ba256b45" - }, - { - "path": "src/artifacts/AsarArtifactReader.ts", - "sha256": "1363467183c4ac264bdaaccf55a7b6e48243ce85f138feffe901e34042d1b520" - }, - { - "path": "src/artifacts/DirectoryArtifactReader.ts", - "sha256": "7643b591cb21f6c11119625816e7a863c40fbc5c9925e20b9bda85d7d11c3973" - }, - { - "path": "src/artifacts/MachOSliceArtifactReader.ts", - "sha256": "8d8b03250dd3fc9f83fd3a155a561335acd6a4e88274e95974144b89030f8c63" - }, - { - "path": "src/artifacts/SafeOutputTree.ts", - "sha256": "0cc8db23d75cc19edc6072fc6b17f63074afe52d65f51a4599c268c2d233d8f6" - }, - { - "path": "src/artifacts/StreamBytes.ts", - "sha256": "af717ba28133a48c0bb95c415aab7432c221a3e1fbf73277d981d49059cae32a" - }, - { - "path": "src/artifacts/ZipArtifactReader.ts", - "sha256": "4d8581522d795578b3446ede27642b35c49879505cb019fe9b929073f7261139" - }, - { - "path": "src/reference/ReferenceSourceReaderEntries.ts", - "sha256": "90ebecd4847bfa335fa806d957067a9885f572b5392bbf6148ca9a7c0b068ca4" - }, - { - "path": "src/reference/ReferenceSourceReaderErrors.ts", - "sha256": "c168294b8e4279d2cfee2e5a2692df7a0af8cd819f2757683328fc553a9bd402" - }, - { - "path": "src/reference/ReferenceSourceReaderFile.ts", - "sha256": "3b95030cb842505d7742f7c225eb7141e0de42611a0d7b7cbec88394a27210e5" - }, - { - "path": "src/reference/ReferenceSourceReaderPaths.ts", - "sha256": "7b4b903f6115573b4219615133bd87d15a2a6a07a336d056145bcfbae1e3e87f" - }, - { - "path": "src/reference/ReferenceSourceReaderTypes.ts", - "sha256": "dbf56e376ea4106ae87153248a0207e6e7940d56f1009b59d536addc29c331a2" - }, - { - "path": "src/reference/ReferenceSourceReaderValidate.ts", - "sha256": "e14a620a4e5fa350635158c584989707d877dc25f9da793cec1d82e7667f85fe" - }, - { - "path": "src/reference/ReferenceSourceReader.ts", - "sha256": "75c83b5c233a4ceda4233e33a19f7e421796623d337cf3ac737e6a94d0126bdc" - } - ], - "package": { - "name": "rea-agents", - "version": "0.3.0" - }, - "providers": [ - { - "id": "hopper", - "version": null, - "authority": "shipped-artifact" - }, - { - "id": "native-macos", - "version": null, - "authority": "shipped-artifact" - }, - { - "id": "rea-artifact-graph", - "version": "1", - "authority": "shipped-artifact" - } - ], - "environments": [ - { - "id": "package-isolation", - "isolation": "process" - }, - { - "id": "real-hopper-macos", - "isolation": "process" - } - ], - "scenarios": [ - { - "id": "conformance-fixtures", - "version": 1 - }, - { - "id": "package-install", - "version": 1 - }, - { - "id": "real-hopper-two-binary", - "version": 2 - } - ], - "schemas": { - "evidence": 2, - "evidence_bundle": 2, - "completion": 1 - }, - "tool_count": 68 - }, - "evidence_index": [], - "outcomes": { - "pass": [], - "fail": [], - "unsupported": [ - { - "claim_id": "real-hopper-verification", - "reason": "Generated repository metadata contains no checked-in real-Hopper capture.", - "evidence_ids": [] - } - ], - "truncated": [], - "unknown": [ - { - "claim_id": "semantic-conformance-complete", - "reason": "Completion requires external verifier evidence and cannot be inferred from test counts.", - "evidence_ids": [] - } - ] - } -} diff --git a/package-lock.json b/package-lock.json index 795959ffb..42c75a8f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", - "write-file-atomic": "^8.0.0", + "write-file-atomic": "8.0.0", "ws": "8.21.0", "zod": "4.4.3" }, @@ -32,10 +32,10 @@ "rea": "scripts/rea.mjs" }, "devDependencies": { - "@fast-check/vitest": "^0.4.1", + "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.3", "@types/node": "24.10.1", - "@types/write-file-atomic": "^4.0.3", + "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", "husky": "9.1.7", diff --git a/package.json b/package.json index a95109026..fba3daf30 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,6 @@ "build:fixtures": "node scripts/build-conformance-fixtures.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", "verify:package": "npm run build && node scripts/verify-package.mjs", - "completion:generate": "node scripts/generate-completion-manifest.mjs", - "completion:check": "node scripts/generate-completion-manifest.mjs --check", "config:print": "node scripts/print-mcp-config.mjs", "docs:generate": "typedoc", "prepack": "npm run build", @@ -97,15 +95,15 @@ "node-pty": "1.1.0", "pino": "9.14.0", "plist": "5.0.0", - "write-file-atomic": "^8.0.0", + "write-file-atomic": "8.0.0", "ws": "8.21.0", "zod": "4.4.3" }, "devDependencies": { - "@fast-check/vitest": "^0.4.1", + "@fast-check/vitest": "0.4.1", "@modelcontextprotocol/client": "2.0.0-beta.3", "@types/node": "24.10.1", - "@types/write-file-atomic": "^4.0.3", + "@types/write-file-atomic": "4.0.3", "@types/ws": "8.18.1", "@vitest/coverage-v8": "4.1.10", "husky": "9.1.7", diff --git a/scripts/generate-completion-manifest.mjs b/scripts/generate-completion-manifest.mjs deleted file mode 100644 index 5ca72288c..000000000 --- a/scripts/generate-completion-manifest.mjs +++ /dev/null @@ -1,283 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { dirname, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import writeFileAtomic from "write-file-atomic"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const outputPath = resolve(root, "metadata/completion-manifest.json"); -const check = process.argv.slice(2).includes("--check"); -const unexpected = process.argv - .slice(2) - .filter((argument) => argument !== "--check"); -if (unexpected.length > 0) - throw new Error(`Unknown argument: ${unexpected.join(" ")}`); - -const artifactPaths = [ - "bridge/hopper_bridge.py", - "package-lock.json", - "scripts/verify-conformance-fixtures.mjs", - "scripts/verify-package.mjs", - "scripts/verify-real-hopper.mjs", - "skills/rea-analysis/SKILL.md", - "src/application/AnalysisProvider.ts", - "src/application/ArtifactExtraction.ts", - "src/application/ArtifactGraphConstruction.ts", - "src/application/ArtifactInventory.ts", - "src/application/CompositeProvider.ts", - "src/application/EvidenceLedger.ts", - "src/application/DirectAnalysis.ts", - "src/application/ProcessSampling.ts", - "src/application/ReferenceSourceImport.ts", - "src/application/ReferenceSourceImportEntries.ts", - "src/application/ReferenceSourceImportPolicy.ts", - "src/application/ReferenceSourceImportTypes.ts", - "src/application/ReferenceSourceVcsAdapter.ts", - "src/application/RealHopperAssertions.ts", - "src/application/runtime.ts", - "src/contracts/nativeToolContracts.ts", - "src/contracts/artifactToolContracts.ts", - "src/contracts/artifactComparisonExample.ts", - "src/contracts/functionComparisonExample.ts", - "src/contracts/investigationExamples.ts", - "src/contracts/toolContractExamples.ts", - "src/contracts/processCaptureExample.ts", - "src/contracts/enhancedInputs.ts", - "src/contracts/toolContracts.ts", - "src/contracts/toolOutputSchemas.ts", - "src/contracts/unknownContractExamples.ts", - "src/domain/evidence.ts", - "src/domain/artifactGraph.ts", - "src/domain/artifactComparison.ts", - "src/domain/artifactInventoryEvidence.ts", - "src/domain/functionComparison.ts", - "src/domain/functionComparisonNormalization.ts", - "src/domain/functionComparisonResults.ts", - "src/domain/functionComparisonSchemas.ts", - "src/domain/bundleComparison.ts", - "src/domain/changedBehavior.ts", - "src/domain/callPath.ts", - "src/domain/staticRuntimeCorrelation.ts", - "src/domain/reconstructionVerification.ts", - "src/domain/reconstructionVerificationSchemas.ts", - "src/domain/reconstructionUnknowns.ts", - "src/domain/referenceSourceGraph.ts", - "src/domain/referenceSourceClassification.ts", - "src/domain/referenceSourceImportParsing.ts", - "src/domain/referenceSourcePolicy.ts", - "src/domain/functionDossierEvidence.ts", - "src/domain/evidenceBundle.ts", - "src/domain/nativeInspection.ts", - "src/domain/processCapture.ts", - "src/domain/residualUnknown.ts", - "src/server/sessionToolPolicies.ts", - "src/server/registerEnhancedTools.ts", - "src/server/registerEvidenceTools.ts", - "src/server/registerArtifactTools.ts", - "src/server/registerArtifactComparisonTool.ts", - "src/server/registerFunctionComparisonTool.ts", - "src/server/registerBundleComparisonTool.ts", - "src/server/registerInvestigationTools.ts", - "src/server/recordDerivedEvidence.ts", - "src/server/registerNativeTools.ts", - "src/server/registerProcessComparisonTool.ts", - "src/server/registerSessionTools.ts", - "src/native/CommandRunner.ts", - "src/native/NativeMacOSProvider.ts", - "src/native/NativeMachoInspection.ts", - "src/native/parsers/codesign.ts", - "src/native/parsers/demangle.ts", - "src/native/parsers/dyldInfo.ts", - "src/native/parsers/lipo.ts", - "src/native/parsers/otool.ts", - "src/native/parsers/plist.ts", - "src/artifacts/ArtifactPaths.ts", - "src/artifacts/ArtifactProvider.ts", - "src/artifacts/ArtifactReader.ts", - "src/artifacts/AsarArtifactReader.ts", - "src/artifacts/DirectoryArtifactReader.ts", - "src/artifacts/MachOSliceArtifactReader.ts", - "src/artifacts/SafeOutputTree.ts", - "src/artifacts/StreamBytes.ts", - "src/artifacts/ZipArtifactReader.ts", - "src/reference/ReferenceSourceReaderEntries.ts", - "src/reference/ReferenceSourceReaderErrors.ts", - "src/reference/ReferenceSourceReaderFile.ts", - "src/reference/ReferenceSourceReaderPaths.ts", - "src/reference/ReferenceSourceReaderTypes.ts", - "src/reference/ReferenceSourceReaderValidate.ts", - "src/reference/ReferenceSourceReader.ts", -]; - -const artifacts = await Promise.all( - artifactPaths.map(async (path) => ({ - path, - sha256: sha256(await readFile(resolve(root, path))), - })), -); -const packageJson = JSON.parse( - await readFile(resolve(root, "package.json"), "utf8"), -); -const toolSource = await readFile( - resolve(root, "src/contracts/toolContracts.ts"), - "utf8", -); -const nativeToolSource = await readFile( - resolve(root, "src/contracts/nativeToolContracts.ts"), - "utf8", -); -const artifactToolSource = await readFile( - resolve(root, "src/contracts/artifactToolContracts.ts"), - "utf8", -); -const skillSource = await readFile( - resolve(root, "skills/rea-analysis/SKILL.md"), - "utf8", -); -const toolCount = - countContracts(toolSource) + - countProviderContracts(nativeToolSource, "NATIVE", "native") + - countProviderContracts(artifactToolSource, "ARTIFACT", "artifact"); -const skillToolCount = Number(/tool_count:\s*(\d+)/u.exec(skillSource)?.[1]); -if (!Number.isSafeInteger(skillToolCount) || skillToolCount !== toolCount) - throw new Error( - `Skill tool_count drift: skill=${String(skillToolCount)} contracts=${toolCount}`, - ); - -const manifest = { - schema_version: 1, - inputs: { - artifacts, - package: { name: packageJson.name, version: packageJson.version }, - providers: [ - { - id: "hopper", - version: null, - authority: "shipped-artifact", - }, - { - id: "native-macos", - version: null, - authority: "shipped-artifact", - }, - { - id: "rea-artifact-graph", - version: "1", - authority: "shipped-artifact", - }, - ], - environments: [ - { id: "package-isolation", isolation: "process" }, - { id: "real-hopper-macos", isolation: "process" }, - ], - scenarios: [ - { id: "conformance-fixtures", version: 1 }, - { id: "package-install", version: 1 }, - { id: "real-hopper-two-binary", version: 2 }, - ], - schemas: { evidence: 2, evidence_bundle: 2, completion: 1 }, - tool_count: toolCount, - }, - evidence_index: [], - outcomes: { - pass: [], - fail: [], - unsupported: [ - { - claim_id: "real-hopper-verification", - reason: - "Generated repository metadata contains no checked-in real-Hopper capture.", - evidence_ids: [], - }, - ], - truncated: [], - unknown: [ - { - claim_id: "semantic-conformance-complete", - reason: - "Completion requires external verifier evidence and cannot be inferred from test counts.", - evidence_ids: [], - }, - ], - }, -}; -validateOutcomeEvidence(manifest); -const serialized = `${JSON.stringify(manifest, null, 2)}\n`; - -if (check) { - let existing; - try { - existing = await readFile(outputPath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") - throw new Error( - `Missing generated manifest: ${relative(root, outputPath)}`, - ); - throw error; - } - if (existing !== serialized) - throw new Error( - `Generated completion metadata is stale or tampered: run npm run completion:generate`, - ); -} else { - await writeFileAtomic(outputPath, serialized, { encoding: "utf8" }); -} - -process.stdout.write( - `${JSON.stringify({ mode: check ? "check" : "generate", path: relative(root, outputPath), toolCount })}\n`, -); - -function sha256(value) { - return createHash("sha256").update(value).digest("hex"); -} - -function countContracts(source) { - const sections = [ - ["OFFICIAL", "official"], - ["ENHANCED", "enhanced"], - ["SESSION", "session"], - ]; - return sections.reduce((total, [section, constructor]) => { - const match = new RegExp( - `export const ${section}_TOOL_CONTRACTS = \\[([\\s\\S]*?)\\n\\] as const satisfies`, - "u", - ).exec(source); - if (match?.[1] === undefined) - throw new Error(`Cannot derive ${section.toLowerCase()} tool contracts`); - return ( - total + - [...match[1].matchAll(new RegExp(`^ ${constructor}\\($`, "gmu"))].length - ); - }, 0); -} - -function countProviderContracts(source, section, constructor) { - const match = new RegExp( - `export const ${section}_TOOL_CONTRACTS = \\[([\\s\\S]*?)\\n\\] as const satisfies`, - "u", - ).exec(source); - if (match?.[1] === undefined) - throw new Error(`Cannot derive ${section.toLowerCase()} tool contracts`); - return [...match[1].matchAll(new RegExp(`^ ${constructor}\\($`, "gmu"))] - .length; -} - -function validateOutcomeEvidence(value) { - const evidenceIds = new Set( - value.evidence_index.map(({ evidence_id }) => evidence_id), - ); - for (const status of ["pass", "fail"]) { - for (const outcome of value.outcomes[status]) { - if (outcome.evidence_ids.length === 0) - throw new Error( - `${status} outcome ${outcome.claim_id} has no evidence`, - ); - for (const evidenceId of outcome.evidence_ids) - if (!evidenceIds.has(evidenceId)) - throw new Error( - `${status} outcome ${outcome.claim_id} references missing evidence ${evidenceId}`, - ); - } - } -} diff --git a/src/application/ArtifactExtraction.ts b/src/application/ArtifactExtraction.ts index 0d0a13018..e3e85cc49 100644 --- a/src/application/ArtifactExtraction.ts +++ b/src/application/ArtifactExtraction.ts @@ -23,7 +23,7 @@ import { type ArtifactOccurrence, } from "../domain/artifactGraph.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; -import { inventoryArtifact } from "./ArtifactInventory.js"; +import { scanArtifactInventory } from "./ArtifactInventory.js"; import { digestCanonical, pageOf, @@ -43,7 +43,7 @@ export interface ArtifactExtractionInput { readonly limits: ArtifactLimits; } -/** Extract only explicitly selected inventory occurrences into an absent root. */ +/** Extract selected inventory occurrences into an exclusively owned absent root. */ export const extractArtifact = async ( input: ArtifactExtractionInput, signal?: AbortSignal, @@ -172,13 +172,6 @@ const materializeSelection = async ({ selected, extracted, ); - const verifiedSource = await inventoryArtifact( - sourcePath, - input.limits, - pageInput(0, 0), - signal, - ); - assertSameManifest(inventory.manifest, verifiedSource.manifest); await output.commit(); return result; } catch (cause: unknown) { @@ -233,62 +226,20 @@ const loadInventory = async ( selectedIds: ReadonlySet, signal?: AbortSignal, ): Promise => { - const first = await inventoryArtifact(path, limits, pageInput(0, 0), signal); + const snapshot = await scanArtifactInventory(path, limits, signal); const occurrences = new Map(); const neededNodes = new Set(); collectOccurrences( - first.occurrences.items, + snapshot.occurrences, selectedIds, occurrences, neededNodes, ); - for ( - let offset = first.occurrences.next_offset; - offset !== null && occurrences.size < selectedIds.size; - offset += PAGE_SIZE - ) { - const page = await inventoryArtifact( - path, - limits, - pageInput(0, offset), - signal, - ); - assertSameManifest(first.manifest, page.manifest); - collectOccurrences( - page.occurrences.items, - selectedIds, - occurrences, - neededNodes, - ); - } const nodes = new Map(); - collectNodes(first.nodes.items, neededNodes, nodes); - for ( - let offset = first.nodes.next_offset; - offset !== null && nodes.size < neededNodes.size; - offset += PAGE_SIZE - ) { - const page = await inventoryArtifact( - path, - limits, - pageInput(offset, 0), - signal, - ); - assertSameManifest(first.manifest, page.manifest); - collectNodes(page.nodes.items, neededNodes, nodes); - } - return { manifest: first.manifest, occurrences, nodes }; + collectNodes(snapshot.nodes, neededNodes, nodes); + return { manifest: snapshot.manifest, occurrences, nodes }; }; -const pageInput = (nodeOffset: number, occurrenceOffset: number) => ({ - nodeOffset, - nodeLimit: PAGE_SIZE, - occurrenceOffset, - occurrenceLimit: PAGE_SIZE, - edgeOffset: 0, - edgeLimit: 1, -}); - const collectOccurrences = ( items: readonly ArtifactOccurrence[], selected: ReadonlySet, @@ -363,14 +314,3 @@ const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => { `Selected artifact exceeds compression ratio limit: ${entry.path}`, ); }; - -const assertSameManifest = ( - expected: ArtifactGraphManifest, - observed: ArtifactGraphManifest, -): void => { - if (expected.manifest_id !== observed.manifest_id) - throw new ArtifactReaderFailure( - "integrity", - "Artifact changed while resolving extraction selection", - ); -}; diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index 56bf13751..080de5458 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -49,6 +49,19 @@ interface InventoryPageInput { readonly edgeLimit: number; } +/** Immutable inventory produced by one complete artifact scan. */ +export interface ArtifactInventorySnapshot { + readonly manifest: ArtifactInventoryResult["manifest"]; + readonly nodes: readonly ArtifactNode[]; + readonly occurrences: ArtifactInventoryResult["occurrences"]["items"]; + readonly edges: ArtifactInventoryResult["edges"]["items"]; + readonly limits: ArtifactInventoryResult["limits"]; + readonly provenance: ReadonlyArray< + ArtifactInventoryResult["provenance"][number] + >; + readonly limitations: readonly string[]; +} + /** Inventory one local artifact without extracting or mounting it. */ export const inventoryArtifact = async ( inputPath: string, @@ -56,6 +69,16 @@ export const inventoryArtifact = async ( page: InventoryPageInput, signal?: AbortSignal, ): Promise => { + const snapshot = await scanArtifactInventory(inputPath, limits, signal); + return paginateArtifactInventory(snapshot, page); +}; + +/** Scan an artifact once and retain the complete immutable graph for projection. */ +export const scanArtifactInventory = async ( + inputPath: string, + limits: ArtifactLimits, + signal?: AbortSignal, +): Promise => { abortIfNeeded(signal); const path = await realpath(inputPath); const metadata = await lstat(path); @@ -122,8 +145,8 @@ export const inventoryArtifact = async ( root_artifact_id: rootNode.artifact_id, graph_sha256: graphSha256, })}`; - return artifactInventoryResultSchema.parse({ - manifest: { + return { + manifest: artifactInventoryResultSchema.shape.manifest.parse({ schema_version: 1, manifest_id: manifestId, root_artifact_id: rootNode.artifact_id, @@ -133,23 +156,35 @@ export const inventoryArtifact = async ( node_count: orderedNodes.length, occurrence_count: orderedOccurrences.length, edge_count: orderedEdges.length, - }, - nodes: pageOf(orderedNodes, page.nodeOffset, page.nodeLimit), - occurrences: pageOf( - orderedOccurrences, - page.occurrenceOffset, - page.occurrenceLimit, - ), - edges: pageOf(orderedEdges, page.edgeOffset, page.edgeLimit), + }), + nodes: orderedNodes, + occurrences: orderedOccurrences, + edges: orderedEdges, limits: toOutputLimits(limits), provenance: reader?.provenance() ?? [], limitations: inventoryLimitations(rootFormat, reader), - }); + }; } finally { await reader?.close(); } }; +/** Project independently paged graph collections from one inventory snapshot. */ +export const paginateArtifactInventory = ( + snapshot: ArtifactInventorySnapshot, + page: InventoryPageInput, +): ArtifactInventoryResult => + artifactInventoryResultSchema.parse({ + ...snapshot, + nodes: pageOf(snapshot.nodes, page.nodeOffset, page.nodeLimit), + occurrences: pageOf( + snapshot.occurrences, + page.occurrenceOffset, + page.occurrenceLimit, + ), + edges: pageOf(snapshot.edges, page.edgeOffset, page.edgeLimit), + }); + const inventoryLimitations = ( format: ArtifactNode["format"], reader: ArtifactReader | undefined, diff --git a/src/application/FilesystemSnapshot.ts b/src/application/FilesystemSnapshot.ts index 8b1dd4866..6cdd36987 100644 --- a/src/application/FilesystemSnapshot.ts +++ b/src/application/FilesystemSnapshot.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import { lstat, open, readdir, readlink } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import type { FileState, ProcessScenario } from "../domain/processCapture.js"; +import type { Stats } from "node:fs"; export interface SnapshotResult { readonly files: readonly FileState[]; @@ -13,9 +14,29 @@ const isWithin = (candidate: string, root: string): boolean => candidate === root || candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`); +const hasSameIdentity = ( + before: Stats, + after: Stats, + expected: "directory" | "symlink", +): boolean => + before.dev === after.dev && + before.ino === after.ino && + (expected === "directory" ? after.isDirectory() : after.isSymbolicLink()); + +const lstatIfPresent = async (path: string): Promise => { + try { + return await lstat(path); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + return undefined; + throw cause; + } +}; + const hashFile = async ( path: string, maxBytes: number, + signal?: AbortSignal, ): Promise => { const handle = await open( path, @@ -24,9 +45,25 @@ const hashFile = async ( try { const stats = await handle.stat(); if (!stats.isFile() || stats.size > maxBytes) return null; - return createHash("sha256") - .update(await handle.readFile()) - .digest("hex"); + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes)); + let position = 0; + while (position < stats.size) { + signal?.throwIfAborted(); + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, stats.size - position), + position, + ); + if (bytesRead === 0) return null; + hash.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + const after = await handle.stat(); + if (after.size !== stats.size || after.mtimeMs !== stats.mtimeMs) + return null; + return hash.digest("hex"); } finally { await handle.close(); } @@ -35,6 +72,7 @@ const hashFile = async ( /** Capture bounded, root-aliased filesystem state without following symlinks. */ export const snapshotRoots = async ( scenario: ProcessScenario, + signal?: AbortSignal, ): Promise => { const entries: FileState[] = []; let remainingBytes = scenario.limits.file_bytes; @@ -45,6 +83,7 @@ export const snapshotRoots = async ( path: string, depth: number, ): Promise => { + signal?.throwIfAborted(); if ( entries.length >= scenario.limits.files || depth > scenario.limits.filesystem_depth @@ -52,17 +91,16 @@ export const snapshotRoots = async ( truncated = true; return; } - let stats; - try { - stats = await lstat(path); - } catch (error: unknown) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") - return; - throw error; - } + const stats = await lstatIfPresent(path); + if (stats === undefined) return; const relativePath = relative(root, path) || "."; if (stats.isSymbolicLink()) { const target = resolve(dirname(path), await readlink(path)); + const afterRead = await lstat(path); + if (!hasSameIdentity(stats, afterRead, "symlink")) { + truncated = true; + return; + } const safeTarget = isWithin(target, root) ? relative(root, target) || "." : ""; @@ -80,7 +118,7 @@ export const snapshotRoots = async ( if (stats.isFile()) { const sha256 = remainingBytes >= stats.size - ? await hashFile(path, remainingBytes) + ? await hashFile(path, remainingBytes, signal) : null; remainingBytes -= sha256 === null ? 0 : stats.size; if (sha256 === null) truncated = true; @@ -104,7 +142,13 @@ export const snapshotRoots = async ( symlink_target: null, }); if (type !== "directory") return; - for (const child of (await readdir(path)).sort()) + const children = await readdir(path); + const afterRead = await lstat(path); + if (!hasSameIdentity(stats, afterRead, "directory")) { + truncated = true; + return; + } + for (const child of children.sort()) await visit(root, rootAlias, join(path, child), depth + 1); }; for (const [index, root] of scenario.filesystem_roots.entries()) diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 21b4097f6..5ec13a6f4 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -183,6 +183,7 @@ interface CaptureResultOptions { readonly truncated: boolean; readonly scenario: ProcessScenario; readonly rootPid: number; + readonly samplingPartial: boolean; } const classifyFilesystemEffects = ( @@ -236,6 +237,9 @@ const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({ truncated: options.truncated, limitations: [ "Process trees are sampled and may omit short-lived descendants.", + ...(options.samplingPartial + ? ["Process-tree sampling ended with an incomplete observation."] + : []), "Filesystem observations are before/after snapshots, not syscall traces.", "The harness does not enforce external network isolation.", ], @@ -269,7 +273,14 @@ const awaitTerminalExit = async ({ }> => new Promise((resolveExit) => { let reason: "exited" | "timeout" | "idle_timeout" | "cancelled" = "exited"; - terminal.onExit((exit) => resolveExit({ ...exit, reason })); + terminal.onExit((exit) => { + for (const timer of timers) { + clearTimeout(timer); + clearInterval(timer); + } + timers.clear(); + resolveExit({ ...exit, reason }); + }); const timeout = setInterval(() => { if (signal?.aborted === true) { reason = "cancelled"; @@ -361,34 +372,51 @@ const resolveProcessResult = ( executionFailure: unknown, cleanupFailure: string | undefined, ): ProcessCapture => { - if (cleanupFailure !== undefined) - throw new ProcessCaptureError(cleanupFailure); if (executionFailure instanceof ProcessCaptureError) throw executionFailure; if (executionFailure !== undefined) throw new ProcessCaptureError("process capture failed", { cause: executionFailure, }); + if (cleanupFailure !== undefined) + throw new ProcessCaptureError(cleanupFailure); if (capture === undefined) throw new ProcessCaptureError("process capture produced no result"); return capture; }; +const prepareProcessCapture = async ( + scenario: ProcessScenario, + policy: ProcessExecutionPolicy, + signal: AbortSignal | undefined, +): Promise<{ + readonly temporaryRoot: string; + readonly runId: string; + readonly home: string; + readonly before: SnapshotResult; +}> => { + const decision = authorizeProcessScenario(scenario, policy); + if (!decision.allowed) throw new ProcessCaptureError(decision.reason); + await assertRealPathAuthority(scenario, policy); + assertNotCancelled(signal); + const before = await snapshotRoots(scenario, signal); + const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-")); + const runId = randomUUID(); + const home = join(temporaryRoot, "home"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(home)); + return { temporaryRoot, runId, home, before }; +}; + /** Execute one authorized scenario and return bounded observations. */ const runProcessScenario = async ( scenario: ProcessScenario, policy: ProcessExecutionPolicy, signal?: AbortSignal, ): Promise => { - const decision = authorizeProcessScenario(scenario, policy); - if (!decision.allowed) throw new ProcessCaptureError(decision.reason); - await assertRealPathAuthority(scenario, policy); - assertNotCancelled(signal); - - const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-")); - const runId = randomUUID(); - const home = join(temporaryRoot, "home"); - await import("node:fs/promises").then(({ mkdir }) => mkdir(home)); - const before = await snapshotRoots(scenario); + const { temporaryRoot, runId, home, before } = await prepareProcessCapture( + scenario, + policy, + signal, + ); const frames: TerminalFrame[] = []; const samples: ProcessSample[] = []; let truncated = before.truncated; @@ -400,7 +428,8 @@ const runProcessScenario = async ( let capture: ProcessCapture | undefined; let executionFailure: unknown; let framesTruncated = (): boolean => false; - let stopSampler: () => Promise = async () => undefined; + let stopSampler = async () => ({ partial: false }); + let samplingPartial = false; try { replay = await startLoopbackReplay(scenario); @@ -439,18 +468,20 @@ const runProcessScenario = async ( timers, }); const exitReason = exit.reason; - await stopSampler(); + samplingPartial = (await stopSampler()).partial; if (exitReason === "cancelled") throw new ProcessCaptureError("process capture was cancelled"); await new Promise((resolveSettle) => setTimeout(resolveSettle, scenario.settle_ms), ); - const after = await snapshotRoots(scenario); + assertNotCancelled(signal); + const after = await snapshotRoots(scenario, signal); truncated ||= after.truncated || replay.truncated || framesTruncated() || samples.length >= scenario.limits.processes; + truncated ||= samplingPartial; capture = buildCaptureResult({ frames, exit: { ...exit, reason: exitReason }, @@ -461,12 +492,13 @@ const runProcessScenario = async ( truncated, scenario, rootPid: terminal.pid, + samplingPartial, }); } catch (cause: unknown) { terminal?.kill("SIGKILL"); executionFailure = cause; } - await stopSampler(); + samplingPartial ||= (await stopSampler()).partial; const cleanupFailure = await releaseProcessResources({ timers, replay, diff --git a/src/application/ProcessSampling.ts b/src/application/ProcessSampling.ts index df307f4e8..87f963916 100644 --- a/src/application/ProcessSampling.ts +++ b/src/application/ProcessSampling.ts @@ -26,6 +26,19 @@ interface SampleProcessContext { readonly signal: AbortSignal; } +const isExpectedProcReadFailure = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || + cause.code === "ESRCH" || + cause.code === "EACCES" || + cause.code === "EPERM"); + +const handleProcReadFailure = (cause: unknown): undefined => { + if (isExpectedProcReadFailure(cause)) return undefined; + throw cause; +}; + const parseProcStat = ( identifier: string, stat: string, @@ -66,8 +79,8 @@ const readProcessStat = async ( signal, }); return parseProcStat(String(pid), stat); - } catch { - return undefined; + } catch (cause: unknown) { + return handleProcReadFailure(cause); } }; @@ -77,8 +90,8 @@ const readProcessCommand = async ( ): Promise => { try { return await readFile(`/proc/${String(pid)}/cmdline`, { signal }); - } catch { - return undefined; + } catch (cause: unknown) { + return handleProcReadFailure(cause); } }; @@ -92,8 +105,8 @@ const readLinuxChildren = async ( encoding: "utf8", signal, }); - } catch { - return undefined; + } catch (cause: unknown) { + return handleProcReadFailure(cause); } return text .trim() @@ -303,12 +316,13 @@ export const startProcessSampler = ( started: number, limit: number, samples: ProcessSample[], -): (() => Promise) => { +): (() => Promise<{ readonly partial: boolean }>) => { const identities = new Map(); const sampledPids = new Set(samples.map(({ pid }) => pid)); let pending: Promise | undefined; let stopped = false; let abortCurrent: (() => void) | undefined; + let partial = false; const sample = (): void => { if (stopped || pending !== undefined || sampledPids.size >= limit) return; @@ -331,7 +345,10 @@ export const startProcessSampler = ( samples.push(value); } }) - .catch(() => undefined) + .catch((cause: unknown) => { + if (!(cause instanceof Error && cause.name === "AbortError")) + partial = true; + }) .then(() => { abortCurrent = undefined; pending = undefined; @@ -346,5 +363,6 @@ export const startProcessSampler = ( clearInterval(timer); abortCurrent?.(); await pending; + return { partial }; }; }; diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index 023c09d11..d68a651a1 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -175,7 +175,7 @@ export const importReferenceSource = async ( ...(options.signal === undefined ? {} : { signal: options.signal }), shouldExclude, }), - readReferenceSourceVcs(root), + readReferenceSourceVcs(root, options.signal), ]); if (!readResult.ok) { diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts index e30e475c0..c33f6d267 100644 --- a/src/application/ReferenceSourceVcsAdapter.ts +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -1,9 +1,15 @@ import fs from "node:fs"; +import { lstat } from "node:fs/promises"; +import { join } from "node:path"; -import { log, statusMatrix } from "isomorphic-git"; +import { resolveRef } from "isomorphic-git"; export type ReferenceSourceVcsInfo = - | { readonly kind: "git"; readonly head: string; readonly dirty: boolean } + | { + readonly kind: "git"; + readonly head: string; + readonly dirty: boolean | null; + } | { readonly kind: "none"; readonly head: null; readonly dirty: null } | { readonly kind: "unknown"; readonly head: null; readonly dirty: null }; @@ -14,42 +20,28 @@ export type ReferenceSourceVcsInfo = */ export const readReferenceSourceVcs = async ( root: string, + signal?: AbortSignal, ): Promise => { + if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; try { - const commits = await log({ - fs, - dir: root, - ref: "HEAD", - depth: 1, - cache: {}, - }); - if (commits.length === 0) - return { kind: "unknown", head: null, dirty: null }; - - const head = commits[0]?.oid; - if (head === undefined) return { kind: "unknown", head: null, dirty: null }; - - let dirty = false; - try { - const matrix = await statusMatrix({ - fs, - dir: root, - ref: head, - cache: {}, - }); - for (const row of matrix) { - const [, headStatus, workdirStatus, stageStatus] = row; - if (headStatus !== 1 || workdirStatus !== 1 || stageStatus !== 1) { - dirty = true; - break; - } - } - } catch { - dirty = true; - } - - return { kind: "git", head, dirty }; + await lstat(join(root, ".git")); + } catch (cause: unknown) { + return errorCode(cause) === "ENOENT" + ? { kind: "none", head: null, dirty: null } + : { kind: "unknown", head: null, dirty: null }; + } + try { + const head = await resolveRef({ fs, dir: root, ref: "HEAD" }); + if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; + return { kind: "git", head, dirty: null }; } catch { - return { kind: "none", head: null, dirty: null }; + return { kind: "unknown", head: null, dirty: null }; } }; + +const errorCode = (cause: unknown): string | undefined => + typeof cause === "object" && cause !== null && "code" in cause + ? String(cause.code) + : undefined; + +const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index bd16fe8b5..a2151185e 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -33,7 +33,7 @@ const IDENTITY: ProviderIdentity = Object.freeze({ version: "1", }); -/** Read-only inventory and transaction-owned extraction provider. */ +/** Read-only inventory and exclusively owned extraction provider. */ export class ArtifactProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze( ARTIFACT_TOOL_CONTRACTS.map((contract) => diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 0b1d5720b..9f2f07163 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -29,7 +29,7 @@ export interface ArtifactReader { close(): Promise; } -/** Hard traversal limits shared by every reader and extraction transaction. */ +/** Hard traversal limits shared by every reader and extraction operation. */ export interface ArtifactLimits { readonly maxEntries: number; readonly maxTotalBytes: number; diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index a2e67a3fc..a023f1f94 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -14,7 +14,7 @@ import { type ArtifactLimits, } from "./ArtifactReader.js"; -/** One file durably written to a transaction-owned output tree. */ +/** One file durably written to an operation-owned output tree. */ export interface SafeOutputFile { readonly relativePath: string; readonly sha256: string; @@ -28,7 +28,7 @@ export interface SafeOutputCleanup { readonly residualPaths: readonly string[]; } -/** Transactional, symlink-resistant materialization beneath an absent path. */ +/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */ export class SafeOutputTree { readonly #registry = new ArtifactPathRegistry(); readonly #limits: ArtifactLimits; @@ -52,7 +52,7 @@ export class SafeOutputTree { this.#limits = limits; } - /** Exclusively create the absent destination as this transaction's owned tree. */ + /** Exclusively create the absent destination as this operation's owned tree. */ static async create( outputRoot: string, limits: ArtifactLimits, @@ -177,7 +177,7 @@ export class SafeOutputTree { } } - /** Durably commit the exclusively-created output tree. */ + /** Sync the owned output tree and prevent further writes through this instance. */ async commit(): Promise { this.#assertWritable(); const parent = await open( @@ -195,7 +195,7 @@ export class SafeOutputTree { } catch (cause: unknown) { throw new ArtifactReaderFailure( "path", - "Could not durably commit extraction output", + "Could not durably sync extraction output", { cause }, ); } finally { @@ -203,7 +203,7 @@ export class SafeOutputTree { } } - /** Remove only this transaction's uncommitted tree and verify absence. */ + /** Remove only this operation's unsealed tree and verify absence. */ async rollback(): Promise { if (this.#published) return this.#cleanup; await rm(this.#stagingRoot, { recursive: true, force: true }); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 10accebb8..a83d733f6 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -189,7 +189,7 @@ const extractedArtifactSchema = z.object({ created: z.boolean(), }); -/** Path-independent commitment to one selected extraction transaction. */ +/** Path-independent commitment to one selected extraction operation. */ const artifactExtractionManifestSchema = z.object({ schema_version: z.literal(1), extraction_id: extractionIdSchema, diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 3df3170fc..b9689c217 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -145,7 +145,7 @@ const vcsSchema = z z.strictObject({ kind: z.literal("git"), head: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u), - dirty: z.boolean(), + dirty: z.boolean().nullable(), }), z.strictObject({ kind: z.literal("none"), diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 3f85545c6..f96ce8cd8 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -7,13 +7,13 @@ import { compareArtifacts, } from "../domain/artifactComparison.js"; import { createEvidence } from "../domain/evidence.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; -import { err } from "../domain/result.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { resolveSessionEvidence } from "./sessionEvidence.js"; import { ARTIFACT_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register Evidence-backed deterministic artifact comparison. */ export const registerArtifactComparisonTool = ( @@ -23,35 +23,21 @@ export const registerArtifactComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = artifactComparisonInputSchema.parse(input); + const left = resolveSessionEvidence(session, parsed.left); + if (!left.ok) return toCallToolResult(left, contract); + const right = resolveSessionEvidence(session, parsed.right); + if (!right.ok) return toCallToolResult(right, contract); const comparison = compareArtifacts( - parsed.left, - parsed.right, + left.value, + right.value, parsed.offset, parsed.limit, ); const leftEvidenceIds = evidenceIds(parsed.left); const rightEvidenceIds = evidenceIds(parsed.right); - if ( - [...leftEvidenceIds, ...rightEvidenceIds].some( - (evidenceId) => !session.hasEvidence(evidenceId), - ) - ) - return toCallToolResult( - err( - new EvidenceIntegrityError( - "Artifact comparison input Evidence is not present in this session", - ), - ), - contract, - ); const evidence = createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, { predicateType: "rea.artifact-comparison/v1", operation: contract.name, diff --git a/src/server/registerBundleComparisonTool.ts b/src/server/registerBundleComparisonTool.ts index 5f95a45dc..eca1d414d 100644 --- a/src/server/registerBundleComparisonTool.ts +++ b/src/server/registerBundleComparisonTool.ts @@ -12,6 +12,8 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; import { BUNDLE_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; +import { isSessionEvidence } from "./sessionEvidence.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register canonical Evidence bundle comparison. */ export const registerBundleComparisonTool = ( @@ -21,12 +23,7 @@ export const registerBundleComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = bundleComparisonInputSchema.parse(input); const sourceIds = [ @@ -35,7 +32,11 @@ export const registerBundleComparisonTool = ( ...parsed.right.records.map(({ evidence_id: id }) => id), ]), ].sort(); - if (sourceIds.some((evidenceId) => !session.hasEvidence(evidenceId))) + if ( + [...parsed.left.records, ...parsed.right.records].some( + (evidence) => !isSessionEvidence(session, evidence), + ) + ) return toCallToolResult( err( new EvidenceIntegrityError( diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index 22760569c..6a509cee0 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -12,6 +12,7 @@ import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; import { toCallToolResult } from "./toolResult.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; interface EvidenceToolRegistration { readonly logger: Logger; @@ -29,12 +30,7 @@ export const registerEvidenceTools = ( for (const contract of contracts) { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), async (input, context) => { const parameters = jsonObject(contract.inputSchema.parse(input)); const execution = await logToolExecution( diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index aa7f782e0..ee8f5f991 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -7,13 +7,13 @@ import { functionComparisonInputSchema, } from "../domain/functionComparison.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { EvidenceIntegrityError } from "../domain/errors.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; -import { err } from "../domain/result.js"; import { FUNCTION_COMPARISON_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; +import { resolveSessionEvidence } from "./sessionEvidence.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register explicit Evidence-backed function comparison. */ export const registerFunctionComparisonTool = ( @@ -23,31 +23,18 @@ export const registerFunctionComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = functionComparisonInputSchema.parse(input); - const leftIds = evidenceIds(parsed.left); - const rightIds = evidenceIds(parsed.right); - const missing = [...leftIds, ...rightIds].filter( - (evidenceId) => !session.hasEvidence(evidenceId), - ); - if (missing.length > 0) - return toCallToolResult( - err( - new EvidenceIntegrityError( - "Function comparison input Evidence is not present in this session", - ), - ), - contract, - ); + const left = resolveSessionEvidence(session, parsed.left); + if (!left.ok) return toCallToolResult(left, contract); + const right = resolveSessionEvidence(session, parsed.right); + if (!right.ok) return toCallToolResult(right, contract); + const leftIds = evidenceIds(left.value); + const rightIds = evidenceIds(right.value); const comparison = compareFunctions( - parsed.left, - parsed.right, + left.value, + right.value, parsed.offset, parsed.limit, ); diff --git a/src/server/registerProcessComparisonTool.ts b/src/server/registerProcessComparisonTool.ts index de647ecf9..c7a45ec49 100644 --- a/src/server/registerProcessComparisonTool.ts +++ b/src/server/registerProcessComparisonTool.ts @@ -16,6 +16,7 @@ import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; import { err } from "../domain/result.js"; import { PROCESS_PROVIDER } from "./sessionToolPolicies.js"; import { toCallToolResult } from "./toolResult.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; /** Register deterministic process-capture comparison and contradiction tracking. */ export const registerProcessComparisonTool = ( @@ -25,12 +26,7 @@ export const registerProcessComparisonTool = ( ): void => { server.registerTool( contract.name, - { - description: contract.description, - inputSchema: contract.inputSchema, - outputSchema: contract.outputSchema, - annotations: contract.annotations, - }, + toolRegistrationOptions(contract), (input) => { const parsed = z .object({ diff --git a/src/server/sessionEvidence.ts b/src/server/sessionEvidence.ts new file mode 100644 index 000000000..d0cef780c --- /dev/null +++ b/src/server/sessionEvidence.ts @@ -0,0 +1,45 @@ +import canonicalize from "canonicalize"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import { EvidenceIntegrityError } from "../domain/errors.js"; +import { parseEvidence, type Evidence } from "../domain/evidence.js"; +import { err, ok, type Result } from "../domain/result.js"; + +type EvidenceAuthorityResult = Result; + +/** Resolve caller-supplied records to their immutable session-owned values. */ +export const resolveSessionEvidence = ( + session: BinarySessionPort, + input: Evidence | readonly Evidence[], +): EvidenceAuthorityResult => { + const supplied = Array.isArray(input) ? input : [input]; + const authoritative: Evidence[] = []; + for (const item of supplied) { + const parsed = parseEvidence(item); + const owned = session.evidenceById(parsed.evidence_id); + if (owned === undefined || canonicalJson(owned) !== canonicalJson(parsed)) + return err( + new EvidenceIntegrityError( + "Comparison input does not match its session-owned Evidence", + ), + ); + authoritative.push(owned); + } + return ok(authoritative); +}; + +/** Check exact ownership for a single Evidence record. */ +export const isSessionEvidence = ( + session: BinarySessionPort, + input: Evidence, +): boolean => { + const owned = session.evidenceById(input.evidence_id); + return owned !== undefined && canonicalJson(owned) === canonicalJson(input); +}; + +const canonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Evidence could not be canonicalized"); + return encoded; +}; diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts new file mode 100644 index 000000000..4a852ed84 --- /dev/null +++ b/src/server/toolRegistrationOptions.ts @@ -0,0 +1,9 @@ +import type { ToolContract } from "../contracts/toolContracts.js"; + +/** Project the public contract fields accepted by MCP tool registration. */ +export const toolRegistrationOptions = (contract: ToolContract) => ({ + description: contract.description, + inputSchema: contract.inputSchema, + outputSchema: contract.outputSchema, + annotations: contract.annotations, +}); diff --git a/tests/artifactInventorySnapshot.test.ts b/tests/artifactInventorySnapshot.test.ts new file mode 100644 index 000000000..32aa95764 --- /dev/null +++ b/tests/artifactInventorySnapshot.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + paginateArtifactInventory, + scanArtifactInventory, +} from "../src/application/ArtifactInventory.js"; + +const LIMITS = { + maxEntries: 10, + maxTotalBytes: 1_024, + maxEntryBytes: 512, + maxCompressionRatio: 10, + maxDepth: 8, + maxPathBytes: 256, +} as const; + +describe("artifact inventory snapshot", () => { + it("projects every page from the same completed scan", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-inventory-snapshot-")); + await writeFile(join(root, "a.txt"), "a"); + await writeFile(join(root, "b.txt"), "b"); + const snapshot = await scanArtifactInventory(root, LIMITS); + + await rm(join(root, "b.txt")); + const first = paginateArtifactInventory(snapshot, { + nodeOffset: 0, + nodeLimit: 1, + occurrenceOffset: 0, + occurrenceLimit: 2, + edgeOffset: 0, + edgeLimit: 1, + }); + const second = paginateArtifactInventory(snapshot, { + nodeOffset: 1, + nodeLimit: 10, + occurrenceOffset: 2, + occurrenceLimit: 2, + edgeOffset: 1, + edgeLimit: 10, + }); + + expect(first.manifest).toEqual(second.manifest); + expect( + [...first.occurrences.items, ...second.occurrences.items].map( + ({ logical_path: path }) => path, + ), + ).toEqual([".", "a.txt", "b.txt"]); + expect(first.occurrences.total).toBe(3); + expect(second.occurrences.total).toBe(3); + }); +}); diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts index 23d07ba0d..8c6e8bbe4 100644 --- a/tests/artifactMcp.test.ts +++ b/tests/artifactMcp.test.ts @@ -16,6 +16,45 @@ import { createServer } from "../src/server/createServer.js"; import { observed } from "./fixtures/analysisExecution.js"; describe("artifact graph MCP integration", () => { + it("rejects altered payloads that reuse session Evidence IDs", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.left); + session.recordEvidence(ARTIFACT_COMPARISON_EXAMPLE.right); + const server = createServer(session, session); + const client = new Client({ + name: "artifact-authority-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_artifacts", + arguments: { + ...ARTIFACT_COMPARISON_EXAMPLE, + left: { + ...ARTIFACT_COMPARISON_EXAMPLE.left, + limitations: ["caller altered this record"], + }, + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + it("rejects comparison Evidence that is not owned by the session", async () => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), diff --git a/tests/bundleComparisonMcp.test.ts b/tests/bundleComparisonMcp.test.ts index b250c029f..767d5210b 100644 --- a/tests/bundleComparisonMcp.test.ts +++ b/tests/bundleComparisonMcp.test.ts @@ -23,6 +23,45 @@ const sourceEvidence = (label: string) => ); describe("bundle comparison MCP integration", () => { + it("rejects altered records that reuse session Evidence IDs", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const leftRecord = sourceEvidence("left-authority"); + const rightRecord = sourceEvidence("right-authority"); + session.recordEvidence(leftRecord); + session.recordEvidence(rightRecord); + const altered = { + ...leftRecord, + limitations: ["caller altered this record"], + }; + const server = createServer(session, session); + const client = new Client({ name: "bundle-authority-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_bundles", + arguments: { + left: createEvidenceBundle([altered]), + right: createEvidenceBundle([rightRecord]), + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); + it("rejects unknown histories that are not owned by the session", async () => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), diff --git a/tests/completionManifest.test.ts b/tests/completionManifest.test.ts deleted file mode 100644 index dfeb7521c..000000000 --- a/tests/completionManifest.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; -import { promisify } from "node:util"; - -import { afterEach, describe, expect, it } from "vitest"; - -const exec = promisify(execFile); -const manifestPath = "metadata/completion-manifest.json"; -let original: string | undefined; - -afterEach(async () => { - if (original !== undefined) await writeFile(manifestPath, original); - original = undefined; -}); - -describe("generated completion manifest", () => { - it("is deterministic, current, path-independent, and never promotes unknowns", async () => { - await exec(process.execPath, ["scripts/generate-completion-manifest.mjs"]); - const first = await readFile(manifestPath, "utf8"); - await exec(process.execPath, ["scripts/generate-completion-manifest.mjs"]); - expect(await readFile(manifestPath, "utf8")).toBe(first); - await expect( - exec(process.execPath, [ - "scripts/generate-completion-manifest.mjs", - "--check", - ]), - ).resolves.toBeDefined(); - expect(first).not.toContain(process.cwd()); - const manifest = JSON.parse(first) as { - outcomes: { pass: unknown[]; unsupported: unknown[]; unknown: unknown[] }; - }; - expect(manifest.outcomes.pass).toEqual([]); - expect(manifest.outcomes.unsupported).toHaveLength(1); - expect(manifest.outcomes.unknown).toHaveLength(1); - }); - - it("check mode detects manual tampering without rewriting it", async () => { - original = await readFile(manifestPath, "utf8"); - const tampered = `${original.trimEnd()} `; - await writeFile(manifestPath, tampered); - await expect( - exec(process.execPath, [ - "scripts/generate-completion-manifest.mjs", - "--check", - ]), - ).rejects.toThrow(/stale or tampered/u); - expect(await readFile(manifestPath, "utf8")).toBe(tampered); - }); -}); diff --git a/tests/functionComparisonMcp.test.ts b/tests/functionComparisonMcp.test.ts index 31c83b9c4..b75eaca1f 100644 --- a/tests/functionComparisonMcp.test.ts +++ b/tests/functionComparisonMcp.test.ts @@ -97,4 +97,43 @@ describe("function comparison MCP integration", () => { ]); } }); + + it("rejects altered payloads that reuse session Evidence IDs", async () => { + const session = new BinarySession(() => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left); + session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.right); + const server = createServer(session, session); + const client = new Client({ + name: "function-authority-test", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "compare_functions", + arguments: { + left: { + ...FUNCTION_COMPARISON_EXAMPLE.left, + limitations: ["caller altered this record"], + }, + right: FUNCTION_COMPARISON_EXAMPLE.right, + }, + }); + expect(result.isError).toBe(true); + expect(session.exportEvidenceBundle().records).toHaveLength(2); + } finally { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + } + }); }); diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index 4dd4ed9cd..b00106b38 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -12,6 +12,7 @@ import { probeProcessCaptureCapability, ProcessCaptureError, } from "../src/application/ProcessHarness.js"; +import { snapshotRoots } from "../src/application/FilesystemSnapshot.js"; import { authorizeProcessScenario, compareProcessCaptures, @@ -31,6 +32,20 @@ describe("process capture domain", () => { working_directory: "/tmp", }; + it("cancels filesystem snapshots before traversing declared roots", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + snapshotRoots( + parseProcessScenario({ + ...base, + filesystem_roots: ["/tmp"], + }), + controller.signal, + ), + ).rejects.toMatchObject({ name: "AbortError" }); + }); + it("parses bounded scenarios and rejects unordered events", () => { expect(parseProcessScenario(base).timeout_ms).toBe(30_000); expect(() => diff --git a/tests/referenceSourceImport.test.ts b/tests/referenceSourceImport.test.ts index 900f527c0..087ad9713 100644 --- a/tests/referenceSourceImport.test.ts +++ b/tests/referenceSourceImport.test.ts @@ -118,7 +118,7 @@ describe("reference source import", () => { const result = await importTree(root, parent); expect(result.ok).toBe(true); if (!result.ok) throw result.error; - expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: true }); + expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: null }); } finally { await rm(parent, { recursive: true, force: true }); } diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index e8e0bb475..bbb48f542 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -58,7 +58,7 @@ describe("production stdio runtime", () => { ); expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON"); expect(stderr).not.toContain(fixturePath); - }, 20_000); + }, 15_000); it("honors the configured kind for an initial database target", async () => { const transport = new StdioClientTransport({ diff --git a/tests/safeOutputTree.test.ts b/tests/safeOutputTree.test.ts index 9333bfb0b..ab8ed3665 100644 --- a/tests/safeOutputTree.test.ts +++ b/tests/safeOutputTree.test.ts @@ -1,4 +1,5 @@ -import { access, mkdtemp, readdir } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { access, mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; @@ -16,19 +17,19 @@ const LIMITS = { maxPathBytes: 256, } as const; -describe("safe artifact output transaction", () => { +describe("safe artifact output tree", () => { it("removes only its owned tree after digest failure and proves absence", async () => { const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-")); const output = join(parent, "published"); - const transaction = await SafeOutputTree.create(output, LIMITS); + const tree = await SafeOutputTree.create(output, LIMITS); await expect( - transaction.write( + tree.write( "nested/file.txt", Readable.from(Buffer.from("unexpected")), "0".repeat(64), ), ).rejects.toThrow(/disagrees/u); - expect(await transaction.rollback()).toMatchObject({ + expect(await tree.rollback()).toMatchObject({ attempted: true, verified: true, residualPaths: [], @@ -36,4 +37,23 @@ describe("safe artifact output transaction", () => { await expect(access(output)).rejects.toThrow(); expect(await readdir(parent)).toEqual([]); }); + + it("publishes files while building and preserves them after sealing", async () => { + const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-")); + const output = join(parent, "published"); + const tree = await SafeOutputTree.create(output, LIMITS); + const bytes = Buffer.from("visible before seal"); + const digest = createHash("sha256").update(bytes).digest("hex"); + + await tree.write("file.txt", Readable.from(bytes), digest); + expect(await readFile(join(output, "file.txt"), "utf8")).toBe( + "visible before seal", + ); + + await tree.commit(); + expect(await tree.rollback()).toMatchObject({ attempted: false }); + expect(await readFile(join(output, "file.txt"), "utf8")).toBe( + "visible before seal", + ); + }); }); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index 79cbaec08..7ef75c713 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -170,7 +170,7 @@ describe("target-free MCP lifecycle", () => { expect( text(await mcp.callTool({ name: "binary_session", arguments: {} })), ).toContain('"open": false'); - }, 20_000); + }, 10_000); it("records approved process residuals in the unknown registry", async () => { const session = new BinarySession(() => client("fixture", []));