Simplify and harden investigation workflows
This commit is contained in:
parent
fbe8ec1f13
commit
293cea7723
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LoadedInventory> => {
|
||||
const first = await inventoryArtifact(path, limits, pageInput(0, 0), signal);
|
||||
const snapshot = await scanArtifactInventory(path, limits, signal);
|
||||
const occurrences = new Map<string, ArtifactOccurrence>();
|
||||
const neededNodes = new Set<string>();
|
||||
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<string, ArtifactNode>();
|
||||
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<string>,
|
||||
|
|
@ -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",
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ArtifactInventoryResult> => {
|
||||
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<ArtifactInventorySnapshot> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Stats | undefined> => {
|
||||
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<string | null> => {
|
||||
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<SnapshotResult> => {
|
||||
const entries: FileState[] = [];
|
||||
let remainingBytes = scenario.limits.file_bytes;
|
||||
|
|
@ -45,6 +83,7 @@ export const snapshotRoots = async (
|
|||
path: string,
|
||||
depth: number,
|
||||
): Promise<void> => {
|
||||
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) || "."
|
||||
: "<outside-declared-root>";
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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<ProcessCapture> => {
|
||||
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<void> = 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,
|
||||
|
|
|
|||
|
|
@ -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<Buffer | undefined> => {
|
||||
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<void>) => {
|
||||
): (() => Promise<{ readonly partial: boolean }>) => {
|
||||
const identities = new Map<number, string>();
|
||||
const sampledPids = new Set<number>(samples.map(({ pid }) => pid));
|
||||
let pending: Promise<void> | 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 };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export const importReferenceSource = async (
|
|||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
shouldExclude,
|
||||
}),
|
||||
readReferenceSourceVcs(root),
|
||||
readReferenceSourceVcs(root, options.signal),
|
||||
]);
|
||||
|
||||
if (!readResult.ok) {
|
||||
|
|
|
|||
|
|
@ -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<ReferenceSourceVcsInfo> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export interface ArtifactReader {
|
|||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<SafeOutputCleanup> {
|
||||
if (this.#published) return this.#cleanup;
|
||||
await rm(this.#stagingRoot, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<Evidence[], EvidenceIntegrityError>;
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", []));
|
||||
|
|
|
|||
Loading…
Reference in New Issue