Simplify and harden investigation workflows

This commit is contained in:
morluto 2026-07-12 21:45:26 +00:00
parent fbe8ec1f13
commit 293cea7723
35 changed files with 531 additions and 1041 deletions

View File

@ -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

View File

@ -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.

View File

@ -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": []
}
]
}
}

6
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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}`,
);
}
}
}

View File

@ -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",
);
};

View File

@ -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,

View File

@ -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())

View File

@ -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,

View File

@ -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 };
};
};

View File

@ -175,7 +175,7 @@ export const importReferenceSource = async (
...(options.signal === undefined ? {} : { signal: options.signal }),
shouldExclude,
}),
readReferenceSourceVcs(root),
readReferenceSourceVcs(root, options.signal),
]);
if (!readResult.ok) {

View File

@ -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)
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: "unknown", head: null, dirty: null };
const head = commits[0]?.oid;
if (head === undefined) return { kind: "unknown", head: null, dirty: null };
let dirty = false;
try {
const matrix = await statusMatrix({
fs,
dir: root,
ref: head,
cache: {},
});
for (const row of matrix) {
const [, headStatus, workdirStatus, stageStatus] = row;
if (headStatus !== 1 || workdirStatus !== 1 || stageStatus !== 1) {
dirty = true;
break;
}
}
} catch {
dirty = true;
}
return { kind: "git", head, dirty };
} catch {
return { kind: "none", head: null, dirty: null };
}
};
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;

View File

@ -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) =>

View File

@ -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;

View File

@ -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 });

View File

@ -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,

View File

@ -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"),

View File

@ -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,

View File

@ -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(

View File

@ -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(

View File

@ -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,
);

View File

@ -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({

View File

@ -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;
};

View File

@ -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,
});

View File

@ -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);
});
});

View File

@ -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(),

View File

@ -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(),

View File

@ -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);
});
});

View File

@ -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(),
]);
}
});
});

View File

@ -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(() =>

View File

@ -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 });
}

View File

@ -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({

View File

@ -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",
);
});
});

View File

@ -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", []));